Merge remote-tracking branch 'origin/calendar/synchro-msgraph' into calendar/finalization

This commit is contained in:
Julien Fastré 2022-06-17 17:04:37 +02:00
commit 03d64995d9
96 changed files with 4505 additions and 305 deletions

View File

@ -1,3 +1,14 @@
{#
WARNING: this file is in use in both ActivityBundle and CalendarBundle.
Take care when editing this file.
Maybe should we think about abstracting this file a bit more ? Moving it to PersonBundle ?
#}
{% if context == 'calendar_accompanyingCourse' %}
{% import "@ChillCalendar/_invite.html.twig" as invite %}
{% endif %}
{% macro href(pathname, key, value) %}
{% set parms = { (key): value } %}
{{ path(pathname, parms) }}
@ -132,6 +143,12 @@
{% if bloc.type == 'user' %}
<span class="badge-user">
{{ item|chill_entity_render_box({'render': 'raw', 'addAltNames': false }) }}
{%- if context == 'calendar_accompanyingCourse' %}
{% set invite = entity.inviteForUser(item) %}
{% if invite is not null %}
{{ invite.invite_span(invite) }}
{% endif %}
{%- endif -%}
</span>
{% else %}
{{ _self.insert_onthefly(bloc.type, item) }}

View File

@ -0,0 +1,164 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class MapAndSubscribeUserCalendarCommand extends Command
{
private EntityManagerInterface $em;
private EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator;
private LoggerInterface $logger;
private MapCalendarToUser $mapCalendarToUser;
private MSGraphUserRepository $userRepository;
public function __construct(
EntityManagerInterface $em,
EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator,
LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser,
MSGraphUserRepository $userRepository
) {
parent::__construct('chill:calendar:msgraph-user-map-subscribe');
$this->em = $em;
$this->eventsOnUserSubscriptionCreator = $eventsOnUserSubscriptionCreator;
$this->logger = $logger;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->userRepository = $userRepository;
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$this->logger->info(__CLASS__ . ' execute command');
$limit = 50;
$offset = 0;
/** @var DateInterval $interval the interval before the end of the expiration */
$interval = new DateInterval('P1D');
$expiration = (new DateTimeImmutable('now'))->add(new DateInterval('PT15M'));
$total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval);
$created = 0;
$renewed = 0;
$this->logger->info(__CLASS__ . ' the number of user to get - renew', [
'total' => $total,
'expiration' => $expiration->format(DateTimeImmutable::ATOM),
]);
while ($offset < ($total - 1)) {
$users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData(
$interval,
$limit,
$offset
);
foreach ($users as $user) {
if (!$this->mapCalendarToUser->hasUserId($user)) {
$this->mapCalendarToUser->writeMetadata($user);
}
if ($this->mapCalendarToUser->hasUserId($user)) {
// we first try to renew an existing subscription, if any.
// if not, or if it fails, we try to create a new one
if ($this->mapCalendarToUser->hasActiveSubscription($user)) {
$this->logger->debug(__CLASS__ . ' renew a subscription for', [
'userId' => $user->getId(),
'username' => $user->getUsernameCanonical(),
]);
['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs]
= $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration);
$this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret);
if (0 !== $expirationTs) {
++$renewed;
} else {
$this->logger->warning(__CLASS__ . ' could not renew subscription for a user', [
'userId' => $user->getId(),
'username' => $user->getUsernameCanonical(),
]);
}
}
if (!$this->mapCalendarToUser->hasActiveSubscription($user)) {
$this->logger->debug(__CLASS__ . ' create a subscription for', [
'userId' => $user->getId(),
'username' => $user->getUsernameCanonical(),
]);
['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs]
= $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration);
$this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret);
if (0 !== $expirationTs) {
++$created;
} else {
$this->logger->warning(__CLASS__ . ' could not create subscription for a user', [
'userId' => $user->getId(),
'username' => $user->getUsernameCanonical(),
]);
}
}
}
++$offset;
}
$this->em->flush();
$this->em->clear();
}
$this->logger->warning(__CLASS__ . ' process executed', [
'created' => $created,
'renewed' => $renewed,
]);
return 0;
}
protected function configure()
{
parent::configure();
$this
->setDescription('MSGraph: collect user metadata and create subscription on events for users')
->addOption(
'renew-before-end-interval',
'r',
InputOption::VALUE_OPTIONAL,
'delay before renewing subscription',
'P1D'
)
->addOption(
'subscription-duration',
's',
InputOption::VALUE_OPTIONAL,
'duration for the subscription',
'PT4230M'
);
}
}

View File

@ -1,58 +0,0 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\MainBundle\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class MapUserCalendarCommand extends Command
{
private EntityManagerInterface $em;
private MapCalendarToUser $mapCalendarToUser;
private UserRepository $userRepository;
public function __construct(EntityManagerInterface $em, MapCalendarToUser $mapCalendarToUser, UserRepository $userRepository)
{
parent::__construct('chill:calendar:map-user');
$this->em = $em;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->userRepository = $userRepository;
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$limit = 2;
$offset = 0;
$total = $this->userRepository->countByNotHavingAttribute(MapCalendarToUser::METADATA_KEY);
while ($offset < $total) {
$users = $this->userRepository->findByNotHavingAttribute(MapCalendarToUser::METADATA_KEY, $limit, $offset);
foreach ($users as $user) {
$this->mapCalendarToUser->writeMetadata($user);
++$offset;
}
$this->em->flush();
$this->em->clear();
}
return 0;
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessageSender;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SendShortMessageOnEligibleCalendar extends Command
{
private BulkCalendarShortMessageSender $messageSender;
public function __construct(BulkCalendarShortMessageSender $messageSender)
{
parent::__construct();
$this->messageSender = $messageSender;
}
public function getName()
{
return 'chill:calendar:send-short-messages';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->messageSender->sendBulkMessageToEligibleCalendars();
return 0;
}
}

View File

@ -0,0 +1,200 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Chill\MainBundle\Service\ShortMessage\ShortMessageTransporterInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use DateInterval;
use DateTimeImmutable;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberType;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use UnexpectedValueException;
use function count;
class SendTestShortMessageOnCalendarCommand extends Command
{
private ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder;
private PersonRepository $personRepository;
private PhoneNumberHelperInterface $phoneNumberHelper;
private PhoneNumberUtil $phoneNumberUtil;
private ShortMessageTransporterInterface $transporter;
private UserRepositoryInterface $userRepository;
public function __construct(
PersonRepository $personRepository,
PhoneNumberUtil $phoneNumberUtil,
PhoneNumberHelperInterface $phoneNumberHelper,
ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder,
ShortMessageTransporterInterface $transporter,
UserRepositoryInterface $userRepository
) {
parent::__construct();
$this->personRepository = $personRepository;
$this->phoneNumberUtil = $phoneNumberUtil;
$this->phoneNumberHelper = $phoneNumberHelper;
$this->messageForCalendarBuilder = $messageForCalendarBuilder;
$this->transporter = $transporter;
$this->userRepository = $userRepository;
}
public function getName()
{
return 'chill:calendar:test-send-short-message';
}
protected function configure()
{
$this->setDescription('Test sending a SMS for a dummy calendar appointment');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$calendar = new Calendar();
$calendar->setSendSMS(true);
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
// start date
$question = new Question('When will start the appointment ? (default: "1 hour") ', '1 hour');
$startDate = new DateTimeImmutable($helper->ask($input, $output, $question));
if (false === $startDate) {
throw new UnexpectedValueException('could not create a date with this date and time');
}
$calendar->setStartDate($startDate);
// end date
$question = new Question('How long will last the appointment ? (default: "PT30M") ', 'PT30M');
$interval = new DateInterval($helper->ask($input, $output, $question));
if (false === $interval) {
throw new UnexpectedValueException('could not create the interval');
}
$calendar->setEndDate($calendar->getStartDate()->add($interval));
// a person
$question = new Question('Who will participate ? Give an id for a person. ');
$question
->setValidator(function ($answer): Person {
if (!is_numeric($answer)) {
throw new UnexpectedValueException('the answer must be numeric');
}
if (0 >= (int) $answer) {
throw new UnexpectedValueException('the answer must be greater than zero');
}
$person = $this->personRepository->find((int) $answer);
if (null === $person) {
throw new UnexpectedValueException('The person is not found');
}
return $person;
});
$person = $helper->ask($input, $output, $question);
$calendar->addPerson($person);
// a main user
$question = new Question('Who will be the main user ? Give an id for a user. ');
$question
->setValidator(function ($answer): User {
if (!is_numeric($answer)) {
throw new UnexpectedValueException('the answer must be numeric');
}
if (0 >= (int) $answer) {
throw new UnexpectedValueException('the answer must be greater than zero');
}
$user = $this->userRepository->find((int) $answer);
if (null === $user) {
throw new UnexpectedValueException('The user is not found');
}
return $user;
});
$user = $helper->ask($input, $output, $question);
$calendar->setMainUser($user);
// phonenumber
$phonenumberFormatted = null !== $person->getMobilenumber() ?
$this->phoneNumberUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164) : '';
$question = new Question(
sprintf('To which number are we going to send this fake message ? (default to: %s)', $phonenumberFormatted),
$phonenumberFormatted
);
$question->setNormalizer(function ($answer): PhoneNumber {
if (null === $answer) {
throw new UnexpectedValueException('The person is not found');
}
$phone = $this->phoneNumberUtil->parse($answer, 'BE');
if (!$this->phoneNumberUtil->isPossibleNumberForType($phone, PhoneNumberType::MOBILE)) {
throw new UnexpectedValueException('Phone number si not a mobile');
}
return $phone;
});
$phone = $helper->ask($input, $output, $question);
$question = new ConfirmationQuestion('really send the message to the phone ?');
$reallySend = (bool) $helper->ask($input, $output, $question);
$messages = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar);
if (0 === count($messages)) {
$output->writeln('no message to send to this user');
}
foreach ($messages as $key => $message) {
$output->writeln("The short message for SMS {$key} will be: ");
$output->writeln($message->getContent());
$message->setPhoneNumber($phone);
if ($reallySend) {
$this->transporter->send($message);
}
}
return 0;
}
}

View File

@ -14,18 +14,20 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Form\CalendarType;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTimeImmutable;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
@ -37,11 +39,11 @@ use Symfony\Component\Serializer\SerializerInterface;
class CalendarController extends AbstractController
{
private AuthorizationHelper $authorizationHelper;
private CalendarACLAwareRepositoryInterface $calendarACLAwareRepository;
private CalendarRepository $calendarRepository;
private EventDispatcherInterface $eventDispatcher;
private FilterOrderHelperFactoryInterface $filterOrderHelperFactory;
private LoggerInterface $logger;
@ -54,18 +56,18 @@ class CalendarController extends AbstractController
private UserRepository $userRepository;
public function __construct(
AuthorizationHelper $authorizationHelper,
CalendarRepository $calendarRepository,
EventDispatcherInterface $eventDispatcher,
CalendarACLAwareRepositoryInterface $calendarACLAwareRepository,
FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
LoggerInterface $logger,
PaginatorFactory $paginator,
RemoteCalendarConnectorInterface $remoteCalendarConnector,
SerializerInterface $serializer,
UserRepository $userRepository
) {
$this->authorizationHelper = $authorizationHelper;
$this->calendarRepository = $calendarRepository;
$this->eventDispatcher = $eventDispatcher;
$this->calendarACLAwareRepository = $calendarACLAwareRepository;
$this->filterOrderHelperFactory = $filterOrderHelperFactory;
$this->logger = $logger;
$this->paginator = $paginator;
$this->remoteCalendarConnector = $remoteCalendarConnector;
@ -165,7 +167,7 @@ class CalendarController extends AbstractController
$params = $this->buildParamsToUrl($user, $accompanyingPeriod);
return $this->redirectToRoute('chill_calendar_calendar_list', $params);
return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params);
}
if ($form->isSubmitted() && !$form->isValid()) {
@ -214,48 +216,31 @@ class CalendarController extends AbstractController
/**
* Lists all Calendar entities.
*
* @Route("/{_locale}/calendar/calendar/", name="chill_calendar_calendar_list")
* @Route("/{_locale}/calendar/calendar/by-period/{id}", name="chill_calendar_calendar_list_by_period")
*/
public function listAction(Request $request): Response
public function listActionByCourse(AccompanyingPeriod $accompanyingPeriod): Response
{
$view = null;
$filterOrder = $this->buildListFilterOrder();
['from' => $from, 'to' => $to] = $filterOrder->getDateRangeData('startDate');
[$user, $accompanyingPeriod] = $this->getEntity($request);
$total = $this->calendarACLAwareRepository
->countByAccompanyingPeriod($accompanyingPeriod, $from, $to);
$paginator = $this->paginator->create($total);
$calendarItems = $this->calendarACLAwareRepository->findByAccompanyingPeriod(
$accompanyingPeriod,
$from,
$to,
['startDate' => 'DESC'],
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
/*
dead code ?
if ($user instanceof User) {
$calendarItems = $this->calendarRepository->findByUser($user);
$view = '@ChillCalendar/Calendar/listByUser.html.twig';
return $this->render($view, [
'calendarItems' => $calendarItems,
'user' => $user,
]);
}
*/
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$total = $this->calendarRepository->countByAccompanyingPeriod($accompanyingPeriod);
$paginator = $this->paginator->create($total);
$calendarItems = $this->calendarRepository->findBy(
['accompanyingPeriod' => $accompanyingPeriod],
['startDate' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$view = '@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig';
return $this->render($view, [
'calendarItems' => $calendarItems,
'accompanyingCourse' => $accompanyingPeriod,
'paginator' => $paginator,
]);
}
throw new Exception('Unable to list actions.');
return $this->render('@ChillCalendar/Calendar/listByAccompanyingCourse.html.twig', [
'calendarItems' => $calendarItems,
'accompanyingCourse' => $accompanyingPeriod,
'paginator' => $paginator,
'filterOrder' => $filterOrder,
]);
}
/**
@ -328,7 +313,7 @@ class CalendarController extends AbstractController
$params = $this->buildParamsToUrl($user, $accompanyingPeriod);
return $this->redirectToRoute('chill_calendar_calendar_list', $params);
return $this->redirectToRoute('chill_calendar_calendar_list_by_period', $params);
}
if ($form->isSubmitted() && !$form->isValid()) {
@ -421,6 +406,14 @@ class CalendarController extends AbstractController
]);
}
private function buildListFilterOrder(): FilterOrderHelper
{
$filterOrder = $this->filterOrderHelperFactory->create(self::class);
$filterOrder->addDateRange('startDate', 'chill_calendar.start date filter', new DateTimeImmutable('3 days ago'), null);
return $filterOrder->build();
}
private function buildParamsToUrl(?User $user, ?AccompanyingPeriod $accompanyingPeriod): array
{
$params = [];
@ -430,7 +423,7 @@ class CalendarController extends AbstractController
}
if (null !== $accompanyingPeriod) {
$params['accompanying_period_id'] = $accompanyingPeriod->getId();
$params['id'] = $accompanyingPeriod->getId();
}
return $params;

View File

@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Messenger\Message\InviteUpdateMessage;
use Chill\CalendarBundle\Security\Voter\InviteVoter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
@ -20,6 +21,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use function in_array;
@ -28,12 +30,15 @@ class InviteApiController
{
private EntityManagerInterface $entityManager;
private MessageBusInterface $messageBus;
private Security $security;
public function __construct(Security $security, EntityManagerInterface $entityManager)
public function __construct(EntityManagerInterface $entityManager, MessageBusInterface $messageBus, Security $security)
{
$this->security = $security;
$this->entityManager = $entityManager;
$this->messageBus = $messageBus;
$this->security = $security;
}
/**
@ -57,13 +62,15 @@ class InviteApiController
throw new AccessDeniedHttpException('not allowed to answer on this invitation');
}
if (!in_array($answer, Invite::STATUSES, true) || Invite::PENDING === $answer) {
if (!in_array($answer, Invite::STATUSES, true)) {
throw new BadRequestHttpException('answer not valid');
}
$invite->setStatus($answer);
$this->entityManager->flush();
$this->messageBus->dispatch(new InviteUpdateMessage($invite, $this->security->getUser()));
return new JsonResponse(null, Response::HTTP_ACCEPTED, [], false);
}
}

View File

@ -44,9 +44,7 @@ class RemoteCalendarConnectAzureController
return $this->clientRegistry
->getClient('azure') // key used in config/packages/knpu_oauth2_client.yaml
->redirect([
'https://graph.microsoft.com/.default', 'offline_access',
]);
->redirect(['https://graph.microsoft.com/.default', 'offline_access'], []);
}
/**
@ -59,7 +57,7 @@ class RemoteCalendarConnectAzureController
try {
/** @var AccessToken $token */
$token = $client->getAccessToken();
$token = $client->getAccessToken([]);
$this->MSGraphTokenStorage->setToken($token);
} catch (IdentityProviderException $e) {

View File

@ -0,0 +1,54 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Messenger\Message\MSGraphChangeNotificationMessage;
use JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use const JSON_THROW_ON_ERROR;
class RemoteCalendarMSGraphSyncController
{
private MessageBusInterface $messageBus;
public function __construct(MessageBusInterface $messageBus)
{
$this->messageBus = $messageBus;
}
/**
* @Route("/public/incoming-hook/calendar/msgraph/events/{userId}", name="chill_calendar_remote_msgraph_incoming_webhook_events",
* methods={"POST"})
*/
public function webhookCalendarReceiver(int $userId, Request $request): Response
{
if ($request->query->has('validationToken')) {
return new Response($request->query->get('validationToken'), Response::HTTP_OK, [
'content-type' => 'text/plain',
]);
}
try {
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BadRequestHttpException('could not decode json', $e);
}
$this->messageBus->dispatch(new MSGraphChangeNotificationMessage($body, $userId));
return new Response('', Response::HTTP_ACCEPTED);
}
}

View File

@ -39,6 +39,12 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf
$loader->load('services/remote_calendar.yaml');
$container->setParameter('chill_calendar', $config);
if ($config['short_messages']['enabled']) {
$container->setParameter('chill_calendar.short_messages', $config['short_messages']);
} else {
$container->setParameter('chill_calendar.short_messages', null);
}
}
public function prepend(ContainerBuilder $container)

View File

@ -26,7 +26,12 @@ class Configuration implements ConfigurationInterface
$treeBuilder = new TreeBuilder('chill_calendar');
$rootNode = $treeBuilder->getRootNode('chill_calendar');
$rootNode->children()
$rootNode
->children()
->arrayNode('short_messages')
->canBeDisabled()
->children()->end()
->end() // end for short_messages
->arrayNode('remote_calendars_sync')->canBeEnabled()
->children()
->arrayNode('microsoft_graph')->canBeEnabled()

View File

@ -38,7 +38,10 @@ use Symfony\Component\Validator\Mapping\ClassMetadata;
use function in_array;
/**
* @ORM\Table(name="chill_calendar.calendar", indexes={@ORM\Index(name="idx_calendar_remote", columns={"remoteId"})}))
* @ORM\Table(
* name="chill_calendar.calendar",
* uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})}
* )
* @ORM\Entity
*/
class Calendar implements TrackCreationInterface, TrackUpdateInterface
@ -49,6 +52,12 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
use TrackUpdateTrait;
public const SMS_CANCEL_PENDING = 'sms_cancel_pending';
public const SMS_PENDING = 'sms_pending';
public const SMS_SENT = 'sms_sent';
public const STATUS_CANCELED = 'canceled';
public const STATUS_MOVED = 'moved';
@ -102,7 +111,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
private CommentEmbeddable $comment;
/**
* @ORM\Column(type="datetimetz_immutable")
* @ORM\Column(type="datetime_immutable", nullable=false)
* @Serializer\Groups({"calendar:read", "read"})
*/
private ?DateTimeImmutable $endDate = null;
@ -166,10 +175,15 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
/**
* @ORM\Column(type="boolean", nullable=true)
*/
private ?bool $sendSMS;
private ?bool $sendSMS = null;
/**
* @ORM\Column(type="datetimetz_immutable")
* @ORM\Column(type="text", nullable=false, options={"default": Calendar::SMS_PENDING})
*/
private string $smsStatus = self::SMS_PENDING;
/**
* @ORM\Column(type="datetime_immutable", nullable=false)
* @Serializer\Groups({"calendar:read", "read"})
*/
private ?DateTimeImmutable $startDate = null;
@ -365,6 +379,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
return $this->sendSMS;
}
public function getSmsStatus(): string
{
return $this->smsStatus;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
@ -486,7 +505,10 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
}
$this->calendarRange = $calendarRange;
$this->calendarRange->setCalendar($this);
if ($this->calendarRange instanceof CalendarRange) {
$this->calendarRange->setCalendar($this);
}
return $this;
}
@ -545,6 +567,13 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function setSmsStatus(string $smsStatus): self
{
$this->smsStatus = $smsStatus;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
@ -556,6 +585,10 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
{
$this->status = $status;
if (self::STATUS_CANCELED === $status && $this->getSmsStatus() === self::SMS_SENT) {
$this->setSmsStatus(self::SMS_CANCEL_PENDING);
}
return $this;
}
}

