Julien Fastré 96ddc73e45
Feature: [calendar sync msgraph] Allow to show the calendar details if the user does not have msgraph mapping
We first check that there are "msgraph" attributes before forcing redirection to MSGraph authorization. If not, we let's the user see the calendar edit/create page.
2023-03-23 12:48:37 +01:00

764 lines
27 KiB
PHP

<?php
/**
* Chill is a software for social workers.
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
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\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_key_exists;
use function count;
class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
private array $cacheScheduleTimeForUser = [];
private CalendarRangeRepository $calendarRangeRepository;
private CalendarRepository $calendarRepository;
private LoggerInterface $logger;
private HttpClientInterface $machineHttpClient;
private MapCalendarToUser $mapCalendarToUser;
private RemoteEventConverter $remoteEventConverter;
private OnBehalfOfUserTokenStorage $tokenStorage;
private TranslatorInterface $translator;
private UrlGeneratorInterface $urlGenerator;
private OnBehalfOfUserHttpClient $userHttpClient;
private Security $security;
public function __construct(
CalendarRepository $calendarRepository,
CalendarRangeRepository $calendarRangeRepository,
HttpClientInterface $machineHttpClient,
MapCalendarToUser $mapCalendarToUser,
LoggerInterface $logger,
OnBehalfOfUserTokenStorage $tokenStorage,
OnBehalfOfUserHttpClient $userHttpClient,
RemoteEventConverter $remoteEventConverter,
TranslatorInterface $translator,
UrlGeneratorInterface $urlGenerator,
Security $security
) {
$this->calendarRepository = $calendarRepository;
$this->calendarRangeRepository = $calendarRangeRepository;
$this->machineHttpClient = $machineHttpClient;
$this->mapCalendarToUser = $mapCalendarToUser;
$this->logger = $logger;
$this->remoteEventConverter = $remoteEventConverter;
$this->tokenStorage = $tokenStorage;
$this->translator = $translator;
$this->urlGenerator = $urlGenerator;
$this->userHttpClient = $userHttpClient;
$this->security = $security;
}
public function countEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): int
{
$userId = $this->mapCalendarToUser->getUserId($user);
if (null === $userId) {
return 0;
}
try {
$data = $this->userHttpClient->request(
'GET',
'users/' . $userId . '/calendarView',
[
'query' => [
'startDateTime' => $startDate->format(DateTimeImmutable::ATOM),
'endDateTime' => $endDate->format(DateTimeImmutable::ATOM),
'$count' => 'true',
'$top' => 0,
],
]
)->toArray();
} catch (ClientExceptionInterface $e) {
if (403 === $e->getResponse()->getStatusCode()) {
return count($this->getScheduleTimesForUser($user, $startDate, $endDate));
}
$this->logger->error('Could not get list of event on MSGraph', [
'error_code' => $e->getResponse()->getStatusCode(),
'error' => $e->getResponse()->getInfo(),
]);
return 0;
}
return $data['@odata.count'];
}
public function getMakeReadyResponse(string $returnPath): Response
{
return new RedirectResponse($this->urlGenerator
->generate('chill_calendar_remote_connect_azure', ['returnPath' => $returnPath]));
}
public function isReady(): bool
{
$user = $this->security->getUser();
if (!$user instanceof User) {
// this is not a user from chill. This is not the role of this class to
// restrict access, so we will just say that we do not have to do anything more
// here...
return true;
}
if (null === $this->mapCalendarToUser->getUserId($user)) {
// this user is not mapped with remote calendar. The user will have to wait for
// the next calendar subscription iteration
$this->logger->debug('mark user ready for msgraph calendar as he does not have any mapping', [
'userId' => $user->getId(),
]);
return true;
}
return $this->tokenStorage->hasToken();
}
/**
* @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|\Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent[]
*/
public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array
{
$userId = $this->mapCalendarToUser->getUserId($user);
if (null === $userId) {
return [];
}
try {
$bareEvents = $this->userHttpClient->request(
'GET',
'users/' . $userId . '/calendarView',
[
'query' => [
'startDateTime' => $startDate->format(DateTimeImmutable::ATOM),
'endDateTime' => $endDate->format(DateTimeImmutable::ATOM),
'$select' => 'id,subject,start,end,isAllDay',
'$top' => $limit,
'$skip' => $offset,
],
]
)->toArray();
$ids = array_map(static function ($item) { return $item['id']; }, $bareEvents['value']);
$existingIdsInRange = $this->calendarRangeRepository->findRemoteIdsPresent($ids);
$existingIdsInCalendar = $this->calendarRepository->findRemoteIdsPresent($ids);
return array_values(
array_map(
function ($item) {
return $this->remoteEventConverter->convertToRemote($item);
},
// filter all event to keep only the one not in range
array_filter(
$bareEvents['value'],
static function ($item) use ($existingIdsInRange, $existingIdsInCalendar) {
return ((!$existingIdsInRange[$item['id']]) ?? true) && ((!$existingIdsInCalendar[$item['id']]) ?? true);
}
)
)
);
} catch (ClientExceptionInterface $e) {
if (403 === $e->getResponse()->getStatusCode()) {
return $this->getScheduleTimesForUser($user, $startDate, $endDate);
}
$this->logger->error('Could not get list of event on MSGraph', [
'error_code' => $e->getResponse()->getStatusCode(),
'error' => $e->getResponse()->getInfo(),
]);
return [];
}
}
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void
{
if ('' === $remoteId) {
return;
}
$this->removeEvent($remoteId, $user);
if (null !== $associatedCalendarRange) {
$this->syncCalendarRange($associatedCalendarRange);
}
}
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void
{
if ('' === $remoteId) {
return;
}
$this->removeEvent($remoteId, $user);
}
public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void
{
/*
* cases to support:
*
* * a calendar range is created:
* * create on remote
* * if calendar range is associated: remove the range
* * a Calendar change the CalendarRange:
* * re-create the previous calendar range;
* * remove the current calendar range
* * a calendar change the mainUser
* * cancel the calendar in the previous mainUser
* * recreate the previous calendar range in the previousMainUser, if any
* * delete the current calendar range in the current mainUser, if any
* * create the calendar in the current mainUser
*
*/
if (!$calendar->hasRemoteId()) {
$this->createCalendarOnRemote($calendar);
} else {
if (null !== $previousMainUser) {
// cancel event in previousMainUserCalendar
$this->cancelOnRemote(
$calendar->getRemoteId(),
$this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]),
$previousMainUser,
'calendar_' . $calendar->getRemoteId()
);
$this->createCalendarOnRemote($calendar);
} else {
$this->patchCalendarOnRemote($calendar, $newInvites);
}
}
if ($calendar->hasCalendarRange() && $calendar->getCalendarRange()->hasRemoteId()) {
$this->removeEvent(
$calendar->getCalendarRange()->getRemoteId(),
$calendar->getCalendarRange()->getUser()
);
$calendar->getCalendarRange()
->addRemoteAttributes([
'lastModifiedDateTime' => null,
'changeKey' => null,
'previousId' => $calendar->getCalendarRange()->getRemoteId(),
])
->setRemoteId('');
}
if (null !== $previousCalendarRange) {
$this->createRemoteCalendarRange($previousCalendarRange);
}
}
public function syncCalendarRange(CalendarRange $calendarRange): void
{
if ($calendarRange->hasRemoteId()) {
$this->updateRemoteCalendarRange($calendarRange);
} else {
$this->createRemoteCalendarRange($calendarRange);
}
}
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);
if (null === $userId) {
return;
}
try {
$this->machineHttpClient->request(
'POST',
"users/{$userId}/calendar/events/{$remoteId}/cancel",
[
'json' => ['Comment' => $comment],
]
);
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'content' => $e->getResponse()->getContent(),
'calendarRangeId' => $identifier,
]);
throw $e;
}
}
private function createCalendarOnRemote(Calendar $calendar): void
{
$eventData = $this->remoteEventConverter->calendarToEvent($calendar);
[
'id' => $id,
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey
] = $this->createOnRemote($eventData, $calendar->getMainUser(), 'calendar_' . $calendar->getId());
if (null === $id) {
return;
}
$calendar
->setRemoteId($id)
->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey,
]);
}
/**
* @param string $identifier an identifier for logging in case of something does not work
*
* @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string}
*/
private function createOnRemote(array $eventData, User $user, string $identifier): array
{
$userId = $this->mapCalendarToUser->getUserId($user);
if (null === $userId) {
$this->logger->warning('user does not have userId nor calendarId', [
'user_id' => $user->getId(),
'calendar_identifier' => $identifier,
]);
return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
}
try {
$event = $this->machineHttpClient->request(
'POST',
'users/' . $userId . '/calendar/events',
[
'json' => $eventData,
]
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not save calendar range to remote', [
'exception' => $e->getTraceAsString(),
'content' => $e->getResponse()->getContent(),
'calendar_identifier' => $identifier,
]);
throw $e;
}
return [
'id' => $event['id'],
'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
'changeKey' => $event['changeKey'],
];
}
private function createRemoteCalendarRange(CalendarRange $calendarRange): void
{
$userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser());
if (null === $userId) {
$this->logger->warning('user does not have userId nor calendarId', [
'user_id' => $calendarRange->getUser()->getId(),
'calendar_range_id' => $calendarRange->getId(),
]);
return;
}
$eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange);
[
'id' => $id,
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey
] = $this->createOnRemote(
$eventData,
$calendarRange->getUser(),
'calendar_range_' . $calendarRange->getId()
);
$calendarRange->setRemoteId($id)
->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey,
]);
}
/**
* 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);
if (null === $userId) {
throw new Exception('no remote calendar for this user', [
'user' => $user->getId(),
'remoteId' => $remoteId,
]);
}
try {
return $this->machineHttpClient->request(
'GET',
'users/' . $userId . '/calendar/events/' . $remoteId
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->warning('Could not get event from calendar', [
'remoteId' => $remoteId,
]);
throw $e;
}
}
private function getScheduleTimesForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): array
{
$userId = $this->mapCalendarToUser->getUserId($user);
if (array_key_exists($userId, $this->cacheScheduleTimeForUser)) {
return $this->cacheScheduleTimeForUser[$userId];
}
if (null === $userId) {
return [];
}
if (null === $user->getEmailCanonical() || '' === $user->getEmailCanonical()) {
return [];
}
$body = [
'schedules' => [$user->getEmailCanonical()],
'startTime' => [
'dateTime' => ($startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())),
'timeZone' => 'UTC',
],
'endTime' => [
'dateTime' => ($endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat())),
'timeZone' => 'UTC',
],
];
try {
$response = $this->userHttpClient->request('POST', 'users/' . $userId . '/calendar/getSchedule', [
'json' => $body,
])->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->debug('Could not get schedule on MSGraph', [
'error_code' => $e->getResponse()->getStatusCode(),
'error' => $e->getResponse()->getInfo(),
]);
return [];
}
$this->cacheScheduleTimeForUser[$userId] = array_map(
function ($item) {
return $this->remoteEventConverter->convertAvailabilityToRemoteEvent($item);
},
$response['value'][0]['scheduleItems']
);
return $this->cacheScheduleTimeForUser[$userId];
}
private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void
{
$eventDatas = [];
$eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);
if (0 < count($newInvites)) {
// 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) {
[
'id' => $id,
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey
] = $this->patchOnRemote(
$calendar->getRemoteId(),
$eventData,
$calendar->getMainUser(),
'calendar_' . $calendar->getId()
);
$calendar->addRemoteAttributes([
'lastModifiedDateTime' => $lastModified,
'changeKey' => $changeKey,
]);
}
}
/**
* @param string $identifier an identifier for logging in case of something does not work
*
* @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string}
*/
private function patchOnRemote(string $remoteId, array $eventData, User $user, string $identifier): array
{
$userId = $this->mapCalendarToUser->getUserId($user);
if (null === $userId) {
$this->logger->warning('user does not have userId nor calendarId', [
'user_id' => $user->getId(),
'calendar_identifier' => $identifier,
]);
return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
}
try {
$event = $this->machineHttpClient->request(
'PATCH',
'users/' . $userId . '/calendar/events/' . $remoteId,
[
'json' => $eventData,
]
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'calendarRangeId' => $identifier,
]);
throw $e;
}
return [
'id' => $event['id'],
'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
'changeKey' => $event['changeKey'],
];
}
private function removeEvent($remoteId, User $user): void
{
$userId = $this->mapCalendarToUser->getUserId($user);
try {
$this->machineHttpClient->request(
'DELETE',
'users/' . $userId . '/calendar/events/' . $remoteId
);
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not remove event from calendar', [
'event_remote_id' => $remoteId,
'user_id' => $user->getId(),
]);
}
}
private function updateRemoteCalendarRange(CalendarRange $calendarRange): void
{
$userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser());
$calendarId = $this->mapCalendarToUser->getCalendarId($calendarRange->getUser());
if (null === $userId || null === $calendarId) {
$this->logger->warning('user does not have userId nor calendarId', [
'user_id' => $calendarRange->getUser()->getId(),
'calendar_range_id' => $calendarRange->getId(),
]);
return;
}
try {
$event = $this->machineHttpClient->request(
'GET',
'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId()
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->warning('Could not get event from calendar', [
'calendar_range_id' => $calendarRange->getId(),
'calendar_range_remote_id' => $calendarRange->getRemoteId(),
]);
throw $e;
}
if ($this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp() > $calendarRange->getUpdatedAt()->getTimestamp()) {
$this->logger->info('Skip updating as the lastModified date seems more fresh than the database one', [
'calendar_range_id' => $calendarRange->getId(),
'calendar_range_remote_id' => $calendarRange->getRemoteId(),
'db_last_updated' => $calendarRange->getUpdatedAt()->getTimestamp(),
'remote_last_updated' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
]);
return;
}
$eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange);
try {
$event = $this->machineHttpClient->request(
'PATCH',
'users/' . $userId . '/calendar/events/' . $calendarRange->getRemoteId(),
[
'json' => $eventData,
]
)->toArray();
} catch (ClientExceptionInterface $e) {
$this->logger->warning('could not update calendar range to remote', [
'exception' => $e->getTraceAsString(),
'calendarRangeId' => $calendarRange->getId(),
]);
throw $e;
}
$calendarRange
->addRemoteAttributes([
'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
'changeKey' => $event['changeKey'],
]);
}
}