diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig index 8e1d8c713..9b64403b1 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/concernedGroups.html.twig @@ -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' %} {{ 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 -%} {% else %} {{ _self.insert_onthefly(bloc.type, item) }} diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php new file mode 100644 index 000000000..5fb3f9acc --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -0,0 +1,164 @@ +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' + ); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php deleted file mode 100644 index e0f68ec3a..000000000 --- a/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php +++ /dev/null @@ -1,58 +0,0 @@ -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; - } -} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php new file mode 100644 index 000000000..091436561 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendShortMessageOnEligibleCalendar.php @@ -0,0 +1,41 @@ +messageSender = $messageSender; + } + + public function getName() + { + return 'chill:calendar:send-short-messages'; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->messageSender->sendBulkMessageToEligibleCalendars(); + + return 0; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php new file mode 100644 index 000000000..9d79f5654 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Command/SendTestShortMessageOnCalendarCommand.php @@ -0,0 +1,200 @@ +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; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index 1361c7b62..110d07179 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -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; diff --git a/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php index 960459617..05ab52718 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php @@ -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); } } diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php index f8d67b093..cd220972f 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php @@ -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) { diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php new file mode 100644 index 000000000..e07895782 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php index 3246814e6..9d556e6d8 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php @@ -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) diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php index bb3ab576d..0d295ccbb 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php @@ -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() diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index 89f1c25b7..da024fa94 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -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; } } diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index 088842876..76596347c 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -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; } diff --git a/src/Bundle/ChillCalendarBundle/Entity/Invite.php b/src/Bundle/ChillCalendarBundle/Entity/Invite.php index be80a3e56..e53ca1ca1 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Invite.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Invite.php @@ -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; diff --git a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php index fc3db2d43..05fa11524 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CalendarType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CalendarType.php @@ -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; diff --git a/src/Bundle/ChillCalendarBundle/Menu/AccompanyingCourseMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/AccompanyingCourseMenuBuilder.php index fdc07d56d..b3efc635f 100644 --- a/src/Bundle/ChillCalendarBundle/Menu/AccompanyingCourseMenuBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Menu/AccompanyingCourseMenuBuilder.php @@ -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]); } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php new file mode 100644 index 000000000..ece0b6929 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/InviteUpdateHandler.php @@ -0,0 +1,48 @@ +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(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php new file mode 100644 index 000000000..bcc0a5bc9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php @@ -0,0 +1,103 @@ +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(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php new file mode 100644 index 000000000..ddb5d9028 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/InviteUpdateMessage.php @@ -0,0 +1,38 @@ +inviteId = $invite->getId(); + $this->byUserId = $byUser->getId(); + } + + public function getByUserId(): int + { + return $this->byUserId; + } + + public function getInviteId(): int + { + return $this->inviteId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php new file mode 100644 index 000000000..f5e0f98cb --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php @@ -0,0 +1,35 @@ +content = $content; + $this->userId = $userId; + } + + public function getContent(): array + { + return $this->content; + } + + public function getUserId(): int + { + return $this->userId; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php new file mode 100644 index 000000000..de80aa3fd --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/EventsOnUserSubscriptionCreator.php @@ -0,0 +1,133 @@ +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 + */ + 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 + */ + 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()]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php new file mode 100644 index 000000000..d3caea942 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSGraphUserRepository.php @@ -0,0 +1,77 @@ +'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(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php index 680aaa0a7..32a4cdf01 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php @@ -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( diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php index ff14e1d49..f1d7e883f 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php @@ -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); diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php index fcc74cb1c..4ff42cd6f 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php @@ -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 = diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php new file mode 100644 index 000000000..18d1e4632 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php @@ -0,0 +1,103 @@ +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']); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php new file mode 100644 index 000000000..db947e039 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarSyncer.php @@ -0,0 +1,182 @@ +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); + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php index 5def925c2..d346d67bc 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php @@ -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 { diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php index ef0db2548..a1803d9c1 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/NullRemoteCalendarConnector.php @@ -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 + { + } } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php index c55c06f86..4eebb8863 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php @@ -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; } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index 852dd6893..f6631f0cc 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -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); + } } } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php new file mode 100644 index 000000000..4832d2735 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepository.php @@ -0,0 +1,80 @@ +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(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php new file mode 100644 index 000000000..bc62a6a65 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarACLAwareRepositoryInterface.php @@ -0,0 +1,26 @@ +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; + } } diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml index 4dcda0fd8..7e316acf3 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services.yml @@ -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 diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js index ab10f72ee..a97fe584d 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/module/Invite/answer.js @@ -14,7 +14,18 @@ document.addEventListener('DOMContentLoaded', function (e) { components: { Answer, }, - template: '', + data() { + return { + status: el.dataset.status, + calendarId: Number.parseInt(el.dataset.calendarId), + } + }, + template: '', + methods: { + onStatusChanged: function(newStatus) { + this.$data.status = newStatus; + }, + } }); app.use(i18n).mount(el); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue index b53d94981..aac253737 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue @@ -1,37 +1,72 @@ - - - Utilisateur principal - - - - - - - - 5 - 10 - 15 - 30 - - - - Masquer les week-ends - - + + + + Utilisateur principal - - - + + + + + + + + + + + + + Date + + {{ $d(activity.startDate, 'long') }} - {{ $d(activity.endDate, 'hoursOnly') }} + (Pas de plage de disponibilité sélectionnée) + (Une plage de disponibilité sélectionnée) + + + + + + + + + + + + + + + + + Durée des créneaux + + 5 minutes + 10 minutes + 15 minutes + 30 minutes + + + + + + + + + Masquer les week-ends + + + + + {{ arg.timeText }} @@ -39,7 +74,6 @@ - + diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue index 23ef4565e..cd6e52815 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue @@ -1,15 +1,24 @@ - - {{ user.text }} - - - Disponibilité - - - - Agenda - - + + + {{ user.text }} + + + + + + {{ invite.status }} + + + + + + + + + + + - diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js index ab24d17ac..741dbd120 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/actions.js @@ -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"); diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js index 3f87ca203..8dfbbd32c 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/getters.js @@ -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 * diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js index 01322be0f..5fb6b1d6a 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/index.js @@ -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; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.js b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.js index 7c36b4342..176b84a6b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.js +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.js @@ -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, diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue index d001334ae..4607af7fc 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue @@ -1,31 +1,89 @@ - - {{ $t('answer')}} + + + {{ $t('Give_an_answer')}} + + + {{ $t('Accepted')}} + + + {{ $t('Declined')}} + + + {{ $t('Tentative')}} + - {{ $t('accept') }} - {{ $t('decline') }} - {{ $t('tentatively_accept') }} - {{ $t('pending') }} + {{ $t('Accept') }} + {{ $t('Decline') }} + {{ $t('Tentatively_accept') }} + {{ $t('Set_pending') }} {{ encore_entry_script_tags('vue_calendar') }} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig new file mode 100644 index 000000000..d6c46a7e9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message.txt.twig @@ -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 %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig new file mode 100644 index 000000000..82453a7a9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig @@ -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 %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig new file mode 100644 index 000000000..c3f2aef58 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/_invite.html.twig @@ -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 %} + +{% endmacro %} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php new file mode 100644 index 000000000..06a67c1ce --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/BulkCalendarShortMessageSender.php @@ -0,0 +1,64 @@ +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]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php new file mode 100644 index 000000000..feb3b698b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/CalendarForShortMessageProvider.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php new file mode 100644 index 000000000..0587296a5 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultRangeGenerator.php @@ -0,0 +1,68 @@ + 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]; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php new file mode 100644 index 000000000..2abb3cb00 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php new file mode 100644 index 000000000..7117d982b --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/RangeGeneratorInterface.php @@ -0,0 +1,22 @@ + + */ + public function generateRange(DateTimeImmutable $date): array; +} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php new file mode 100644 index 000000000..ffcc8fe5c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/ShortMessageForCalendarBuilderInterface.php @@ -0,0 +1,23 @@ +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()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php new file mode 100644 index 000000000..6ff112956 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php @@ -0,0 +1,248 @@ +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); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php new file mode 100644 index 000000000..f7f658b92 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarSyncerTest.php @@ -0,0 +1,589 @@ +\r\n\r\n\r\n\r\n\r\n\r\n\r\n________________________________________________________________________________\r\n\r\n\r\nRéunion Microsoft Teams\r\n\r\n\r\nRejoindre sur votre ordinateur ou application mobile\r\n\r\nCliquez\r\n ici pour participer à la réunion \r\nPour en savoir\r\n plus | \r\nOptions de réunion \r\n\r\n\r\n\r\n\r\n\r\n________________________________________________________________________________\r\n\r\n\r\n