View File

@ -21,7 +21,10 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Table(name="chill_calendar.calendar_range", indexes={@ORM\Index(name="idx_calendar_range_remote", columns={"remoteId"})})
* @ORM\Table(
* name="chill_calendar.calendar_range",
* uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_range_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})}
* )
* @ORM\Entity
*/
class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
@ -38,7 +41,7 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
private ?Calendar $calendar = null;
/**
* @ORM\Column(type="datetimetz_immutable")
* @ORM\Column(type="datetime_immutable", nullable=false)
* @groups({"read", "write", "calendar:read"})
*/
private ?DateTimeImmutable $endDate = null;
@ -52,7 +55,7 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
private $id;
/**
* @ORM\Column(type="datetimetz_immutable")
* @ORM\Column(type="datetime_immutable", nullable=false)
* @groups({"read", "write", "calendar:read"})
*/
private ?DateTimeImmutable $startDate = null;
@ -63,7 +66,7 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
*/
private ?User $user = null;
public function getCalendar(): Calendar
public function getCalendar(): ?Calendar
{
return $this->calendar;
}

View File

@ -21,11 +21,21 @@ use LogicException;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* @ORM\Table(name="chill_calendar.invite")
* An invitation for another user to a Calendar.
*
* The event/calendar in the user may have a different id than the mainUser. We add then fields to store the
* remote id of this event in the remote calendar.
*
* @ORM\Table(
* name="chill_calendar.invite",
* uniqueConstraints={@ORM\UniqueConstraint(name="idx_calendar_invite_remote", columns={"remoteId"}, options={"where": "remoteId <> ''"})}
* )
* @ORM\Entity
*/
class Invite implements TrackUpdateInterface, TrackCreationInterface
{
use RemoteCalendarTrait;
use TrackCreationTrait;
use TrackUpdateTrait;

View File

@ -18,12 +18,9 @@ use Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer;
use Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer;
use Chill\MainBundle\Form\DataTransformer\IdToUsersDataTransformer;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\PrivateCommentType;
use Chill\PersonBundle\Form\DataTransformer\PersonsToIdDataTransformer;
use Chill\ThirdPartyBundle\Form\DataTransformer\ThirdPartiesToIdDataTransformer;
use Chill\MainBundle\Form\Type\PrivateCommentType;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTimeImmutable;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;

View File

@ -37,9 +37,9 @@ class AccompanyingCourseMenuBuilder implements LocalMenuBuilderInterface
if ($this->security->isGranted(CalendarVoter::SEE, $period)) {
$menu->addChild($this->translator->trans('Calendar'), [
'route' => 'chill_calendar_calendar_list',
'route' => 'chill_calendar_calendar_list_by_period',
'routeParameters' => [
'accompanying_period_id' => $period->getId(),
'id' => $period->getId(),
], ])
->setExtras(['order' => 35]);
}

View File

@ -0,0 +1,48 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\InviteUpdateMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\InviteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* @AsMessageHandler
*/
class InviteUpdateHandler implements MessageHandlerInterface
{
private EntityManagerInterface $em;
private InviteRepository $inviteRepository;
private RemoteCalendarConnectorInterface $remoteCalendarConnector;
public function __construct(EntityManagerInterface $em, InviteRepository $inviteRepository, RemoteCalendarConnectorInterface $remoteCalendarConnector)
{
$this->em = $em;
$this->inviteRepository = $inviteRepository;
$this->remoteCalendarConnector = $remoteCalendarConnector;
}
public function __invoke(InviteUpdateMessage $inviteUpdateMessage): void
{
if (null === $invite = $this->inviteRepository->find($inviteUpdateMessage->getInviteId())) {
return;
}
$this->remoteCalendarConnector->syncInvite($invite);
$this->em->flush();
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Handler;
use Chill\CalendarBundle\Messenger\Message\MSGraphChangeNotificationMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarRangeSyncer;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarSyncer;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* Handle notification of chagnes from MSGraph.
*
* @AsMessageHandler
*/
class MSGraphChangeNotificationHandler implements MessageHandlerInterface
{
private CalendarRangeRepository $calendarRangeRepository;
private CalendarRangeSyncer $calendarRangeSyncer;
private CalendarRepository $calendarRepository;
private CalendarSyncer $calendarSyncer;
private EntityManagerInterface $em;
private LoggerInterface $logger;
private MapCalendarToUser $mapCalendarToUser;
private UserRepository $userRepository;
public function __construct(
CalendarRangeRepository $calendarRangeRepository,
CalendarRangeSyncer $calendarRangeSyncer,
CalendarRepository $calendarRepository,
CalendarSyncer $calendarSyncer,
EntityManagerInterface $em,
LoggerInterface $logger,
MapCalendarToUser $mapCalendarToUser,
UserRepository $userRepository
) {
$this->calendarRangeRepository = $calendarRangeRepository;
$this->calendarRangeSyncer = $calendarRangeSyncer;
$this->calendarRepository = $calendarRepository;
$this->calendarSyncer = $calendarSyncer;
$this->em = $em;
$this->logger = $logger;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->userRepository = $userRepository;
}
public function __invoke(MSGraphChangeNotificationMessage $changeNotificationMessage): void
{
$user = $this->userRepository->find($changeNotificationMessage->getUserId());
if (null === $user) {
$this->logger->warning(__CLASS__ . ' notification concern non-existent user, skipping');
return;
}
foreach ($changeNotificationMessage->getContent()['value'] as $notification) {
$secret = $this->mapCalendarToUser->getSubscriptionSecret($user);
if ($secret !== ($notification['clientState'] ?? -1)) {
$this->logger->warning(__CLASS__ . ' could not validate secret, skipping');
continue;
}
$remoteId = $notification['resourceData']['id'];
// is this a calendar range ?
if (null !== $calendarRange = $this->calendarRangeRepository->findOneBy(['remoteId' => $remoteId])) {
$this->calendarRangeSyncer->handleCalendarRangeSync($calendarRange, $notification, $user);
$this->em->flush();
} elseif (null !== $calendar = $this->calendarRepository->findOneBy(['remoteId' => $remoteId])) {
$this->calendarSyncer->handleCalendarSync($calendar, $notification, $user);
$this->em->flush();
} else {
$this->logger->info(__CLASS__ . ' id not found in any calendar nor calendar range');
}
}
$this->em->flush();
}
}

View File

@ -0,0 +1,38 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
class InviteUpdateMessage
{
private int $byUserId;
private int $inviteId;
public function __construct(Invite $invite, User $byUser)
{
$this->inviteId = $invite->getId();
$this->byUserId = $byUser->getId();
}
public function getByUserId(): int
{
return $this->byUserId;
}
public function getInviteId(): int
{
return $this->inviteId;
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Messenger\Message;
class MSGraphChangeNotificationMessage
{
private array $content;
private int $userId;
public function __construct(array $content, int $userId)
{
$this->content = $content;
$this->userId = $userId;
}
public function getContent(): array
{
return $this->content;
}
public function getUserId(): int
{
return $this->userId;
}
}

View File

@ -0,0 +1,133 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
/**
* Create a subscription for a user.
*/
class EventsOnUserSubscriptionCreator
{
private LoggerInterface $logger;
private MachineHttpClient $machineHttpClient;
private MapCalendarToUser $mapCalendarToUser;
private UrlGeneratorInterface $urlGenerator;
public function __construct(
LoggerInterface $logger,
MachineHttpClient $machineHttpClient,
MapCalendarToUser $mapCalendarToUser,
UrlGeneratorInterface $urlGenerator
) {
$this->logger = $logger;
$this->machineHttpClient = $machineHttpClient;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->urlGenerator = $urlGenerator;
}
/**
* @throws ClientExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
*
* @return array<secret: string, id: string, expiration: int>
*/
public function createSubscriptionForUser(User $user, DateTimeImmutable $expiration): array
{
if (null === $userId = $this->mapCalendarToUser->getUserId($user)) {
throw new LogicException('no user id');
}
$subscription = [
'changeType' => 'deleted,updated',
'notificationUrl' => $this->urlGenerator->generate(
'chill_calendar_remote_msgraph_incoming_webhook_events',
['userId' => $user->getId()],
UrlGeneratorInterface::ABSOLUTE_URL
),
'resource' => "/users/{$userId}/calendar/events",
'clientState' => $secret = base64_encode(openssl_random_pseudo_bytes(92, $cstrong)),
'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM),
];
try {
$subs = $this->machineHttpClient->request(
'POST',
'/v1.0/subscriptions',
[
'json' => $subscription,
]
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->error('could not create subscription for user events', [
'body' => $e->getResponse()->getContent(false),
]);
return ['secret' => '', 'id' => '', 'expiration' => 0];
}
return ['secret' => $secret, 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()];
}
/**
* @throws ClientExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
*
* @return array<secret: string, id: string, expiration: int>
*/
public function renewSubscriptionForUser(User $user, DateTimeImmutable $expiration): array
{
if (null === $userId = $this->mapCalendarToUser->getUserId($user)) {
throw new LogicException('no user id');
}
if (null === $subscriptionId = $this->mapCalendarToUser->getActiveSubscriptionId($user)) {
throw new LogicException('no user id');
}
$subscription = [
'expirationDateTime' => $expiration->format(DateTimeImmutable::ATOM),
];
try {
$subs = $this->machineHttpClient->request(
'PATCH',
"/v1.0/subscriptions/{$subscriptionId}",
[
'json' => $subscription,
]
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->error('could not patch subscription for user events', [
'body' => $e->getResponse()->getContent(false),
]);
return ['secret' => '', 'id' => '', 'expiration' => 0];
}
return ['secret' => $subs['clientState'], 'id' => $subs['id'], 'expiration' => $expiration->getTimestamp()];
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateInterval;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use function strtr;
/**
* Contains classes and methods for fetching users with some calendar metadatas.
*/
class MSGraphUserRepository
{
private const MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH = <<<'SQL'
select
{select}
from users u
where
NOT attributes ?? 'msgraph'
OR NOT attributes->'msgraph' ?? 'subscription_events_expiration'
OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval)))
LIMIT :limit OFFSET :offset
;
SQL;
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int
{
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [
'{select}' => 'COUNT(u) AS c',
'LIMIT :limit OFFSET :offset' => '',
]);
return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([
'interval' => $interval,
])->getSingleScalarResult();
}
/**
* @return array|User[]
*/
public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array
{
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(User::class, 'u');
return $this->entityManager->createNativeQuery(
strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]),
$rsm
)->setParameters([
'interval' => $interval,
'limit' => $limit,
'offset' => $offset,
])->getResult();
}
}

View File

@ -34,6 +34,10 @@ class MachineHttpClient implements HttpClientInterface
$this->machineTokenStorage = $machineTokenStorage;
}
/**
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @throws LogicException if method is not supported
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options['headers'] = array_merge(

View File

@ -12,15 +12,24 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
use Psr\Log\LoggerInterface;
use function array_key_exists;
/**
* Write metadata to user, which allow to find his default calendar.
*/
class MapCalendarToUser
{
public const EXPIRATION_SUBSCRIPTION_EVENT = 'subscription_events_expiration';
public const ID_SUBSCRIPTION_EVENT = 'subscription_events_id';
public const METADATA_KEY = 'msgraph';
public const SECRET_SUBSCRIPTION_EVENT = 'subscription_events_secret';
private LoggerInterface $logger;
private MachineHttpClient $machineHttpClient;
@ -33,6 +42,19 @@ class MapCalendarToUser
$this->logger = $logger;
}
public function getActiveSubscriptionId(User $user): string
{
if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) {
throw new LogicException('do not contains msgraph metadata');
}
if (!array_key_exists(self::ID_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) {
throw new LogicException('do not contains metadata for subscription id');
}
return $user->getAttributes()[self::METADATA_KEY][self::ID_SUBSCRIPTION_EVENT];
}
public function getCalendarId(User $user): ?string
{
if (null === $msKey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) {
@ -51,6 +73,19 @@ class MapCalendarToUser
return $value[0] ?? null;
}
public function getSubscriptionSecret(User $user): string
{
if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) {
throw new LogicException('do not contains msgraph metadata');
}
if (!array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) {
throw new LogicException('do not contains secret in msgraph');
}
return $user->getAttributes()[self::METADATA_KEY][self::SECRET_SUBSCRIPTION_EVENT];
}
public function getUserByEmail(string $email): ?array
{
$value = $this->machineHttpClient->request('GET', 'users', [
@ -69,16 +104,48 @@ class MapCalendarToUser
return $msKey['id'] ?? null;
}
public function hasActiveSubscription(User $user): bool
{
if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) {
return false;
}
if (!array_key_exists(self::EXPIRATION_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) {
return false;
}
return $user->getAttributes()[self::METADATA_KEY][self::EXPIRATION_SUBSCRIPTION_EVENT]
>= (new DateTimeImmutable('now'))->getTimestamp();
}
public function hasSubscriptionSecret(User $user): bool
{
if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) {
return false;
}
return array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY]);
}
public function hasUserId(User $user): bool
{
if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) {
return false;
}
return array_key_exists('id', $user->getAttributes()[self::METADATA_KEY]);
}
public function writeMetadata(User $user): User
{
if (null === $userData = $this->getUserByEmail($user->getEmailCanonical())) {
$this->logger->warning('[MapCalendarToUser] could find user on msgraph', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]);
$this->logger->warning('[MapCalendarToUser] could not find user on msgraph', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]);
return $this->writeNullData($user);
}
if (null === $defaultCalendar = $this->getDefaultUserCalendar($userData['id'])) {
$this->logger->warning('[MapCalendarToUser] could find default calendar', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]);
$this->logger->warning('[MapCalendarToUser] could not find default calendar', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]);
return $this->writeNullData($user);
}
@ -90,6 +157,26 @@ class MapCalendarToUser
]]);
}
/**
* @param int $expiration the expiration time as unix timestamp
*/
public function writeSubscriptionMetadata(
User $user,
int $expiration,
?string $id = null,
?string $secret = null
): void {
$user->setAttributeByDomain(self::METADATA_KEY, self::EXPIRATION_SUBSCRIPTION_EVENT, $expiration);
if (null !== $id) {
$user->setAttributeByDomain(self::METADATA_KEY, self::ID_SUBSCRIPTION_EVENT, $id);
}
if (null !== $secret) {
$user->setAttributeByDomain(self::METADATA_KEY, self::SECRET_SUBSCRIPTION_EVENT, $secret);
}
}
private function writeNullData(User $user): User
{
return $user->unsetAttribute(self::METADATA_KEY);

View File

@ -19,6 +19,7 @@ use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -28,10 +29,16 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class RemoteEventConverter
{
/**
* valid when the remote string contains also a timezone, like in
* lastModifiedDate.
*/
public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P';
private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0';
private const REMOTE_DATETIME_WITHOUT_TZ_FORMAT = 'Y-m-d\TH:i:s.u?';
private DateTimeZone $defaultDateTimeZone;
private EngineInterface $engine;
@ -112,6 +119,7 @@ class RemoteEventConverter
['calendar' => $calendar]
),
],
'responseRequested' => true,
],
$this->calendarToEventAttendeesOnly($calendar)
);
@ -146,6 +154,34 @@ class RemoteEventConverter
);
}
public static function convertStringDateWithoutTimezone(string $date): DateTimeImmutable
{
$d = DateTimeImmutable::createFromFormat(
self::REMOTE_DATETIME_WITHOUT_TZ_FORMAT,
$date,
self::getRemoteTimeZone()
);
if (false === $d) {
throw new RuntimeException("could not convert string date to datetime: {$date}");
}
return $d->setTimezone((new DateTimeImmutable())->getTimezone());
}
public static function convertStringDateWithTimezone(string $date): DateTimeImmutable
{
$d = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $date);
if (false === $d) {
throw new RuntimeException("could not convert string date to datetime: {$date}");
}
$d->setTimezone((new DateTimeImmutable())->getTimezone());
return $d;
}
public function convertToRemote(array $event): RemoteEvent
{
$startDate =

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarRangeSyncer
{
private EntityManagerInterface $em;
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
/**
* @param MachineHttpClient $machineHttpClient
*/
public function __construct(
EntityManagerInterface $em,
LoggerInterface $logger,
HttpClientInterface $machineHttpClient
) {
$this->em = $em;
$this->logger = $logger;
$this->machineHttpClient = $machineHttpClient;
}
public function handleCalendarRangeSync(CalendarRange $calendarRange, array $notification, User $user): void
{
switch ($notification['changeType']) {
case 'deleted':
// test if the notification is not linked to a Calendar
if (null !== $calendarRange->getCalendar()) {
return;
}
$calendarRange->preventEnqueueChanges = true;
$this->logger->info(__CLASS__ . ' remove a calendar range because deleted on remote calendar');
$this->em->remove($calendarRange);
break;
case 'updated':
try {
$new = $this->machineHttpClient->request(
'GET',
$notification['resource']
)->toArray();
} catch (ClientExceptionInterface $clientException) {
$this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [
'calendarRangeId' => $calendarRange->getId(),
'remoteEventId' => $notification['resource'],
]);
throw $clientException;
}
$lastModified = RemoteEventConverter::convertStringDateWithTimezone($new['lastModifiedDateTime']);
if ($calendarRange->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) {
$this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [
'calendarRangeId' => $calendarRange->getId(),
'remoteEventId' => $notification['resource'],
]);
return;
}
$startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']);
$endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']);
$calendarRange
->setStartDate($startDate)->setEndDate($endDate)
->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified->getTimestamp(),
'changeKey' => $new['changeKey'],
])
->preventEnqueueChanges = true;
break;
default:
throw new RuntimeException('This changeType is not suppored: ' . $notification['changeType']);
}
}
}

