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 @@ + 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 @@ - 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 @@ {{ 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\n
Réunion Microsoft Teams\r\n
\r\n
\r\n
Rejoindre sur votre ordinateur ou application mobile\r\n
\r\nCliquez\r\n ici pour participer à la réunion
\r\n
Pour 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\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": "\r\n\r\n\r\n\r\n\r\n
\r\n
\r\n
________________________________________________________________________________\r\n
\r\n
\r\n
Réunion Microsoft Teams\r\n
\r\n
\r\n
Rejoindre sur votre ordinateur ou application mobile\r\n
\r\nCliquez\r\n ici pour participer à la réunion
\r\n
Pour 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\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()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php new file mode 100644 index 000000000..fc1262d64 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/BulkCalendarShortMessageSenderTest.php @@ -0,0 +1,128 @@ +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()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php new file mode 100644 index 000000000..ffeefa213 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/CalendarForShortMessageProviderTest.php @@ -0,0 +1,103 @@ +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); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php new file mode 100644 index 000000000..7d873c0c1 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultRangeGeneratorTest.php @@ -0,0 +1,94 @@ + 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)); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php new file mode 100644 index 000000000..736bea81d --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilderTest.php @@ -0,0 +1,108 @@ +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()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php new file mode 100644 index 000000000..1ae43eb41 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606153851.php @@ -0,0 +1,43 @@ +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)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php new file mode 100644 index 000000000..990e5bcab --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220606154119.php @@ -0,0 +1,43 @@ +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)\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php new file mode 100644 index 000000000..390e1b743 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220608084052.php @@ -0,0 +1,36 @@ +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)'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php new file mode 100644 index 000000000..8894da5d3 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220609200857.php @@ -0,0 +1,43 @@ +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 <> \'\''); + } +} diff --git a/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php new file mode 100644 index 000000000..9e9099384 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/migrations/Version20220613202636.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index 974812a9e..a038a11ea 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -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 diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index 32e075b7f..54da6911a 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -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()); } } diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 929bffe14..a435bbe5d 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -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', [ diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php new file mode 100644 index 000000000..fa9994408 --- /dev/null +++ b/src/Bundle/ChillMainBundle/DependencyInjection/CompilerPass/ShortMessageCompilerPass.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php index a9b3ffec0..19b43de43 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php @@ -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() diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index f72bfec42..3c9bf7959 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -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 { diff --git a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php index 2b6cdc21d..fade6230c 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php @@ -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': diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepository.php b/src/Bundle/ChillMainBundle/Repository/UserRepository.php index d72443aa8..dfb5c2c5d 100644 --- a/src/Bundle/ChillMainBundle/Repository/UserRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/UserRepository.php @@ -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; } diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php new file mode 100644 index 000000000..75e8b90b4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Repository/UserRepositoryInterface.php @@ -0,0 +1,73 @@ + -