View File

@ -0,0 +1,182 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use LogicException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use function in_array;
class CalendarSyncer
{
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
private UserRepositoryInterface $userRepository;
public function __construct(LoggerInterface $logger, HttpClientInterface $machineHttpClient, UserRepositoryInterface $userRepository)
{
$this->logger = $logger;
$this->machineHttpClient = $machineHttpClient;
$this->userRepository = $userRepository;
}
public function handleCalendarSync(Calendar $calendar, array $notification, User $user): void
{
switch ($notification['changeType']) {
case 'deleted':
$this->handleDeleteCalendar($calendar, $notification, $user);
break;
case 'updated':
$this->handleUpdateCalendar($calendar, $notification, $user);
break;
default:
throw new RuntimeException('this change type is not supported: ' . $notification['changeType']);
}
}
private function handleDeleteCalendar(Calendar $calendar, array $notification, User $user): void
{
$calendar
->setStatus(Calendar::STATUS_CANCELED)
->setCalendarRange(null);
$calendar->preventEnqueueChanges = true;
}
private function handleUpdateCalendar(Calendar $calendar, array $notification, User $user): void
{
try {
$new = $this->machineHttpClient->request(
'GET',
$notification['resource']
)->toArray();
} catch (ClientExceptionInterface $clientException) {
$this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [
'calendarId' => $calendar->getId(),
'remoteEventId' => $notification['resource'],
]);
throw $clientException;
}
if (false === $new['isOrganizer']) {
return;
}
$lastModified = RemoteEventConverter::convertStringDateWithTimezone(
$new['lastModifiedDateTime']
);
if ($calendar->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) {
$this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [
'calendarRangeId' => $calendar->getId(),
'remoteEventId' => $notification['resource'],
]);
return;
}
$this->syncAttendees($calendar, $new['attendees']);
$startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']);
$endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']);
if ($startDate->getTimestamp() !== $calendar->getStartDate()->getTimestamp()) {
$calendar->setStartDate($startDate)->setStatus(Calendar::STATUS_MOVED);
}
if ($endDate->getTimestamp() !== $calendar->getEndDate()->getTimestamp()) {
$calendar->setEndDate($endDate)->setStatus(Calendar::STATUS_MOVED);
}
$calendar
->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified->getTimestamp(),
'changeKey' => $new['changeKey'],
])
->preventEnqueueChanges = true;
}
private function syncAttendees(Calendar $calendar, array $attendees): void
{
$emails = [];
foreach ($attendees as $attendee) {
$status = $attendee['status']['response'];
if ('organizer' === $status) {
continue;
}
$email = $attendee['emailAddress']['address'];
$emails[] = strtolower($email);
$user = $this->userRepository->findOneByUsernameOrEmail($email);
if (null === $user) {
continue;
}
if (!$calendar->isInvited($user)) {
$calendar->addUser($user);
}
$invite = $calendar->getInviteForUser($user);
switch ($status) {
// possible cases: none, organizer, tentativelyAccepted, accepted, declined, notResponded.
case 'none':
case 'notResponded':
$invite->setStatus(Invite::PENDING);
break;
case 'tentativelyAccepted':
$invite->setStatus(Invite::TENTATIVELY_ACCEPTED);
break;
case 'accepted':
$invite->setStatus(Invite::ACCEPTED);
break;
case 'declined':
$invite->setStatus(Invite::DECLINED);
break;
default:
throw new LogicException('should not happens, not implemented: ' . $status);
break;
}
}
foreach ($calendar->getUsers() as $user) {
if (!in_array(strtolower($user->getEmailCanonical()), $emails, true)) {
$calendar->removeUser($user);
}
}
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserHttpClient;
@ -209,6 +210,67 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
}
}
public function syncInvite(Invite $invite): void
{
if ('' === $remoteId = $invite->getCalendar()->getRemoteId()) {
return;
}
if (null === $invite->getUser()) {
return;
}
if (null === $userId = $this->mapCalendarToUser->getUserId($invite->getUser())) {
return;
}
if ($invite->hasRemoteId()) {
$remoteIdAttendeeCalendar = $invite->getRemoteId();
} else {
$remoteIdAttendeeCalendar = $this->findRemoteIdOnUserCalendar($invite->getCalendar(), $invite->getUser());
$invite->setRemoteId($remoteIdAttendeeCalendar);
}
switch ($invite->getStatus()) {
case Invite::PENDING:
return;
case Invite::ACCEPTED:
$url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/accept";
break;
case Invite::TENTATIVELY_ACCEPTED:
$url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/tentativelyAccept";
break;
case Invite::DECLINED:
$url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/decline";
break;
default:
throw new Exception('not supported');
}
try {
$this->machineHttpClient->request(
'POST',
$url,
['json' => ['sendResponse' => true]]
);
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'content' => $e->getResponse()->getContent(),
'calendarRangeId' => 'invite_' . $invite->getId(),
]);
throw $e;
}
}
private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void
{
$userId = $this->mapCalendarToUser->getUserId($user);
@ -273,7 +335,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
'calendar_identifier' => $identifier,
]);
return [];
return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
}
try {
@ -333,6 +395,44 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
]);
}
/**
* the remoteId is not the same across different user calendars. This method allow to find
* the correct remoteId in another calendar.
*
* For achieving this, the iCalUid is used.
*/
private function findRemoteIdOnUserCalendar(Calendar $calendar, User $user): ?string
{
// find the icalUid on original user
$event = $this->getOnRemote($calendar->getMainUser(), $calendar->getRemoteId());
$userId = $this->mapCalendarToUser->getUserId($user);
if ('' === $iCalUid = ($event['iCalUId'] ?? '')) {
throw new Exception('no iCalUid for this event');
}
try {
$events = $this->machineHttpClient->request(
'GET',
"/v1.0/users/{$userId}/calendar/events",
[
'query' => [
'$select' => 'id',
'$filter' => "iCalUId eq '{$iCalUid}'",
],
]
)->toArray();
} catch (ClientExceptionInterface $clientException) {
throw $clientException;
}
if (1 !== count($events['value'])) {
throw new Exception('multiple events found with same iCalUid');
}
return $events['value'][0]['id'];
}
private function getOnRemote(User $user, string $remoteId): array
{
$userId = $this->mapCalendarToUser->getUserId($user);
@ -345,10 +445,13 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
}
try {
return $this->machineHttpClient->request(
$v = $this->machineHttpClient->request(
'GET',
'users/' . $userId . '/calendar/events/' . $remoteId
)->toArray();
dump($v);
return $v;
} catch (ClientExceptionInterface $e) {
$this->logger->warning('Could not get event from calendar', [
'remoteId' => $remoteId,
@ -409,7 +512,8 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
$eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);
if (0 < count($newInvites)) {
$eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
// it seems that invitaiton are always send, even if attendee changes are mixed with other datas
// $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
}
foreach ($eventDatas as $eventData) {
@ -446,7 +550,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
'calendar_identifier' => $identifier,
]);
return [];
return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
}
try {

View File

@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use LogicException;
@ -46,4 +47,8 @@ class NullRemoteCalendarConnector implements RemoteCalendarConnectorInterface
public function syncCalendarRange(CalendarRange $calendarRange): void
{
}
public function syncInvite(Invite $invite): void
{
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
@ -46,4 +47,6 @@ interface RemoteCalendarConnectorInterface
public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void;
public function syncCalendarRange(CalendarRange $calendarRange): void;
public function syncInvite(Invite $invite): void;
}

View File

@ -12,14 +12,17 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\RemoteCalendar\DependencyInjection;
use Chill\CalendarBundle\Command\AzureGrantAdminConsentAndAcquireToken;
use Chill\CalendarBundle\Command\MapUserCalendarCommand;
use Chill\CalendarBundle\Command\MapAndSubscribeUserCalendarCommand;
use Chill\CalendarBundle\Controller\RemoteCalendarConnectAzureController;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use RuntimeException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use TheNetworg\OAuth2\Client\Provider\Azure;
class RemoteCalendarCompilerPass implements CompilerPassInterface
@ -35,11 +38,16 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) {
$connector = MSGraphRemoteCalendarConnector::class;
$container->setAlias(HttpClientInterface::class . ' $machineHttpClient', MachineHttpClient::class);
} else {
// remove services which cannot be loaded
$container->removeDefinition(MapUserCalendarCommand::class);
$container->removeDefinition(MapAndSubscribeUserCalendarCommand::class);
$container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class);
$container->removeDefinition(RemoteCalendarConnectAzureController::class);
$container->removeDefinition(MachineTokenStorage::class);
$container->removeDefinition(MachineHttpClient::class);
$container->removeDefinition(MSGraphRemoteCalendarConnector::class);
}
if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) {
@ -58,7 +66,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
->setDecoratedService(RemoteCalendarConnectorInterface::class);
} else {
// keep the container lighter by removing definitions
$container->removeDefinition($serviceId);
if ($container->hasDefinition($serviceId)) {
$container->removeDefinition($serviceId);
}
}
}
}

View File

@ -0,0 +1,80 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
class CalendarACLAwareRepository implements CalendarACLAwareRepositoryInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function buildQueryByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): QueryBuilder
{
$qb = $this->em->createQueryBuilder();
$qb->from(Calendar::class, 'c');
$andX = $qb->expr()->andX($qb->expr()->eq('c.accompanyingPeriod', ':period'));
$qb->setParameter('period', $period);
if (null !== $startDate) {
$andX->add($qb->expr()->gte('c.startDate', ':startDate'));
$qb->setParameter('startDate', $startDate);
}
if (null !== $endDate) {
$andX->add($qb->expr()->lte('c.endDate', ':endDate'));
$qb->setParameter('endDate', $endDate);
}
$qb->where($andX);
return $qb;
}
public function countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int
{
$qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('count(c)');
return $qb->getQuery()->getSingleScalarResult();
}
/**
* @return array|Calendar[]
*/
public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array
{
$qb = $this->buildQueryByAccompanyingPeriod($period, $startDate, $endDate)->select('c');
foreach ($orderBy as $sort => $order) {
$qb->addOrderBy('c.' . $sort, $order);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
if (null !== $limit) {
$qb->setMaxResults($limit);
}
return $qb->getQuery()->getResult();
}
}

View File

@ -0,0 +1,26 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
interface CalendarACLAwareRepositoryInterface
{
public function countByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate): int;
/**
* @return array|Calendar[]
*/
public function findByAccompanyingPeriod(AccompanyingPeriod $period, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?array $orderBy = [], ?int $offset = null, ?int $limit = null): array;
}

View File

@ -13,8 +13,10 @@ namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class CalendarRepository implements ObjectRepository
@ -67,6 +69,21 @@ class CalendarRepository implements ObjectRepository
);
}
public function findByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $limit = null, ?int $offset = null): array
{
$qb = $this->queryByNotificationAvailable($startDate, $endDate)->select('c');
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria): ?Calendar
{
return $this->repository->findOneBy($criteria);
@ -76,4 +93,31 @@ class CalendarRepository implements ObjectRepository
{
return Calendar::class;
}
private function queryByNotificationAvailable(DateTimeImmutable $startDate, DateTimeImmutable $endDate): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('c');
$qb->where(
$qb->expr()->andX(
$qb->expr()->eq('c.sendSMS', ':true'),
$qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lt('c.startDate', ':endDate'),
$qb->expr()->orX(
$qb->expr()->eq('c.smsStatus', ':pending'),
$qb->expr()->eq('c.smsStatus', ':cancel_pending')
)
)
);
$qb->setParameters([
'true' => true,
'startDate' => $startDate,
'endDate' => $endDate,
'pending' => Calendar::SMS_PENDING,
'cancel_pending' => Calendar::SMS_CANCEL_PENDING,
]);
return $qb;
}
}

View File

@ -3,9 +3,8 @@ services:
Chill\CalendarBundle\Repository\:
autowire: true
autoconfigure: true
resource: '../../Repository/'
tags:
- { name: 'doctrine.repository_service' }
Chill\CalendarBundle\Menu\:
autowire: true
@ -29,8 +28,16 @@ services:
arguments:
$azure: '@knpu.oauth2.provider.azure'
tags: ['console.command']
Chill\CalendarBundle\Security\:
autoconfigure: true
autowire: true
resource: '../../Security/'
Chill\CalendarBundle\Service\:
autoconfigure: true
autowire: true
resource: '../../Service/'
Chill\CalendarBundle\Service\ShortMessageForCalendarBuilderInterface:
alias: Chill\CalendarBundle\Service\DefaultShortMessageForCalendarBuider

View File

@ -14,7 +14,18 @@ document.addEventListener('DOMContentLoaded', function (e) {
components: {
Answer,
},
template: '<answer :calendarId="14" :status="defined"></answer>',
data() {
return {
status: el.dataset.status,
calendarId: Number.parseInt(el.dataset.calendarId),
}
},
template: '<answer :calendarId="calendarId" :status="status" @statusChanged="onStatusChanged"></answer>',
methods: {
onStatusChanged: function(newStatus) {
this.$data.status = newStatus;
},
}
});
app.use(i18n).mount(el);

View File

@ -1,37 +1,72 @@
<template>
<div class="row">
<div class="col-md-4">
<label>Utilisateur principal</label>
</div>
<div class="col-md-8 align-content-end">
<pick-entity
:multiple="false"
:types="['user']"
:uniqid="'main_user_calendar'"
:picked="null !== this.$store.getters.getMainUser ? [this.$store.getters.getMainUser] : []"
:removableIfSet="false"
@addNewEntity="setMainUser"
></pick-entity>
</div>
</div>
<div class="row">
<select v-model="this.slotDuration">
<option value="00:05:00">5</option>
<option value="00:10:00">10</option>
<option value="00:15:00">15</option>
<option value="00:30:00">30</option>
</select>
</div>
<div>
<input type="checkbox" v-model="this.hideWeekEnds" /><label>Masquer les week-ends</label>
</div>
<concerned-groups></concerned-groups>
<teleport to="#mainUser">
<h2 class="chill-red">Utilisateur principal</h2>
<div>
<template v-for="u in getActiveUsers" :key="u.id">
<calendar-active :user="u" ></calendar-active>
</template>
<div>
<div v-if="null !== this.$store.getters.getMainUser">
<calendar-active :user="this.$store.getters.getMainUser" ></calendar-active>
</div>
<pick-entity
:multiple="false"
:types="['user']"
:uniqid="'main_user_calendar'"
:picked="null !== this.$store.getters.getMainUser ? [this.$store.getters.getMainUser] : []"
:removableIfSet="false"
:displayPicked="false"
@addNewEntity="setMainUser"
></pick-entity>
</div>
</div>
</teleport>
<concerned-groups></concerned-groups>
<teleport to="#schedule">
<div class="row mb-3" v-if="activity.startDate !== null">
<label class="col-form-label col-sm-4">Date</label>
<div class="col-sm-8">
{{ $d(activity.startDate, 'long') }} - {{ $d(activity.endDate, 'hoursOnly') }}
<span v-if="activity.calendarRange === null">(Pas de plage de disponibilité sélectionnée)</span>
<span v-else>(Une plage de disponibilité sélectionnée)</span>
</div>
</div>
</teleport>
<location></location>
<teleport to="#fullCalendar">
<div class="calendar-actives">
<template class="" v-for="u in getActiveUsers" :key="u.id">
<calendar-active :user="u" :invite="this.$store.getters.getInviteForUser(u)"></calendar-active>
</template>
</div>
<div class="display-options row justify-content-between">
<div class="col-sm col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label>
<select v-model="this.slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
</select>
</div>
</div>
<div class="col-sm col-xs-12">
<div class="float-end">
<div class="input-group mb-3">
<div class="input-group-text">
<input id="showHideWE" class="form-check-input mt-0" type="checkbox" v-model="this.hideWeekEnds">
<label for="showHideWE" class="form-check-label"> Masquer les week-ends</label>
</div>
</div>
</div>
</div>
</div>
<FullCalendar ref="fullCalendar" :options="calendarOptions">
<template v-slot:eventContent='arg'>
<b>{{ arg.timeText }}</b>
@ -39,7 +74,6 @@
</template>
</FullCalendar>
</teleport>
<location></location>
</template>
<script>
@ -55,7 +89,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import timeGridPlugin from '@fullcalendar/timegrid';
import CalendarActive from './Components/CalendarActive';
import PickEntity from 'ChillMainAssets/vuejs/PickEntity/PickEntity.vue';
import {mapGetters} from "vuex";
import {mapGetters, mapState} from "vuex";
export default {
name: "App",
@ -76,6 +110,7 @@ export default {
},
computed: {
...mapGetters(['getMainUser']),
...mapState(['activity']),
events() {
return this.$store.getters.getEventSources;
},
@ -84,7 +119,7 @@ export default {
locale: frLocale,
plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin, dayGridPlugin],
initialView: 'timeGridWeek',
initialDate: this.$store.getters.initialDate,
initialDate: this.$store.getters.getInitialDate,
eventSources: this.events,
selectable: true,
datesSet: this.onDatesSet,
@ -103,7 +138,7 @@ export default {
timeGrid: {
slotEventOverlap: false,
slotDuration: this.slotDuration,
scrollTime: '10:00:00',
//scrollTime: '10:00:00',
},
dayGridThreeDays: {
type: 'dayGridWeek',
@ -153,8 +188,9 @@ export default {
console.log('onDateSelect', payload);
// show an alert if changing mainUser
if (this.$store.getters.getMainUser !== null
&& this.$store.state.me.id !== this.$store.getters.getMainUser.id) {
if ((this.$store.getters.getMainUser !== null
&& this.$store.state.me.id !== this.$store.getters.getMainUser.id)
|| this.$store.getters.getMainUser === null) {
if (!window.confirm(this.$t('will_change_main_user_for_me'))) {
return;
} else {
@ -191,3 +227,14 @@ export default {
}
</script>
<style>
.calendar-actives {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.display-options {
margin-top: 1rem;
}
</style>

View File

@ -1,15 +1,24 @@
<template>
<span class="badge" :style="style">
{{ user.text }}
<span class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="rangeShow">
<label class="form-check-label" for="flexSwitchCheckDefault">Disponibilité</label>
</span>
<span class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="remoteShow">
<label class="form-check-label" for="flexSwitchCheckDefault">Agenda</label>
</span>
</span>
<div :style="style" class="calendar-active">
<span class="badge-user">
{{ user.text }}
<template v-if="invite !== null">
<i v-if="invite.status === 'accepted'" class="fa fa-check"></i>
<i v-else-if="invite.status === 'declined'" class="fa fa-times"></i>
<i v-else-if="invite.status === 'pending'" class="fa fa-question-o"></i>
<i v-else-if="invite.status === 'tentative'" class="fa fa-question"></i>
<span v-else="">{{ invite.status }}</span>
</template>
</span>
<span class="form-check-inline form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="rangeShow">
&nbsp;<label class="form-check-label" for="flexSwitchCheckDefault" title="Disponibilités"><i class="fa fa-calendar-check-o"></i></label>
</span>
<span class="form-check-inline form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault" v-model="remoteShow">
&nbsp;<label class="form-check-label" for="flexSwitchCheckDefault" title="Agenda"><i class="fa fa-calendar"></i></label>
</span>
</div>
</template>
<script>
@ -17,12 +26,21 @@ import {mapGetters} from 'vuex';
export default {
name: "CalendarActive",
props: ['user'],
props: {
user: {
type: Object,
required: true
},
invite: {
type: Object,
required: false,
default: null,
}
},
computed: {
style() {
return {
backgroundColor: this.$store.getters.getUserData(this.user).mainColor,
color: 'black',
};
},
rangeShow: {
@ -45,6 +63,18 @@ export default {
}
</script>
<style scoped>
<style scoped lang="scss">
.calendar-active {
margin: 0 0.25rem 0.25rem 0;
padding: 0.5rem;
border-radius: 0.5rem;
color: var(--bs-blue);
& > .badge-user {
margin-right: 0.5rem;
}
}
</style>

View File

@ -81,6 +81,7 @@ export default {
},
addPersonsInvolved({commit, dispatch}, payload) {
console.log('### action addPersonsInvolved', payload.result.type);
console.log('### action addPersonsInvolved payload result', payload.result);
switch (payload.result.type) {
case 'person':
let aPersons = document.getElementById("chill_activitybundle_activity_persons");

View File

@ -62,8 +62,6 @@ export default {
const userData = state.usersData.get(userId);
if (kinds.ranges && userData.calendarRanges.length > 0) {
console.log('first range', userData.calendarRanges[0]);
console.log('state activity', state.activity);
const s = {
id: `ranges_${userId}`,
events: userData.calendarRanges.filter(r => state.activity.calendarRange === null || r.calendarRangeId !== state.activity.calendarRange.calendarRangeId),
@ -91,6 +89,12 @@ export default {
return sources;
},
getInitialDate(state) {
return state.activity.startDate;
},
getInviteForUser: (state) => (user) => {
return state.activity.invites.find(i => i.user.id === user.id);
},
/**
* get the user data for a specific user
*

View File

@ -47,8 +47,6 @@ const store = createStore({
actions,
});
console.log('calendar event', store.state.activity);
whoami().then(me => {
store.commit('setWhoAmiI', me);
});
@ -57,4 +55,10 @@ if (null !== store.getters.getMainUser) {
store.commit('showUserOnCalendar', {ranges: true, remotes: true, user: store.getters.getMainUser});
}
for (let u of store.state.activity.users) {
store.commit('showUserOnCalendar', {ranges: false, remotes: false, user: u});
}
console.log('store', store);
export default store;

View File

@ -19,9 +19,8 @@ const removeIdFromValue = (string, id) => {
* Assign missing keys for the ConcernedGroups component
*/
const mapEntity = (entity) => {
console.log('mapEntity', entity);
let calendar = { ...entity};
Object.assign(calendar, {thirdParties: entity.professionals, users: entity.invites});
Object.assign(calendar, {thirdParties: entity.professionals});
if (entity.startDate !== null ) {
calendar.startDate = ISOToDatetime(entity.startDate.datetime);
@ -35,8 +34,6 @@ const mapEntity = (entity) => {
calendar.calendarRange.id = `range_${entity.calendarRange.id}`;
}
console.log('new calendar object ', calendar);
return calendar;
};
@ -56,7 +53,7 @@ const createUserData = (user, colorIndex) => {
const calendarRangeToFullCalendarEvent = (entity) => {
return {
id: `range_${entity.id}`,
title: "",
title: "(" + entity.user.text + ")",
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: false,

View File

@ -1,31 +1,89 @@
<template>
<div class="btn-group" role="group">
<button id="btnGroupDrop1" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
{{ $t('answer')}}
<button id="btnGroupDrop1" type="button" class="btn btn-misc dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<template v-if="status === Statuses.PENDING">
<span class="fa fa-hourglass"></span> {{ $t('Give_an_answer')}}
</template>
<template v-else-if="status === Statuses.ACCEPTED">
<span class="fa fa-check"></span> {{ $t('Accepted')}}
</template>
<template v-else-if="status === Statuses.DECLINED">
<span class="fa fa-times"></span> {{ $t('Declined')}}
</template>
<template v-else-if="status === Statuses.TENTATIVELY_ACCEPTED">
<span class="fa fa-question"></span> {{ $t('Tentative')}}
</template>
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<li><a class="dropdown-item" href="#"><i class="fa fa-check" aria-hidden="true"></i> {{ $t('accept') }}</a></li>
<li><a class="dropdown-item" href="#"><i class="fa fa-times" aria-hidden="true"></i> {{ $t('decline') }}</a></li>
<li><a class="dropdown-item" href="#"><i class="fa fa-question"></i> {{ $t('tentatively_accept') }}</a></li>
<li><a class="dropdown-item" href="#"><i class="fa fa-hourglass-o"></i> {{ $t('pending') }}</a></li>
<li v-if="status !== Statuses.ACCEPTED"><a class="dropdown-item" @click="changeStatus(Statuses.ACCEPTED)"><i class="fa fa-check" aria-hidden="true"></i> {{ $t('Accept') }}</a></li>
<li v-if="status !== Statuses.DECLINED"><a class="dropdown-item" @click="changeStatus(Statuses.DECLINED)"><i class="fa fa-times" aria-hidden="true"></i> {{ $t('Decline') }}</a></li>
<li v-if="status !== Statuses.TENTATIVELY_ACCEPTED"><a class="dropdown-item" @click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)"><i class="fa fa-question"></i> {{ $t('Tentatively_accept') }}</a></li>
<li v-if="status !== Statuses.PENDING"><a class="dropdown-item" @click="changeStatus(Statuses.PENDING)"><i class="fa fa-hourglass-o"></i> {{ $t('Set_pending') }}</a></li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {defineComponent, PropType} from 'vue';
interface Props {
calendarId: number,
status: string
}
const ACCEPTED = 'accepted';
const DECLINED = 'declined';
const PENDING = 'pending';
const TENTATIVELY_ACCEPTED = 'tentative';
const i18n = {
messages: {
fr: {
"Give_an_answer": "Répondre",
"Accepted": "Accepté",
"Declined": "Refusé",
"Tentative": "Accepté provisoirement",
"Accept": "Accepter",
"Decline": "Refuser",
"Tentatively_accept": "Accepter provisoirement",
"Set_pending": "Ne pas répondre",
}
}
};
export default defineComponent({
name: "Answer",
i18n,
props: {
calendarId: { type: Number, required: true},
status: {type: String, required: true},
status: {type: String as PropType<"accepted" | "declined" | "pending" | "tentative">, required: true},
},
emits: {
statusChanged(payload: "accepted" | "declined" | "pending" | "tentative") {
return true;
},
},
data() {
return {
Statuses: {
ACCEPTED,
DECLINED,
PENDING,
TENTATIVELY_ACCEPTED,
},
}
},
methods: {
changeStatus: function (newStatus: "accepted" | "declined" | "pending" | "tentative") {
console.log('changeStatus', newStatus);
const url = `/api/1.0/calendar/calendar/${this.$props.calendarId}/answer/${newStatus}.json`;
window.fetch(url, {
method: 'POST',
}).then((r: Response) => {
if (!r.ok) {
console.error('could not confirm answer', newStatus);
return;
}
console.log('answer sent', newStatus);
this.$emit('statusChanged', newStatus);
});
},
}
})
@ -34,3 +92,4 @@ export default defineComponent({
<style scoped>
</style>

View File

@ -9,8 +9,8 @@
{
'title' : 'Remove calendar item'|trans,
'confirm_question' : 'Are you sure you want to remove the calendar item?'|trans,
'cancel_route' : 'chill_calendar_calendar_list',
'cancel_parameters' : { 'accompanying_period_id' : accompanyingCourse.id, 'id' : calendar.id },
'cancel_route' : 'chill_calendar_calendar_list_by_period',
'cancel_parameters' : { 'id' : accompanyingCourse.id },
'form' : delete_form
} ) }}
{% endblock %}

View File

@ -3,6 +3,9 @@
{{ form_start(form) }}
{{ form_errors(form) }}
<div id="calendar"></div> {# <=== vue component #}
<div id="mainUser"></div> {# <=== vue component: mainUser #}
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2>
@ -23,6 +26,13 @@
<h2 class="chill-red">{{ 'Calendar data'|trans }}</h2>
<div id="schedule"></div>
{%- if form.location is defined -%}
{{ form_row(form.location) }}
<div id="location"></div>
{% endif %}
{%- if form.startDate is defined -%}
{{ form_row(form.startDate) }}
{% endif %}
@ -35,10 +45,7 @@
{{ form_row(form.calendarRange) }}
{% endif %}
{%- if form.location is defined -%}
{{ form_row(form.location) }}
<div id="location"></div>
{% endif %}
<div id="fullCalendar"></div>
{%- if form.comment is defined -%}
{{ form_row(form.comment) }}
@ -56,7 +63,6 @@
<div id="calendarControls"></div>
{% endif %}
<div id="fullCalendar"></div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
@ -65,7 +71,7 @@
{%- if context == 'user' -%}
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'user_id': user.id } )}}"
{%- elseif context == 'accompanyingCourse' -%}
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'accompanying_period_id': accompanyingCourse.id } )}}"
href="{{ chill_return_path_or('chill_calendar_calendar_list_by_period', { 'id': accompanyingCourse.id } )}}"
{%- endif -%}
>
{{ 'Cancel'|trans|chill_return_path_label }}
@ -73,7 +79,7 @@
</li>
<li>
<button class="btn btn-create" type="submit">
{{ 'Update'|trans }}
{{ 'Save'|trans }}
</button>
</li>
</ul>

View File

@ -7,7 +7,6 @@
{% block content %}
<div class="calendar-edit">
<div id="calendar"></div> {# <=== vue component #}
{% include 'ChillCalendarBundle:Calendar:edit.html.twig' with {'context': 'accompanyingCourse'} %}
</div>
@ -16,10 +15,6 @@
{% block js %}
{{ parent() }}
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', function (e) {
chill.displayAlertWhenLeavingModifiedForm('form[name="{{ form.vars.form.vars.name }}"]',
'{{ "You are going to leave a page with unsubmitted data. Are you sure you want to leave ?"|trans }}');
});
window.entity = {{ entity_json|json_encode|raw }};
window.startDate = {{ entity.startDate|date('Y-m-d H:i:s')|json_encode|raw }};
window.endDate = {{ entity.endDate|date('Y-m-d H:i:s')|json_encode|raw }};

View File

@ -8,18 +8,21 @@
{% set accompanying_course_id = accompanyingCourse.id %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_answer') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_answer') }}
{% endblock %}
{% block content %}
<h1>{{ 'Calendar list' |trans }}</h1>
{{ filterOrder|chill_render_filter_order_helper }}
{% if calendarItems|length == 0 %}
{% if calendarItems|length == 0 %}
<p class="chill-no-data-statement">
{{ "There is no calendar items."|trans }}
<a href="{{ path('chill_calendar_calendar_new', {'user_id': user_id, 'accompanying_period_id': accompanying_course_id}) }}" class="btn btn-create button-small"></a>
@ -33,63 +36,35 @@
<div class="item-bloc">
<div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
<div class="wl-col title">
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
<p class="date-label">{{ calendar.startDate|format_datetime('short', 'short') }} - {{ calendar.endDate|format_datetime('short', 'short') }}</p>
{% else %}
<p class="date-label">{{ calendar.startDate|format_datetime('short', 'short') }} - {{ calendar.endDate|format_datetime('none', 'short') }}</p>
{% endif %}
{% if calendar.startDate and calendar.endDate %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
<h3>{{ "From the day"|trans }} {{ calendar.startDate|format_datetime('medium', 'short') }} </h3>
<h3>{{ "to the day"|trans }} {{ calendar.endDate|format_datetime('medium', 'short') }}</h3>
{% else %}
<h3>{{ calendar.startDate|format_date('full') }} </h3>
<h3>{{ calendar.startDate|format_datetime('none', 'short', locale='fr') }} - {{ calendar.endDate|format_datetime('none', 'short', locale='fr') }}</h3>
<div class="duration">
<p>
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I')}}
</p>
</div>
<div class="duration">
<p>
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I')}}
</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box }}</span>
{% endif %}
</ul>
</div>
</div>
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<li>
<b>{{ 'main user concerned'|trans }}: {{ calendar.mainUser.usernameCanonical }}</b>
</li>
{% endif %}
</ul>
<ul class="record_actions">
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}" data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
<li>
<a href="{{ path('chill_calendar_calendar_show', { 'id': calendar.id, 'user_id': user_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-show "></a>
</li>
{# TOOD
{% if is_granted('CHILL_ACTIVITY_UPDATE', calendar) %}
#}
<li>
<a href="{{ path('chill_calendar_calendar_edit', { 'id': calendar.id, 'user_id': user_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-update "></a>
</li>
{# TOOD
{% endif %}
{% if is_granted('CHILL_ACTIVITY_DELETE', calendar) %}
#}
<li>
<a href="{{ path('chill_calendar_calendar_delete', { 'id': calendar.id, 'user_id' : user_id, 'accompanying_period_id': accompanying_course_id } ) }}" class="btn btn-delete "></a>
</li>
{#
{% endif %}
#}
</ul>
</div>
</div>
{%
@ -98,11 +73,11 @@
or calendar.thirdParties|length > 0
or calendar.users|length > 0
%}
<div class="item-row details">
<div class="item-row details separator">
<div class="item-col">
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': 'calendar_accompanyingCourse',
'render': 'row',
'render': 'wrap-list',
'entity': calendar
} %}
</div>
@ -115,6 +90,37 @@
</div>
{% endif %}
<div class="item-row separator">
<ul class="record_actions">
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}" data-calendar-id="{{ calendar.id|e('html_attr') }}"></div>
</li>
{% endif %}
<li>
<a href="{{ path('chill_calendar_calendar_show', { 'id': calendar.id, 'user_id': user_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-show "></a>
</li>
{# TOOD
{% if is_granted('CHILL_ACTIVITY_UPDATE', calendar) %}
#}
<li>
<a href="{{ path('chill_calendar_calendar_edit', { 'id': calendar.id, 'user_id': user_id, 'accompanying_period_id': accompanying_course_id }) }}" class="btn btn-update "></a>
</li>
{# TOOD
{% endif %}
{% if is_granted('CHILL_ACTIVITY_DELETE', calendar) %}
#}
<li>
<a href="{{ path('chill_calendar_calendar_delete', { 'id': calendar.id, 'user_id' : user_id, 'accompanying_period_id': accompanying_course_id } ) }}" class="btn btn-delete "></a>
</li>
{#
{% endif %}
#}
</ul>
</div>
</div>
{% endfor %}

View File

@ -3,6 +3,9 @@
{{ form_start(form) }}
{{ form_errors(form) }}
<div id="calendar"></div> {# <=== vue component #}
<div id="mainUser"></div> {# <=== vue component: mainUser #}
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2>
@ -27,6 +30,13 @@
<h2 class="chill-red">{{ 'Calendar data'|trans }}</h2>
<div id="schedule"></div>
{%- if form.location is defined -%}
{{ form_row(form.location) }}
<div id="location"></div>
{% endif %}
{%- if form.startDate is defined -%}
{{ form_row(form.startDate) }}
{% endif %}
@ -35,11 +45,12 @@
{{ form_row(form.endDate) }}
{% endif %}
{%- if form.location is defined -%}
{{ form_row(form.location) }}
<div id="location"></div>
{%- if form.calendarRange is defined -%}
{{ form_row(form.calendarRange) }}
{% endif %}
<div id="fullCalendar"></div>
{%- if form.comment is defined -%}
{{ form_row(form.comment) }}
{% endif %}
@ -52,7 +63,6 @@
{{ form_row(form.sendSMS) }}
{% endif %}
<div id="fullCalendar"></div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
@ -61,7 +71,7 @@
{%- if context == 'person' -%}
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'person_id': person.id } )}}"
{%- else -%}
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'accompanying_period_id': accompanyingCourse.id } )}}"
href="{{ chill_return_path_or('chill_calendar_calendar_list_by_period', { 'id': accompanyingCourse.id } )}}"
{%- endif -%}
>
{{ 'Cancel'|trans|chill_return_path_label }}

View File

@ -17,10 +17,6 @@
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', function (e) {
chill.displayAlertWhenLeavingUnsubmittedForm('form[name="{{ form.vars.form.vars.name }}"]',
'{{ "You are going to leave a page with unsubmitted data. Are you sure you want to leave ?"|trans }}');
});
window.entity = {{ entity_json|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_calendar') }}

View File

@ -0,0 +1 @@
Votre travailleur social {{ calendar.mainUser.label }} vous rencontrera le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} - LIEU.{% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas d'indisponibilité, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %}

View File

@ -0,0 +1 @@
Votre RDV avec votre travailleur social {{ calendar.mainUser.label }} prévu le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} est annulé. {% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas de question, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %}

View File

@ -0,0 +1,14 @@
{% macro invite_span(invite) %}
{% if invite.status == 'accepted' %}
{% set fa = 'check' %}
{% elseif invite.status == 'declined' %}
{% set fa = 'times' %}
{% elseif invite.status == 'pending' %}
{% set fa = 'hourglass' %}
{% elseif invite.status == 'tentative' %}
{% set fa = 'question' %}
{% else %}
{% set fa = invite.status %}
{% endif %}
<i class="fa fa-{{ fa }}" title="{{ ('invite.'~invite.status)|trans|e('html_attr') }}"></i>
{% endmacro %}

View File

@ -0,0 +1,64 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
class BulkCalendarShortMessageSender
{
private EntityManagerInterface $em;
private LoggerInterface $logger;
private MessageBusInterface $messageBus;
private ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder;
private CalendarForShortMessageProvider $provider;
public function __construct(CalendarForShortMessageProvider $provider, EntityManagerInterface $em, LoggerInterface $logger, MessageBusInterface $messageBus, ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder)
{
$this->provider = $provider;
$this->em = $em;
$this->logger = $logger;
$this->messageBus = $messageBus;
$this->messageForCalendarBuilder = $messageForCalendarBuilder;
}
public function sendBulkMessageToEligibleCalendars()
{
$countCalendars = 0;
$countSms = 0;
foreach ($this->provider->getCalendars(new DateTimeImmutable('now')) as $calendar) {
$smses = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar);
foreach ($smses as $sms) {
$this->messageBus->dispatch($sms);
++$countSms;
}
$this->em
->createQuery('UPDATE ' . Calendar::class . ' c SET c.smsStatus = :smsStatus WHERE c.id = :id')
->setParameters(['smsStatus' => Calendar::SMS_SENT, 'id' => $calendar->getId()])
->execute();
++$countCalendars;
$this->em->refresh($calendar);
}
$this->logger->info(__CLASS__ . 'a bulk of messages was sent', ['count_calendars' => $countCalendars, 'count_sms' => $countSms]);
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use function count;
class CalendarForShortMessageProvider
{
private CalendarRepository $calendarRepository;
private EntityManagerInterface $em;
private RangeGeneratorInterface $rangeGenerator;
public function __construct(
CalendarRepository $calendarRepository,
EntityManagerInterface $em,
RangeGeneratorInterface $rangeGenerator
) {
$this->calendarRepository = $calendarRepository;
$this->em = $em;
$this->rangeGenerator = $rangeGenerator;
}
/**
* Generate calendars instance.
*
* Warning: this method takes care of clearing the EntityManager at regular interval
*
* @return iterable|Calendar[]
*/
public function getCalendars(DateTimeImmutable $at): iterable
{
['startDate' => $startDate, 'endDate' => $endDate] = $this->rangeGenerator
->generateRange($at);
$offset = 0;
$batchSize = 10;
$calendars = $this->calendarRepository
->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset);
do {
foreach ($calendars as $calendar) {
++$offset;
yield $calendar;
}
$this->em->clear();
$calendars = $this->calendarRepository
->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset);
} while (count($calendars) === $batchSize);
}
}

View File

@ -0,0 +1,68 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use DateInterval;
use Monolog\DateTimeImmutable;
use UnexpectedValueException;
/**
* * Lundi => Envoi des rdv du mardi et mercredi.
* * Mardi => Envoi des rdv du jeudi.
* * Mercredi => Envoi des rdv du vendredi
* * Jeudi => envoi des rdv du samedi et dimanche
* * Vendredi => Envoi des rdv du lundi.
*/
class DefaultRangeGenerator implements RangeGeneratorInterface
{
public function generateRange(\DateTimeImmutable $date): array
{
$onMidnight = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date->format('Y-m-d') . ' 00:00:00');
switch ($dow = (int) $onMidnight->format('w')) {
case 6: // Saturday
case 0: // Sunday
return ['startDate' => null, 'endDate' => null];
case 1: // Monday
// send for Tuesday and Wednesday
$startDate = $onMidnight->add(new DateInterval('P1D'));
$endDate = $startDate->add(new DateInterval('P2D'));
break;
case 2: // tuesday
case 3: // wednesday
$startDate = $onMidnight->add(new DateInterval('P2D'));
$endDate = $startDate->add(new DateInterval('P1D'));
break;
case 4: // thursday
$startDate = $onMidnight->add(new DateInterval('P2D'));
$endDate = $startDate->add(new DateInterval('P2D'));
break;
case 5: // friday
$startDate = $onMidnight->add(new DateInterval('P3D'));
$endDate = $startDate->add(new DateInterval('P1D'));
break;
default:
throw new UnexpectedValueException('a day of a week should not have the value: ' . $dow);
}
return ['startDate' => $startDate, 'endDate' => $endDate];
}
}

View File

@ -0,0 +1,58 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Symfony\Component\Templating\EngineInterface;
class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBuilderInterface
{
private EngineInterface $engine;
public function __construct(
EngineInterface $engine
) {
$this->engine = $engine;
}
public function buildMessageForCalendar(Calendar $calendar): array
{
if (true !== $calendar->getSendSMS()) {
return [];
}
$toUsers = [];
foreach ($calendar->getPersons() as $person) {
if (false === $person->getAcceptSMS() || null === $person->getAcceptSMS() || null === $person->getMobilenumber()) {
continue;
}
if (Calendar::SMS_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
}
}
return $toUsers;
}
}

View File

@ -0,0 +1,22 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use DateTimeImmutable;
interface RangeGeneratorInterface
{
/**
* @return array<startDate: \DateTimeImmutable, endDate: \DateTimeImmutable>
*/
public function generateRange(DateTimeImmutable $date): array;
}

View File

@ -0,0 +1,23 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
interface ShortMessageForCalendarBuilderInterface
{
/**
* @return array|ShortMessage[]
*/
public function buildMessageForCalendar(Calendar $calendar): array;
}

View File

@ -0,0 +1,85 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\Transport\InMemoryTransport;
/**
* @internal
* @coversNothing
*/
final class RemoteCalendarMSGraphSyncControllerTest extends WebTestCase
{
private const SAMPLE_BODY = <<<'JSON'
{
"value": [
{
"subscriptionId": "739703eb-80c4-4c03-b15a-ca370f19624b",
"subscriptionExpirationDateTime": "2022-06-09T02:40:28-07:00",
"changeType": "updated",
"resource": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"@odata.etag": "W/\"DwAAABYAAAAHduaxajFfTpv0kchk+m1FAAAlyzAU\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA="
},
"clientState": "2k05qlr3ds2KzvUP3Ps4A+642fYaI8ThxHGIGbNr2p0MnNkmzxLTNEMxpMc/UEuDkBHfID7OYWj4DQc94vlEkPBdsh9sGTTkHxIE68hqkKkDKhwvfvdj6lS6Dus=",
"tenantId": "421bf216-3f48-47bd-a7cf-8b1995cb24bd"
}
]
}
JSON;
protected function setUp(): void
{
self::bootKernel();
}
public function testSendNotification(): void
{
$client = self::createClient();
$client->request(
'POST',
'/public/incoming-hook/calendar/msgraph/events/23',
[],
[],
[],
self::SAMPLE_BODY
);
$this->assertResponseIsSuccessful();
$this->assertResponseStatusCodeSame(202);
/** @var InMemoryTransport $transport */
$transport = self::$container->get('messenger.transport.async');
$this->assertCount(1, $transport->getSent());
}
public function testValidateSubscription(): void
{
$client = self::createClient();
$client->request(
'POST',
'/public/incoming-hook/calendar/msgraph/events/23?validationToken=something%20to%20decode'
);
$this->assertResponseIsSuccessful();
$response = $client->getResponse();
$this->assertResponseHasHeader('Content-Type');
$this->assertStringContainsString('text/plain', $response->headers->get('Content-Type'));
$this->assertEquals('something to decode', $response->getContent());
}
}

View File

@ -0,0 +1,248 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarRangeSyncer;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* @internal
* @coversNothing
*/
final class CalendarRangeSyncerTest extends TestCase
{
use ProphecyTrait;
private const NOTIF_DELETE = <<<'JSON'
{
"value": [
{
"subscriptionId": "077e8d19-68b3-4d8e-9b1e-8b4ba6733799",
"subscriptionExpirationDateTime": "2022-06-09T06:22:02-07:00",
"changeType": "deleted",
"resource": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"@odata.etag": "W/\"CQAAAA==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA="
},
"clientState": "uds18apRCByqWIodFCHKeM0kJqhfr+qXL/rJWYn7xmtdQ4t03W2OHEOdGJ0Ceo52NAzOYVDpbfRM3TdrZDUiE09OxZkPX/vkpdcnipoiVnPPMFBQn05p8KhklOM=",
"tenantId": "421bf216-3f48-47bd-a7cf-8b1995cb24bd"
}
]
}
JSON;
private const NOTIF_UPDATE = <<<'JSON'
{
"value": [
{
"subscriptionId": "739703eb-80c4-4c03-b15a-ca370f19624b",
"subscriptionExpirationDateTime": "2022-06-09T02:40:28-07:00",
"changeType": "updated",
"resource": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"@odata.etag": "W/\"DwAAABYAAAAHduaxajFfTpv0kchk+m1FAAAlyzAU\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA="
},
"clientState": "2k05qlr3ds2KzvUP3Ps4A+642fYaI8ThxHGIGbNr2p0MnNkmzxLTNEMxpMc/UEuDkBHfID7OYWj4DQc94vlEkPBdsh9sGTTkHxIE68hqkKkDKhwvfvdj6lS6Dus=",
"tenantId": "421bf216-3f48-47bd-a7cf-8b1995cb24bd"
}
]
}
JSON;
private const REMOTE_CALENDAR_RANGE = <<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/events/$entity",
"@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcswFA==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"createdDateTime": "2022-06-08T15:22:24.0096697Z",
"lastModifiedDateTime": "2022-06-09T09:27:09.9223729Z",
"changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcswFA==",
"categories": [],
"transactionId": "90c23105-a6b1-b594-1811-e4ffa612092a",
"originalStartTimeZone": "Romance Standard Time",
"originalEndTimeZone": "Romance Standard Time",
"iCalUId": "040000008200E00074C5B7101A82E00800000000A971DA8D4B7BD801000000000000000010000000BE3F4A21C9008E4FB35A4DE1F80E0118",
"reminderMinutesBeforeStart": 15,
"isReminderOn": true,
"hasAttachments": false,
"subject": "test notif",
"bodyPreview": "",
"importance": "normal",
"sensitivity": "normal",
"isAllDay": false,
"isCancelled": false,
"isOrganizer": true,
"responseRequested": true,
"seriesMasterId": null,
"showAs": "busy",
"type": "singleInstance",
"webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BupAAA%3D&exvsurl=1&path=/calendar/item",
"onlineMeetingUrl": null,
"isOnlineMeeting": false,
"onlineMeetingProvider": "unknown",
"allowNewTimeProposals": true,
"occurrenceId": null,
"isDraft": false,
"hideAttendees": false,
"responseStatus": {
"response": "organizer",
"time": "0001-01-01T00:00:00Z"
},
"body": {
"contentType": "html",
"content": ""
},
"start": {
"dateTime": "2022-06-10T13:30:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2022-06-10T15:30:00.0000000",
"timeZone": "UTC"
},
"location": {
"displayName": "",
"locationType": "default",
"uniqueIdType": "unknown",
"address": {},
"coordinates": {}
},
"locations": [],
"recurrence": null,
"attendees": [],
"organizer": {
"emailAddress": {
"name": "Diego Siciliani",
"address": "DiegoS@2zy74l.onmicrosoft.com"
}
},
"onlineMeeting": null,
"calendar@odata.associationLink": "https://graph.microsoft.com/v1.0/Users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/calendar/$ref",
"calendar@odata.navigationLink": "https://graph.microsoft.com/v1.0/Users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/calendar"
}
JSON;
public function testDeleteCalendarRangeWithAssociation(): void
{
$em = $this->prophesize(EntityManagerInterface::class);
$em->remove(Argument::type(CalendarRange::class))->shouldNotBeCalled();
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]),
]);
$calendarRangeSyncer = new CalendarRangeSyncer(
$em->reveal(),
new NullLogger(),
$machineHttpClient
);
$calendarRange = new CalendarRange();
$calendarRange
->setUser($user = new User());
$calendar = new Calendar();
$calendar->setCalendarRange($calendarRange);
$notification = json_decode(self::NOTIF_DELETE, true);
$calendarRangeSyncer->handleCalendarRangeSync(
$calendarRange,
$notification['value'][0],
$user
);
}
public function testDeleteCalendarRangeWithoutAssociation(): void
{
$em = $this->prophesize(EntityManagerInterface::class);
$em->remove(Argument::type(CalendarRange::class))->shouldBeCalled();
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]),
]);
$calendarRangeSyncer = new CalendarRangeSyncer(
$em->reveal(),
new NullLogger(),
$machineHttpClient
);
$calendarRange = new CalendarRange();
$calendarRange
->setUser($user = new User());
$notification = json_decode(self::NOTIF_DELETE, true);
$calendarRangeSyncer->handleCalendarRangeSync(
$calendarRange,
$notification['value'][0],
$user
);
$this->assertTrue($calendarRange->preventEnqueueChanges);
}
public function testUpdateCalendarRange(): void
{
$em = $this->prophesize(EntityManagerInterface::class);
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]),
]);
$calendarRangeSyncer = new CalendarRangeSyncer(
$em->reveal(),
new NullLogger(),
$machineHttpClient
);
$calendarRange = new CalendarRange();
$calendarRange
->setUser($user = new User())
->setStartDate(new DateTimeImmutable('2020-01-01 15:00:00'))
->setEndDate(new DateTimeImmutable('2020-01-01 15:30:00'))
->addRemoteAttributes([
'lastModifiedDateTime' => 0,
'changeKey' => 'abc',
]);
$notification = json_decode(self::NOTIF_UPDATE, true);
$calendarRangeSyncer->handleCalendarRangeSync(
$calendarRange,
$notification['value'][0],
$user
);
$this->assertStringContainsString(
'2022-06-10T15:30:00',
$calendarRange->getStartDate()->format(DateTimeImmutable::ATOM)
);
$this->assertStringContainsString(
'2022-06-10T17:30:00',
$calendarRange->getEndDate()->format(DateTimeImmutable::ATOM)
);
$this->assertTrue($calendarRange->preventEnqueueChanges);
}
}

View File

@ -0,0 +1,589 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\RemoteCalendar\Connector\MSGraph;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteToLocalSync\CalendarSyncer;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* @internal
* @coversNothing
*/
final class CalendarSyncerTest extends TestCase
{
use ProphecyTrait;
private const NOTIF_DELETE = <<<'JSON'
{
"value": [
{
"subscriptionId": "077e8d19-68b3-4d8e-9b1e-8b4ba6733799",
"subscriptionExpirationDateTime": "2022-06-09T06:22:02-07:00",
"changeType": "deleted",
"resource": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"@odata.etag": "W/\"CQAAAA==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA="
},
"clientState": "uds18apRCByqWIodFCHKeM0kJqhfr+qXL/rJWYn7xmtdQ4t03W2OHEOdGJ0Ceo52NAzOYVDpbfRM3TdrZDUiE09OxZkPX/vkpdcnipoiVnPPMFBQn05p8KhklOM=",
"tenantId": "421bf216-3f48-47bd-a7cf-8b1995cb24bd"
}
]
}
JSON;
private const NOTIF_UPDATE = <<<'JSON'
{
"value": [
{
"subscriptionId": "739703eb-80c4-4c03-b15a-ca370f19624b",
"subscriptionExpirationDateTime": "2022-06-09T02:40:28-07:00",
"changeType": "updated",
"resource": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4/Events/AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"@odata.etag": "W/\"DwAAABYAAAAHduaxajFfTpv0kchk+m1FAAAlyzAU\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA="
},
"clientState": "2k05qlr3ds2KzvUP3Ps4A+642fYaI8ThxHGIGbNr2p0MnNkmzxLTNEMxpMc/UEuDkBHfID7OYWj4DQc94vlEkPBdsh9sGTTkHxIE68hqkKkDKhwvfvdj6lS6Dus=",
"tenantId": "421bf216-3f48-47bd-a7cf-8b1995cb24bd"
}
]
}
JSON;
private const REMOTE_CALENDAR_NO_ATTENDEES = <<<'JSON'
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/events/$entity",
"@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcswFA==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BupAAA=",
"createdDateTime": "2022-06-08T15:22:24.0096697Z",
"lastModifiedDateTime": "2022-06-09T09:27:09.9223729Z",
"changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcswFA==",
"categories": [],
"transactionId": "90c23105-a6b1-b594-1811-e4ffa612092a",
"originalStartTimeZone": "Romance Standard Time",
"originalEndTimeZone": "Romance Standard Time",
"iCalUId": "040000008200E00074C5B7101A82E00800000000A971DA8D4B7BD801000000000000000010000000BE3F4A21C9008E4FB35A4DE1F80E0118",
"reminderMinutesBeforeStart": 15,
"isReminderOn": true,
"hasAttachments": false,
"subject": "test notif",
"bodyPreview": "",
"importance": "normal",
"sensitivity": "normal",
"isAllDay": false,
"isCancelled": false,
"isOrganizer": true,
"responseRequested": true,
"seriesMasterId": null,
"showAs": "busy",
"type": "singleInstance",
"webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BupAAA%3D&exvsurl=1&path=/calendar/item",
"onlineMeetingUrl": null,
"isOnlineMeeting": false,
"onlineMeetingProvider": "unknown",
"allowNewTimeProposals": true,
"occurrenceId": null,
"isDraft": false,
"hideAttendees": false,
"responseStatus": {
"response": "organizer",
"time": "0001-01-01T00:00:00Z"
},
"body": {
"contentType": "html",
"content": ""
},
"start": {
"dateTime": "2022-06-10T13:30:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2022-06-10T15:30:00.0000000",
"timeZone": "UTC"
},
"location": {
"displayName": "",
"locationType": "default",
"uniqueIdType": "unknown",
"address": {},
"coordinates": {}
},
"locations": [],
"recurrence": null,
"attendees": [],
"organizer": {
"emailAddress": {
"name": "Diego Siciliani",
"address": "DiegoS@2zy74l.onmicrosoft.com"
}
},
"onlineMeeting": null,
"calendar@odata.associationLink": "https://graph.microsoft.com/v1.0/Users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/calendar/$ref",
"calendar@odata.navigationLink": "https://graph.microsoft.com/v1.0/Users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/calendar"
}
JSON;
private const REMOTE_CALENDAR_NOT_ORGANIZER = <<<'JSON'
{
"@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcrv7A==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BuqAAA=",
"createdDateTime": "2022-06-08T16:19:18.997293Z",
"lastModifiedDateTime": "2022-06-08T16:22:18.7276485Z",
"changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcrv7A==",
"categories": [],
"transactionId": "42ac0b77-313e-20ca-2cac-1a3f58b3452d",
"originalStartTimeZone": "Romance Standard Time",
"originalEndTimeZone": "Romance Standard Time",
"iCalUId": "040000008200E00074C5B7101A82E00800000000A8955A81537BD801000000000000000010000000007EB443987CD641B6794C4BA48EE356",
"reminderMinutesBeforeStart": 15,
"isReminderOn": true,
"hasAttachments": false,
"subject": "test 2",
"bodyPreview": "________________________________________________________________________________\r\nRéunion Microsoft Teams\r\nRejoindre sur votre ordinateur ou application mobile\r\nCliquez ici pour participer à la réunion\r\nPour en savoir plus | Options de réunion\r\n________",
"importance": "normal",
"sensitivity": "normal",
"isAllDay": false,
"isCancelled": false,
"isOrganizer": false,
"responseRequested": true,
"seriesMasterId": null,
"showAs": "busy",
"type": "singleInstance",
"webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BuqAAA%3D&exvsurl=1&path=/calendar/item",
"onlineMeetingUrl": null,
"isOnlineMeeting": true,
"onlineMeetingProvider": "teamsForBusiness",
"allowNewTimeProposals": true,
"occurrenceId": null,
"isDraft": false,
"hideAttendees": false,
"responseStatus": {
"response": "organizer",
"time": "0001-01-01T00:00:00Z"
},
"body": {
"contentType": "html",
"content": "<html>\r\n<head>\r\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\r\n</head>\r\n<body>\r\n<div></div>\r\n<br>\r\n<div style=\"width:100%; height:20px\"><span style=\"white-space:nowrap; color:#5F5F5F; opacity:.36\">________________________________________________________________________________</span>\r\n</div>\r\n<div class=\"me-email-text\" lang=\"fr-FR\" style=\"color:#252424; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\n<div style=\"margin-top:24px; margin-bottom:20px\"><span style=\"font-size:24px; color:#252424\">Réunion Microsoft Teams</span>\r\n</div>\r\n<div style=\"margin-bottom:20px\">\r\n<div style=\"margin-top:0px; margin-bottom:0px; font-weight:bold\"><span style=\"font-size:14px; color:#252424\">Rejoindre sur votre ordinateur ou application mobile</span>\r\n</div>\r\n<a href=\"https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d\" class=\"me-email-headline\" style=\"font-size:14px; font-family:'Segoe UI Semibold','Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif; text-decoration:underline; color:#6264a7\">Cliquez\r\n ici pour participer à la réunion</a> </div>\r\n<div style=\"margin-bottom:24px; margin-top:20px\"><a href=\"https://aka.ms/JoinTeamsMeeting\" class=\"me-email-link\" style=\"font-size:14px; text-decoration:underline; color:#6264a7; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">Pour en savoir\r\n plus</a> | <a href=\"https://teams.microsoft.com/meetingOptions/?organizerId=4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4&amp;tenantId=421bf216-3f48-47bd-a7cf-8b1995cb24bd&amp;threadId=19_meeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2@thread.v2&amp;messageId=0&amp;language=fr-FR\" class=\"me-email-link\" style=\"font-size:14px; text-decoration:underline; color:#6264a7; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\nOptions de réunion</a> </div>\r\n</div>\r\n<div style=\"font-size:14px; margin-bottom:4px; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\n</div>\r\n<div style=\"font-size:12px\"></div>\r\n<div></div>\r\n<div style=\"width:100%; height:20px\"><span style=\"white-space:nowrap; color:#5F5F5F; opacity:.36\">________________________________________________________________________________</span>\r\n</div>\r\n</body>\r\n</html>\r\n"
},
"start": {
"dateTime": "2022-06-11T12:30:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2022-06-11T13:30:00.0000000",
"timeZone": "UTC"
},
"location": {
"displayName": "",
"locationType": "default",
"uniqueIdType": "unknown",
"address": {},
"coordinates": {}
},
"locations": [],
"recurrence": null,
"attendees": [
{
"type": "required",
"status": {
"response": "accepted",
"time": "2022-06-08T16:22:15.1392583Z"
},
"emailAddress": {
"name": "Alex Wilber",
"address": "AlexW@2zy74l.onmicrosoft.com"
}
},
{
"type": "required",
"status": {
"response": "declined",
"time": "2022-06-08T16:22:15.1392583Z"
},
"emailAddress": {
"name": "Alfred Nobel",
"address": "alfredN@2zy74l.onmicrosoft.com"
}
}
],
"organizer": {
"emailAddress": {
"name": "Diego Siciliani",
"address": "DiegoS@2zy74l.onmicrosoft.com"
}
},
"onlineMeeting": {
"joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d"
}
}
JSON;
private const REMOTE_CALENDAR_WITH_ATTENDEES = <<<'JSON'
{
"@odata.etag": "W/\"B3bmsWoxX06b9JHIZPptRQAAJcrv7A==\"",
"id": "AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk_m1FAAAAAAENAAAHduaxajFfTpv0kchk_m1FAAAl1BuqAAA=",
"createdDateTime": "2022-06-08T16:19:18.997293Z",
"lastModifiedDateTime": "2022-06-08T16:22:18.7276485Z",
"changeKey": "B3bmsWoxX06b9JHIZPptRQAAJcrv7A==",
"categories": [],
"transactionId": "42ac0b77-313e-20ca-2cac-1a3f58b3452d",
"originalStartTimeZone": "Romance Standard Time",
"originalEndTimeZone": "Romance Standard Time",
"iCalUId": "040000008200E00074C5B7101A82E00800000000A8955A81537BD801000000000000000010000000007EB443987CD641B6794C4BA48EE356",
"reminderMinutesBeforeStart": 15,
"isReminderOn": true,
"hasAttachments": false,
"subject": "test 2",
"bodyPreview": "________________________________________________________________________________\r\nRéunion Microsoft Teams\r\nRejoindre sur votre ordinateur ou application mobile\r\nCliquez ici pour participer à la réunion\r\nPour en savoir plus | Options de réunion\r\n________",
"importance": "normal",
"sensitivity": "normal",
"isAllDay": false,
"isCancelled": false,
"isOrganizer": true,
"responseRequested": true,
"seriesMasterId": null,
"showAs": "busy",
"type": "singleInstance",
"webLink": "https://outlook.office365.com/owa/?itemid=AAMkADM1MTdlMGIzLTZhZWUtNDQ0ZC05Y2M4LWViMjhmOWJlMDhhMQBGAAAAAAA5e3965gkBSLcU1p00sMSyBwAHduaxajFfTpv0kchk%2Bm1FAAAAAAENAAAHduaxajFfTpv0kchk%2Bm1FAAAl1BuqAAA%3D&exvsurl=1&path=/calendar/item",
"onlineMeetingUrl": null,
"isOnlineMeeting": true,
"onlineMeetingProvider": "teamsForBusiness",
"allowNewTimeProposals": true,
"occurrenceId": null,
"isDraft": false,
"hideAttendees": false,
"responseStatus": {
"response": "organizer",
"time": "0001-01-01T00:00:00Z"
},
"body": {
"contentType": "html",
"content": "<html>\r\n<head>\r\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\r\n</head>\r\n<body>\r\n<div></div>\r\n<br>\r\n<div style=\"width:100%; height:20px\"><span style=\"white-space:nowrap; color:#5F5F5F; opacity:.36\">________________________________________________________________________________</span>\r\n</div>\r\n<div class=\"me-email-text\" lang=\"fr-FR\" style=\"color:#252424; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\n<div style=\"margin-top:24px; margin-bottom:20px\"><span style=\"font-size:24px; color:#252424\">Réunion Microsoft Teams</span>\r\n</div>\r\n<div style=\"margin-bottom:20px\">\r\n<div style=\"margin-top:0px; margin-bottom:0px; font-weight:bold\"><span style=\"font-size:14px; color:#252424\">Rejoindre sur votre ordinateur ou application mobile</span>\r\n</div>\r\n<a href=\"https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d\" class=\"me-email-headline\" style=\"font-size:14px; font-family:'Segoe UI Semibold','Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif; text-decoration:underline; color:#6264a7\">Cliquez\r\n ici pour participer à la réunion</a> </div>\r\n<div style=\"margin-bottom:24px; margin-top:20px\"><a href=\"https://aka.ms/JoinTeamsMeeting\" class=\"me-email-link\" style=\"font-size:14px; text-decoration:underline; color:#6264a7; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">Pour en savoir\r\n plus</a> | <a href=\"https://teams.microsoft.com/meetingOptions/?organizerId=4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4&amp;tenantId=421bf216-3f48-47bd-a7cf-8b1995cb24bd&amp;threadId=19_meeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2@thread.v2&amp;messageId=0&amp;language=fr-FR\" class=\"me-email-link\" style=\"font-size:14px; text-decoration:underline; color:#6264a7; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\nOptions de réunion</a> </div>\r\n</div>\r\n<div style=\"font-size:14px; margin-bottom:4px; font-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif\">\r\n</div>\r\n<div style=\"font-size:12px\"></div>\r\n<div></div>\r\n<div style=\"width:100%; height:20px\"><span style=\"white-space:nowrap; color:#5F5F5F; opacity:.36\">________________________________________________________________________________</span>\r\n</div>\r\n</body>\r\n</html>\r\n"
},
"start": {
"dateTime": "2022-06-11T12:30:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2022-06-11T13:30:00.0000000",
"timeZone": "UTC"
},
"location": {
"displayName": "",
"locationType": "default",
"uniqueIdType": "unknown",
"address": {},
"coordinates": {}
},
"locations": [],
"recurrence": null,
"attendees": [
{
"type": "required",
"status": {
"response": "accepted",
"time": "2022-06-08T16:22:15.1392583Z"
},
"emailAddress": {
"name": "Alex Wilber",
"address": "AlexW@2zy74l.onmicrosoft.com"
}
},
{
"type": "required",
"status": {
"response": "accepted",
"time": "2022-06-08T16:22:15.1392583Z"
},
"emailAddress": {
"name": "External User",
"address": "external@example.com"
}
},
{
"type": "required",
"status": {
"response": "declined",
"time": "2022-06-08T16:22:15.1392583Z"
},
"emailAddress": {
"name": "Alfred Nobel",
"address": "alfredN@2zy74l.onmicrosoft.com"
}
}
],
"organizer": {
"emailAddress": {
"name": "Diego Siciliani",
"address": "DiegoS@2zy74l.onmicrosoft.com"
}
},
"onlineMeeting": {
"joinUrl": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_NTE3ODUxY2ItNGJhNi00Y2UwLTljN2QtMmQ3YjAxNWY1Nzk2%40thread.v2/0?context=%7b%22Tid%22%3a%22421bf216-3f48-47bd-a7cf-8b1995cb24bd%22%2c%22Oid%22%3a%224feb0ae3-7ffb-48dd-891e-c86b2cdeefd4%22%7d"
}
}
JSON;
protected function setUp(): void
{
parent::setUp();
// all tests should run when timezone = +02:00
$brussels = new DateTimeZone('Europe/Brussels');
if (7200 === $brussels->getOffset(new DateTimeImmutable())) {
date_default_timezone_set('Europe/Brussels');
} else {
date_default_timezone_set('Europe/Moscow');
}
}
public function testHandleAttendeesConfirmingCalendar(): void
{
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_WITH_ATTENDEES, ['http_code' => 200]),
]);
$userA = (new User())->setEmail('alexw@2zy74l.onmicrosoft.com')
->setEmailCanonical('alexw@2zy74l.onmicrosoft.com');
$userB = (new User())->setEmail('zzzzz@2zy74l.onmicrosoft.com')
->setEmailCanonical('zzzzz@2zy74l.onmicrosoft.com');
$userC = (new User())->setEmail('alfredN@2zy74l.onmicrosoft.com')
->setEmailCanonical('alfredn@2zy74l.onmicrosoft.com');
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$userRepository->findOneByUsernameOrEmail(Argument::exact('AlexW@2zy74l.onmicrosoft.com'))
->willReturn($userA);
$userRepository->findOneByUsernameOrEmail(Argument::exact('zzzzz@2zy74l.onmicrosoft.com'))
->willReturn($userB);
$userRepository->findOneByUsernameOrEmail(Argument::exact('alfredN@2zy74l.onmicrosoft.com'))
->willReturn($userC);
$userRepository->findOneByUsernameOrEmail(Argument::exact('external@example.com'))
->willReturn(null);
$calendarSyncer = new CalendarSyncer(
new NullLogger(),
$machineHttpClient,
$userRepository->reveal()
);
$calendar = new Calendar();
$calendar
->setMainUser($user = new User())
->setStartDate(new DateTimeImmutable('2022-06-11 14:30:00'))
->setEndDate(new DateTimeImmutable('2022-06-11 15:30:00'))
->addUser($userA)
->addUser($userB)
->setCalendarRange(new CalendarRange())
->addRemoteAttributes([
'lastModifiedDateTime' => 0,
'changeKey' => 'abcd',
]);
$notification = json_decode(self::NOTIF_UPDATE, true);
$calendarSyncer->handleCalendarSync(
$calendar,
$notification['value'][0],
$user
);
$this->assertTrue($calendar->preventEnqueueChanges);
$this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus());
// user A is invited, and accepted
$this->assertTrue($calendar->isInvited($userA));
$this->assertEquals(Invite::ACCEPTED, $calendar->getInviteForUser($userA)->getStatus());
$this->assertFalse($calendar->getInviteForUser($userA)->preventEnqueueChanges);
// user B is no more invited
$this->assertFalse($calendar->isInvited($userB));
// user C is invited, but declined
$this->assertFalse($calendar->getInviteForUser($userC)->preventEnqueueChanges);
$this->assertTrue($calendar->isInvited($userC));
$this->assertEquals(Invite::DECLINED, $calendar->getInviteForUser($userC)->getStatus());
}
public function testHandleDeleteCalendar(): void
{
$machineHttpClient = new MockHttpClient([]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$calendarSyncer = new CalendarSyncer(
new NullLogger(),
$machineHttpClient,
$userRepository->reveal()
);
$calendar = new Calendar();
$calendar
->setMainUser($user = new User())
->setCalendarRange($calendarRange = new CalendarRange());
$notification = json_decode(self::NOTIF_DELETE, true);
$calendarSyncer->handleCalendarSync(
$calendar,
$notification['value'][0],
$user
);
$this->assertEquals(Calendar::STATUS_CANCELED, $calendar->getStatus());
$this->assertNull($calendar->getCalendarRange());
$this->assertTrue($calendar->preventEnqueueChanges);
}
public function testHandleMoveCalendar(): void
{
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]),
]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$calendarSyncer = new CalendarSyncer(
new NullLogger(),
$machineHttpClient,
$userRepository->reveal()
);
$calendar = new Calendar();
$calendar
->setMainUser($user = new User())
->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00'))
->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00'))
->setCalendarRange(new CalendarRange())
->addRemoteAttributes([
'lastModifiedDateTime' => 0,
'changeKey' => 'abcd',
]);
$notification = json_decode(self::NOTIF_UPDATE, true);
$calendarSyncer->handleCalendarSync(
$calendar,
$notification['value'][0],
$user
);
$this->assertStringContainsString(
'2022-06-10T15:30:00',
$calendar->getStartDate()->format(DateTimeImmutable::ATOM)
);
$this->assertStringContainsString(
'2022-06-10T17:30:00',
$calendar->getEndDate()->format(DateTimeImmutable::ATOM)
);
$this->assertTrue($calendar->preventEnqueueChanges);
$this->assertEquals(Calendar::STATUS_MOVED, $calendar->getStatus());
}
public function testHandleNotMovedCalendar(): void
{
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_NO_ATTENDEES, ['http_code' => 200]),
]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$calendarSyncer = new CalendarSyncer(
new NullLogger(),
$machineHttpClient,
$userRepository->reveal()
);
$calendar = new Calendar();
$calendar
->setMainUser($user = new User())
->setStartDate(new DateTimeImmutable('2022-06-10 15:30:00'))
->setEndDate(new DateTimeImmutable('2022-06-10 17:30:00'))
->setCalendarRange(new CalendarRange())
->addRemoteAttributes([
'lastModifiedDateTime' => 0,
'changeKey' => 'abcd',
]);
$notification = json_decode(self::NOTIF_UPDATE, true);
$calendarSyncer->handleCalendarSync(
$calendar,
$notification['value'][0],
$user
);
$this->assertStringContainsString(
'2022-06-10T15:30:00',
$calendar->getStartDate()->format(DateTimeImmutable::ATOM)
);
$this->assertStringContainsString(
'2022-06-10T17:30:00',
$calendar->getEndDate()->format(DateTimeImmutable::ATOM)
);
$this->assertTrue($calendar->preventEnqueueChanges);
$this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus());
}
public function testHandleNotOrganizer(): void
{
// when isOrganiser === false, nothing should happens
$machineHttpClient = new MockHttpClient([
new MockResponse(self::REMOTE_CALENDAR_NOT_ORGANIZER, ['http_code' => 200]),
]);
$userRepository = $this->prophesize(UserRepositoryInterface::class);
$calendarSyncer = new CalendarSyncer(
new NullLogger(),
$machineHttpClient,
$userRepository->reveal()
);
$calendar = new Calendar();
$calendar
->setMainUser($user = new User())
->setStartDate(new DateTimeImmutable('2020-01-01 10:00:00'))
->setEndDate(new DateTimeImmutable('2020-01-01 12:00:00'))
->setCalendarRange(new CalendarRange())
->addRemoteAttributes([
'lastModifiedDateTime' => 0,
'changeKey' => 'abcd',
]);
$notification = json_decode(self::NOTIF_UPDATE, true);
$calendarSyncer->handleCalendarSync(
$calendar,
$notification['value'][0],
$user
);
$this->assertStringContainsString(
'2020-01-01T10:00:00',
$calendar->getStartDate()->format(DateTimeImmutable::ATOM)
);
$this->assertStringContainsString(
'2020-01-01T12:00:00',
$calendar->getEndDate()->format(DateTimeImmutable::ATOM)
);
$this->assertEquals(Calendar::STATUS_VALID, $calendar->getStatus());
}
}

View File

@ -0,0 +1,128 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use ArrayIterator;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessageSender;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Chill\MainBundle\Test\PrepareUserTrait;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use libphonenumber\PhoneNumberUtil;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @internal
* @coversNothing
*/
final class BulkCalendarShortMessageSenderTest extends KernelTestCase
{
use PersonRandomHelper;
use PrepareUserTrait;
use ProphecyTrait;
private array $toDelete = [];
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
}
protected function tearDown(): void
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
foreach ($this->toDelete as [$entity, $id]) {
$entity = $em->find($entity, $id);
$em->remove($entity);
}
$em->flush();
}
public function testSendBulkMessageToEligibleCalendar()
{
$em = self::$container->get(EntityManagerInterface::class);
$calendar = new Calendar();
$calendar
->addPerson($this->getRandomPerson($em))
->setMainUser($user = $this->prepareUser([]))
->setStartDate(new DateTimeImmutable('now'))
->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M')))
->setSendSMS(true);
$user->setUsername(uniqid());
$user->setEmail(uniqid() . '@gmail.com');
$calendar->getPersons()->first()->setAcceptSMS(true);
// hack to prevent side effect with messages
$calendar->preventEnqueueChanges = true;
$em->persist($user);
//$this->toDelete[] = [User::class, $user->getId()];
$em->persist($calendar);
//$this->toDelete[] = [Calendar::class, $calendar->getId()];
$em->flush();
$provider = $this->prophesize(CalendarForShortMessageProvider::class);
$provider->getCalendars(Argument::type(DateTimeImmutable::class))
->willReturn(new ArrayIterator([$calendar]));
$messageBuilder = $this->prophesize(ShortMessageForCalendarBuilderInterface::class);
$messageBuilder->buildMessageForCalendar(Argument::type(Calendar::class))
->willReturn(
[
new ShortMessage(
'content',
PhoneNumberUtil::getInstance()->parse('+32470123456', 'BE'),
ShortMessage::PRIORITY_MEDIUM
),
]
);
$bus = $this->prophesize(MessageBusInterface::class);
$bus->dispatch(Argument::type(ShortMessage::class))
->willReturn(new Envelope(new stdClass()))
->shouldBeCalledTimes(1);
$bulk = new BulkCalendarShortMessageSender(
$provider->reveal(),
$em,
new NullLogger(),
$bus->reveal(),
$messageBuilder->reveal()
);
$bulk->sendBulkMessageToEligibleCalendars();
$em->clear();
$calendar = $em->find(Calendar::class, $calendar->getId());
$this->assertEquals(Calendar::SMS_SENT, $calendar->getSmsStatus());
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use function count;
/**
* @internal
* @coversNothing
*/
final class CalendarForShortMessageProviderTest extends TestCase
{
use ProphecyTrait;
public function testGetCalendars()
{
$calendarRepository = $this->prophesize(CalendarRepository::class);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::exact(0)
)->will(static function ($args) {
return array_fill(0, $args[2], new Calendar());
})->shouldBeCalledTimes(1);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::not(0)
)->will(static function ($args) {
return array_fill(0, $args[2] - 1, new Calendar());
})->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled();
$provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(),
$em->reveal(),
new DefaultRangeGenerator()
);
$calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now')));
$this->assertGreaterThan(1, count($calendars));
$this->assertLessThan(100, count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars);
}
public function testGetCalendarsWithOnlyOneCalendar()
{
$calendarRepository = $this->prophesize(CalendarRepository::class);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::exact(0)
)->will(static function ($args) {
return array_fill(0, 1, new Calendar());
})->shouldBeCalledTimes(1);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::not(0)
)->will(static function ($args) {
return [];
})->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled();
$provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(),
$em->reveal(),
new DefaultRangeGenerator()
);
$calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now')));
$this->assertEquals(1, count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars);
}
}

View File

@ -0,0 +1,94 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
use DateTimeImmutable;
use Iterator;
use PHPUnit\Framework\TestCase;
/**
* @internal
* @coversNothing
*/
final class DefaultRangeGeneratorTest extends TestCase
{
/**
* * Lundi => Envoi des rdv du mardi et mercredi.
* * Mardi => Envoi des rdv du jeudi.
* * Mercredi => Envoi des rdv du vendredi
* * Jeudi => envoi des rdv du samedi et dimanche
* * Vendredi => Envoi des rdv du lundi.
*/
public function generateData(): Iterator
{
yield [
new DateTimeImmutable('2022-06-13 10:45:00'),
new DateTimeImmutable('2022-06-14 00:00:00'),
new DateTimeImmutable('2022-06-16 00:00:00'),
];
yield [
new DateTimeImmutable('2022-06-14 15:45:00'),
new DateTimeImmutable('2022-06-16 00:00:00'),
new DateTimeImmutable('2022-06-17 00:00:00'),
];
yield [
new DateTimeImmutable('2022-06-15 13:45:18'),
new DateTimeImmutable('2022-06-17 00:00:00'),
new DateTimeImmutable('2022-06-18 00:00:00'),
];
yield [
new DateTimeImmutable('2022-06-16 01:30:55'),
new DateTimeImmutable('2022-06-18 00:00:00'),
new DateTimeImmutable('2022-06-20 00:00:00'),
];
yield [
new DateTimeImmutable('2022-06-17 21:30:55'),
new DateTimeImmutable('2022-06-20 00:00:00'),
new DateTimeImmutable('2022-06-21 00:00:00'),
];
yield [
new DateTimeImmutable('2022-06-18 21:30:55'),
null,
null,
];
yield [
new DateTimeImmutable('2022-06-19 21:30:55'),
null,
null,
];
}
/**
* @dataProvider generateData
*/
public function testGenerateRange(DateTimeImmutable $date, ?DateTimeImmutable $startDate, ?DateTimeImmutable $endDate)
{
$generator = new DefaultRangeGenerator();
['startDate' => $actualStartDate, 'endDate' => $actualEndDate] = $generator->generateRange($date);
if (null === $startDate) {
$this->assertNull($actualStartDate);
$this->assertNull($actualEndDate);
} else {
$this->assertEquals($startDate->format(DateTimeImmutable::ATOM), $actualStartDate->format(DateTimeImmutable::ATOM));
$this->assertEquals($endDate->format(DateTimeImmutable::ATOM), $actualEndDate->format(DateTimeImmutable::ATOM));
}
}
}

View File

@ -0,0 +1,108 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultShortMessageForCalendarBuilder;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use DateInterval;
use DateTimeImmutable;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Templating\EngineInterface;
/**
* @internal
* @coversNothing
*/
final class DefaultShortMessageForCalendarBuilderTest extends TestCase
{
use ProphecyTrait;
private PhoneNumberUtil $phoneNumberUtil;
protected function setUp(): void
{
$this->phoneNumberUtil = PhoneNumberUtil::getInstance();
}
public function testBuildMessageForCalendar()
{
$calendar = new Calendar();
$calendar
->setStartDate(new DateTimeImmutable('now'))
->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M')))
->setMainUser($user = new User())
->addPerson($person = new Person())
->setSendSMS(false);
$user
->setLabel('Alex')
->setMainLocation($location = new Location());
$location->setName('LOCAMAT');
$person
->setMobilenumber($this->phoneNumberUtil->parse('+32470123456', 'BE'))
->setAcceptSMS(false);
$engine = $this->prophesize(EngineInterface::class);
$engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message.txt.twig'), Argument::withKey('calendar'))
->willReturn('message content')
->shouldBeCalledTimes(1);
$engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig'), Argument::withKey('calendar'))
->willReturn('message canceled')
->shouldBeCalledTimes(1);
$builder = new DefaultShortMessageForCalendarBuilder(
$engine->reveal()
);
// if the calendar should not send sms
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(0, $sms);
// if the person do not accept sms
$calendar->setSendSMS(true);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(0, $sms);
// person accepts sms
$person->setAcceptSMS(true);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
);
$this->assertEquals('message content', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
// if the calendar is canceled
$calendar
->setSmsStatus(Calendar::SMS_SENT)
->setStatus(Calendar::STATUS_CANCELED);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
);
$this->assertEquals('message canceled', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220606153851 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER endDate DROP DEFAULT');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER startDate DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar.enddate IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startdate IS \'(DC2Type:datetimetz_immutable)\'');
}
public function getDescription(): string
{
return 'remove timezone from dates in calendar entity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER startdate DROP DEFAULT');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar ALTER enddate DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar.endDate IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220606154119 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate TYPE TIMESTAMP(0) WITH TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER endDate DROP DEFAULT');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate TYPE TIMESTAMP(0) WITH TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startDate DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.enddate IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startdate IS \'(DC2Type:datetimetz_immutable)\'');
}
public function getDescription(): string
{
return 'remove timezone from calendar range';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER startdate DROP DEFAULT');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE chill_calendar.calendar_range ALTER enddate DROP DEFAULT');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_calendar.calendar_range.endDate IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@ -0,0 +1,36 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220608084052 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.invite DROP remoteAttributes');
$this->addSql('ALTER TABLE chill_calendar.invite DROP remoteId');
}
public function getDescription(): string
{
return 'Add remoteId for invitation';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.invite ADD remoteAttributes JSON DEFAULT \'[]\' NOT NULL');
$this->addSql('ALTER TABLE chill_calendar.invite ADD remoteId TEXT DEFAULT \'\' NOT NULL');
$this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)');
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220609200857 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote');
$this->addSql('CREATE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId)');
$this->addSql('DROP INDEX chill_calendar.idx_calendar_remote');
$this->addSql('CREATE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId)');
$this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote');
$this->addSql('CREATE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId)');
}
public function getDescription(): string
{
return 'Set an unique contraint on remoteId on calendar object which are synced to a remote';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX chill_calendar.idx_calendar_range_remote');
$this->addSql('CREATE UNIQUE INDEX idx_calendar_range_remote ON chill_calendar.calendar_range (remoteId) WHERE remoteId <> \'\'');
$this->addSql('DROP INDEX chill_calendar.idx_calendar_remote');
$this->addSql('CREATE UNIQUE INDEX idx_calendar_remote ON chill_calendar.calendar (remoteId) WHERE remoteId <> \'\'');
$this->addSql('DROP INDEX chill_calendar.idx_calendar_invite_remote');
$this->addSql('CREATE UNIQUE INDEX idx_calendar_invite_remote ON chill_calendar.invite (remoteId) WHERE remoteId <> \'\'');
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220613202636 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar DROP smsStatus');
}
public function getDescription(): string
{
return 'Add sms status on calendars';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar ADD smsStatus TEXT DEFAULT \'sms_pending\' NOT NULL');
}
}

View File

@ -43,6 +43,9 @@ chill_calendar:
form:
The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement.
Create for referrer: Créer pour le référent
start date filter: Début du rendez-vous
From: Du
To: Au
remote_ms_graph:
freebusy_statuses:
@ -56,3 +59,9 @@ remote_ms_graph:
remote_calendar:
calendar_range_title: Plage de disponibilité Chill
invite:
accepted: Accepté
declined: Refusé
pending: En attente
tentative: Accepté provisoirement

View File

@ -18,6 +18,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\GroupingCenterCompilerPass
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ShortMessageCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
@ -70,5 +71,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new ACLFlagsCompilerPass());
$container->addCompilerPass(new GroupingCenterCompilerPass());
$container->addCompilerPass(new CRUDControllerCompilerPass());
$container->addCompilerPass(new ShortMessageCompilerPass());
}
}

View File

@ -196,8 +196,11 @@ class ChillMainExtension extends Extension implements
$loader->load('services/search.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/mailer.yaml');
$loader->load('services/short_message.yaml');
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
$container->setParameter('chill_main.short_messages', $config['short_messages']);
//$this->configureSms($config['short_messages'], $container, $loader);
}
public function prepend(ContainerBuilder $container)
@ -313,6 +316,13 @@ class ChillMainExtension extends Extension implements
// Note: the controller are loaded inside compiler pass
}
protected function configureSms(array $config, ContainerBuilder $container, Loader\YamlFileLoader $loader): void
{
$dsn = parse_url($config['dsn']);
dump($dsn);
}
protected function prependCruds(ContainerBuilder $container)
{
$container->prependExtensionConfig('chill_main', [

View File

@ -0,0 +1,87 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\DependencyInjection\CompilerPass;
use Chill\MainBundle\Service\ShortMessage\NullShortMessageSender;
use Chill\MainBundle\Service\ShortMessage\ShortMessageTransporter;
use Chill\MainBundle\Service\ShortMessageOvh\OvhShortMessageSender;
use libphonenumber\PhoneNumberUtil;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
use Symfony\Component\DependencyInjection\Reference;
use function array_key_exists;
class ShortMessageCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
$config = $container->resolveEnvPlaceholders($container->getParameter('chill_main.short_messages', null), true);
// weird fix for special characters
$config['dsn'] = str_replace(['%%'], ['%'], $config['dsn']);
$dsn = parse_url($config['dsn']);
parse_str($dsn['query'] ?? '', $dsn['queries']);
if ('null' === $dsn['scheme'] || false === $config['enabled']) {
$defaultTransporter = new Reference(NullShortMessageSender::class);
} elseif ('ovh' === $dsn['scheme']) {
if (!class_exists('\Ovh\Api')) {
throw new RuntimeException('Class \\Ovh\\Api not found');
}
foreach (['user', 'host', 'pass'] as $component) {
if (!array_key_exists($component, $dsn)) {
throw new RuntimeException(sprintf('The component %s does not exist in dsn. Please provide a dsn ' .
'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $component));
}
$container->setParameter('chill_main.short_messages.ovh_config_' . $component, $dsn[$component]);
}
foreach (['consumer_key', 'sender', 'service_name'] as $param) {
if (!array_key_exists($param, $dsn['queries'])) {
throw new RuntimeException(sprintf('The parameter %s does not exist in dsn. Please provide a dsn ' .
'like ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz', $param));
}
$container->setParameter('chill_main.short_messages.ovh_config_' . $param, $dsn['queries'][$param]);
}
$ovh = new Definition();
$ovh
->setClass('\Ovh\Api')
->setArgument(0, $dsn['user'])
->setArgument(1, $dsn['pass'])
->setArgument(2, $dsn['host'])
->setArgument(3, $dsn['queries']['consumer_key']);
$container->setDefinition('Ovh\Api', $ovh);
$ovhSender = new Definition();
$ovhSender
->setClass(OvhShortMessageSender::class)
->setArgument(0, new Reference('Ovh\Api'))
->setArgument(1, $dsn['queries']['service_name'])
->setArgument(2, $dsn['queries']['sender'])
->setArgument(3, new Reference(LoggerInterface::class))
->setArgument(4, new Reference(PhoneNumberUtil::class));
$container->setDefinition(OvhShortMessageSender::class, $ovhSender);
$defaultTransporter = new Reference(OvhShortMessageSender::class);
} else {
throw new RuntimeException(sprintf('Cannot find a sender for this dsn: %s', $config['dsn']));
}
$container->getDefinition(ShortMessageTransporter::class)
->setArgument(0, $defaultTransporter);
}
}

View File

@ -102,6 +102,14 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('short_messages')
->canBeEnabled()
->children()
->scalarNode('dsn')->cannotBeEmpty()->defaultValue('null://null')
->info('the dsn for sending short message. Example: ovh://applicationKey:secret@endpoint')
->end()
->end()
->end() // end for 'short_messages'
->arrayNode('acl')
->addDefaultsIfNotSet()
->children()

View File

@ -359,10 +359,17 @@ class User implements AdvancedUserInterface
}
}
public function setAttributeByDomain(string $domain, string $key, $value): self
{
$this->attributes[$domain][$key] = $value;
return $this;
}
/**
* Merge the attributes with existing attributes.
*
* Only the key provided will be created or updated.
* Only the key provided will be created or updated. For a two-level array, use @see{User::setAttributeByDomain}
*/
public function setAttributes(array $attributes): self
{

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\Listing;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
@ -70,10 +71,38 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$builder->add($checkboxesBuilder);
}
if (0 < count($helper->getDateRanges())) {
$dateRangesBuilder = $builder->create('dateRanges', null, ['compound' => true]);
foreach ($helper->getDateRanges() as $name => $opts) {
$rangeBuilder = $dateRangesBuilder->create($name, null, [
'compound' => true,
'label' => $opts['label'] ?? $name,
]);
$rangeBuilder->add(
'from',
ChillDateType::class,
['input' => 'datetime_immutable', 'required' => false]
);
$rangeBuilder->add(
'to',
ChillDateType::class,
['input' => 'datetime_immutable', 'required' => false]
);
$dateRangesBuilder->add($rangeBuilder);
}
$builder->add($dateRangesBuilder);
}
foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) {
switch ($key) {
case 'q':
case 'checkboxes' . $key:
case $key . '_from':
case $key . '_to':
break;
case 'page':

View File

@ -15,14 +15,14 @@ use Chill\MainBundle\Entity\GroupCenter;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use function count;
final class UserRepository implements ObjectRepository
final class UserRepository implements UserRepositoryInterface
{
private EntityManagerInterface $entityManager;
@ -144,11 +144,15 @@ final class UserRepository implements ObjectRepository
return $this->repository->findOneBy($criteria, $orderBy);
}
public function findOneByUsernameOrEmail(string $pattern)
public function findOneByUsernameOrEmail(string $pattern): ?User
{
$qb = $this->queryByUsernameOrEmail($pattern);
$qb = $this->queryByUsernameOrEmail($pattern)->select('u');
return $qb->getQuery()->getSingleResult();
try {
return $qb->getQuery()->getSingleResult();
} catch (NoResultException $e) {
return null;
}
}
/**
@ -206,7 +210,7 @@ final class UserRepository implements ObjectRepository
return $qb->getQuery()->getResult();
}
public function getClassName()
public function getClassName(): string
{
return User::class;
}

View File

@ -0,0 +1,73 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\User;
use Doctrine\Persistence\ObjectRepository;
interface UserRepositoryInterface extends ObjectRepository
{
public function countBy(array $criteria): int;
public function countByActive(): int;
public function countByNotHavingAttribute(string $key): int;
public function countByUsernameOrEmail(string $pattern): int;
public function find($id, $lockMode = null, $lockVersion = null): ?User;
/**
* @return User[]
*/
public function findAll(): array;
/**
* @param mixed|null $limit
* @param mixed|null $offset
*
* @return User[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array;
/**
* @return array|User[]
*/
public function findByActive(?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
/**
* Find users which does not have a key on attribute column.
*
* @return array|User[]
*/
public function findByNotHavingAttribute(string $key, ?int $limit = null, ?int $offset = null): array;
public function findByUsernameOrEmail(string $pattern, ?array $orderBy = [], ?int $limit = null, ?int $offset = null): array;
public function findOneBy(array $criteria, ?array $orderBy = null): ?User;
public function findOneByUsernameOrEmail(string $pattern): ?User;
/**
* Get the users having a specific flags.
*
* If provided, only the users amongst "filtered users" are searched. This
* allows to make a first search amongst users based on role and center
* and, then filter those users having some flags.
*
* @param \Chill\MainBundle\Entity\User[] $amongstUsers
* @param mixed $flag
*/
public function findUsersHavingFlags($flag, array $amongstUsers = []): array;
public function getClassName(): string;
}

View File

@ -1,6 +1,3 @@
// import bootstrap variables
@import 'bootstrap/scss/variables';
// Variables
//
// Variables should follow the `$component-state-property-size` formula for

View File

@ -1,5 +1,5 @@
<template>
<ul :class="listClasses" v-if="picked.length">
<ul :class="listClasses" v-if="picked.length && displayPicked">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type+p.id">
<span class="chill_denomination">{{ p.text }}</span>
</li>
@ -44,6 +44,11 @@ export default {
removableIfSet: {
type: Boolean,
default: true,
},
displayPicked: {
// display picked entities.
type: Boolean,
default: true,
}
},
emits: ['addNewEntity', 'removeEntity'],

View File

@ -17,6 +17,11 @@ export default {
hour: "numeric",
minute: "numeric",
hour12: false
},
hoursOnly: {
hour: "numeric",
minute: "numeric",
hour12: false,
}
}
};

View File

@ -10,6 +10,28 @@
</div>
{% endif %}
</div>
{% if form.dateRanges is defined %}
{% if form.dateRanges|length > 0 %}
{% for dateRangeName, _o in form.dateRanges %}
<div class="row gx-2">
<div class="col-md-5">
{{ form_label(form.dateRanges[dateRangeName])}}
</div>
<div class="col-md-6">
<div class="input-group mb-3">
<span class="input-group-text">{{ 'chill_calendar.From'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['from']) }}
<span class="input-group-text">{{ 'chill_calendar.To'|trans }}</span>
{{ form_widget(form.dateRanges[dateRangeName]['to']) }}
</div>
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-misc"><i class="fa fa-filter"></i></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.checkboxes is defined %}
{% if form.checkboxes|length > 0 %}
{% for checkbox_name, options in form.checkboxes %}

View File

@ -0,0 +1,19 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
class NullShortMessageSender implements ShortMessageSenderInterface
{
public function send(ShortMessage $shortMessage): void
{
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
use libphonenumber\PhoneNumber;
class ShortMessage
{
public const PRIORITY_LOW = 'low';
public const PRIORITY_MEDIUM = 'medium';
private string $content;
private PhoneNumber $phoneNumber;
private string $priority = 'low';
public function __construct(string $content, PhoneNumber $phoneNumber, string $priority = 'low')
{
$this->content = $content;
$this->phoneNumber = $phoneNumber;
$this->priority = $priority;
}
public function getContent(): string
{
return $this->content;
}
public function getPhoneNumber(): PhoneNumber
{
return $this->phoneNumber;
}
public function getPriority(): string
{
return $this->priority;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function setPhoneNumber(PhoneNumber $phoneNumber): self
{
$this->phoneNumber = $phoneNumber;
return $this;
}
public function setPriority(string $priority): self
{
$this->priority = $priority;
return $this;
}
}

View File

@ -0,0 +1,32 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* @AsMessageHandler
*/
class ShortMessageHandler implements MessageHandlerInterface
{
private ShortMessageTransporterInterface $messageTransporter;
public function __construct(ShortMessageTransporterInterface $messageTransporter)
{
$this->messageTransporter = $messageTransporter;
}
public function __invoke(ShortMessage $message): void
{
$this->messageTransporter->send($message);
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
interface ShortMessageSenderInterface
{
public function send(ShortMessage $shortMessage): void;
}

View File

@ -0,0 +1,28 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
class ShortMessageTransporter implements ShortMessageTransporterInterface
{
private ShortMessageSenderInterface $sender;
public function __construct(
ShortMessageSenderInterface $sender // hint: must remain at place 0 for DI
) {
$this->sender = $sender;
}
public function send(ShortMessage $shortMessage): void
{
$this->sender->send($shortMessage);
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
interface ShortMessageTransporterInterface
{
public function send(ShortMessage $shortMessage);
}

View File

@ -0,0 +1,71 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessageOvh;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Chill\MainBundle\Service\ShortMessage\ShortMessageSenderInterface;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use Ovh\Api;
use Psr\Log\LoggerInterface;
class OvhShortMessageSender implements ShortMessageSenderInterface
{
private Api $api;
private LoggerInterface $logger;
private PhoneNumberUtil $phoneNumberUtil;
private string $sender;
private string $serviceName;
public function __construct(
Api $api, // for DI, must remains as first argument
string $serviceName, // for di, must remains as second argument
string $sender, // for DI, must remains as third argument
LoggerInterface $logger,
PhoneNumberUtil $phoneNumberUtil
) {
$this->api = $api;
$this->serviceName = $serviceName;
$this->sender = $sender;
$this->logger = $logger;
$this->phoneNumberUtil = $phoneNumberUtil;
}
public function send(ShortMessage $shortMessage): void
{
$receiver = $this->phoneNumberUtil->format($shortMessage->getPhoneNumber(), PhoneNumberFormat::E164);
$response = $this->api->post(
strtr('/sms/{serviceName}/jobs', ['{serviceName}' => $this->serviceName]),
[
'message' => $shortMessage->getContent(),
'receivers' => [$receiver],
'sender' => $this->sender,
'noStopClause' => true,
'coding' => '7bit',
'charset' => 'UTF-8',
'priority' => $shortMessage->getPriority(),
]
);
$improved = array_merge([
'validReceiversI' => implode(',', $response['validReceivers']),
'idsI' => implode(',', $response['ids']),
], $response);
$this->logger->warning('[sms] a sms was sent', $improved);
}
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Form\Type\Listing\FilterOrderType;
use DateTimeImmutable;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@ -23,6 +24,8 @@ class FilterOrderHelper
{
private array $checkboxes = [];
private array $dateRanges = [];
private FormFactoryInterface $formFactory;
private ?string $formName = 'f';
@ -60,6 +63,13 @@ class FilterOrderHelper
return $this;
}
public function addDateRange(string $name, string $label, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null): self
{
$this->dateRanges[$name] = ['from' => $from, 'to' => $to, 'label' => $label];
return $this;
}
public function buildForm(): FormInterface
{
return $this->formFactory
@ -81,6 +91,19 @@ class FilterOrderHelper
return $this->checkboxes;
}
/**
* @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable>
*/
public function getDateRangeData(string $name): array
{
return $this->getFormData()['dateRanges'][$name];
}
public function getDateRanges(): array
{
return $this->dateRanges;
}
public function getQueryString(): ?string
{
return $this->getFormData()['q'];
@ -110,6 +133,11 @@ class FilterOrderHelper
$r['checkboxes'][$name] = $c['default'];
}
foreach ($this->dateRanges as $name => $defaults) {
$r['dateRanges'][$name]['from'] = $defaults['from'];
$r['dateRanges'][$name]['to'] = $defaults['to'];
}
return $r;
}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing;
use DateTimeImmutable;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@ -18,6 +19,8 @@ class FilterOrderHelperBuilder
{
private array $checkboxes = [];
private array $dateRanges = [];
private FormFactoryInterface $formFactory;
private RequestStack $requestStack;
@ -39,6 +42,13 @@ class FilterOrderHelperBuilder
return $this;
}
public function addDateRange(string $name, string $label, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null): self
{
$this->dateRanges[$name] = ['from' => $from, 'to' => $to, 'label' => $label];
return $this;
}
public function addSearchBox(?array $fields = [], ?array $options = []): self
{
$this->searchBoxFields = $fields;
@ -65,6 +75,16 @@ class FilterOrderHelperBuilder
$helper->addCheckbox($name, $choices, $default, $trans);
}
foreach (
$this->dateRanges as $name => [
'from' => $from,
'to' => $to,
'label' => $label,
]
) {
$helper->addDateRange($name, $label, $from, $to);
}
return $helper;
}
}

View File

@ -26,6 +26,7 @@ services:
tags:
- { name: 'doctrine.event_subscriber' }
# workflow related
Chill\MainBundle\Workflow\:
resource: '../Workflow/'

View File

@ -0,0 +1,5 @@
services:
Chill\MainBundle\Service\ShortMessage\:
resource: '../Service/ShortMessage'
autowire: true
autoconfigure: true

View File

@ -46,7 +46,7 @@ class SocialAction
private $desactivationDate;
/**
* @ORM\ManyToMany(targetEntity=Evaluation::class, inversedBy="socialActions")
* @ORM\ManyToMany(targetEntity=Evaluation::class, mappedBy="socialActions")
* @ORM\JoinTable(name="chill_person_social_work_evaluation_action")
*/
private Collection $evaluations;

@ -1 +1 @@
Subproject commit 8694ad7c4de306f9d5e35af966d24fb2e3e1c2ff
Subproject commit 5b35e7ccd0735e5593835e28acbf82386c18e1b6