diff --git a/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php new file mode 100644 index 000000000..960459617 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/InviteApiController.php @@ -0,0 +1,69 @@ +security = $security; + $this->entityManager = $entityManager; + } + + /** + * Give an answer to a calendar invite. + * + * @Route("/api/1.0/calendar/calendar/{id}/answer/{answer}.json", methods={"post"}) + */ + public function answer(Calendar $calendar, string $answer): Response + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException('not a regular user'); + } + + if (null === $invite = $calendar->getInviteForUser($user)) { + throw new AccessDeniedHttpException('not invited to this calendar'); + } + + if (!$this->security->isGranted(InviteVoter::ANSWER, $invite)) { + throw new AccessDeniedHttpException('not allowed to answer on this invitation'); + } + + if (!in_array($answer, Invite::STATUSES, true) || Invite::PENDING === $answer) { + throw new BadRequestHttpException('answer not valid'); + } + + $invite->setStatus($answer); + $this->entityManager->flush(); + + return new JsonResponse(null, Response::HTTP_ACCEPTED, [], false); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index 2a05c1c77..af23c26c4 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -26,6 +26,7 @@ use DateInterval; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; use LogicException; use Symfony\Component\Serializer\Annotation as Serializer; @@ -212,7 +213,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface public function addUser(User $user): self { - if (!$this->getUsers()->contains($user)) { + if (!$this->getUsers()->contains($user) && $this->getMainUser() !== $user) { $this->addInvite((new Invite())->setUser($user)); } @@ -263,6 +264,21 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface return $this->id; } + public function getInviteForUser(User $user): ?Invite + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('user', $user)); + + $matchings = $this->invites + ->matching($criteria); + + if (1 === $matchings->count()) { + return $matchings->first(); + } + + return null; + } + /** * @return Collection|Invite[] */ @@ -365,6 +381,18 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface return null !== $this->calendarRange; } + /** + * return true if the user is invited. + */ + public function isInvited(User $user): bool + { + if ($this->getMainUser() === $user) { + return false; + } + + return $this->getUsers()->contains($user); + } + public static function loadValidatorMetadata(ClassMetadata $metadata): void { $metadata->addPropertyConstraint('startDate', new NotBlank()); @@ -485,6 +513,7 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface } $this->mainUser = $mainUser; + $this->removeUser($mainUser); return $this; } diff --git a/src/Bundle/ChillCalendarBundle/Entity/Invite.php b/src/Bundle/ChillCalendarBundle/Entity/Invite.php index 751727af8..be80a3e56 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Invite.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Invite.php @@ -36,6 +36,16 @@ class Invite implements TrackUpdateInterface, TrackCreationInterface public const PENDING = 'pending'; + /** + * all statuses in one const. + */ + public const STATUSES = [ + self::ACCEPTED, + self::DECLINED, + self::PENDING, + self::TENTATIVELY_ACCEPTED, + ]; + public const TENTATIVELY_ACCEPTED = 'tentative'; /** diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php index f73fcd31b..fcc74cb1c 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php @@ -21,7 +21,6 @@ use DateTimeImmutable; use DateTimeZone; use Symfony\Component\Templating\EngineInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use Twig\Environment; /** * Convert Chill Calendar event to Remote MS Graph event, and MS Graph @@ -35,14 +34,14 @@ class RemoteEventConverter private DateTimeZone $defaultDateTimeZone; + private EngineInterface $engine; + private PersonRenderInterface $personRender; private DateTimeZone $remoteDateTimeZone; private TranslatorInterface $translator; - private EngineInterface $engine; - public function __construct(EngineInterface $engine, PersonRenderInterface $personRender, TranslatorInterface $translator) { $this->engine = $engine; @@ -112,7 +111,7 @@ class RemoteEventConverter '@ChillCalendar/MSGraph/calendar_event_body.html.twig', ['calendar' => $calendar] ), - ] + ], ], $this->calendarToEventAttendeesOnly($calendar) ); @@ -129,17 +128,6 @@ class RemoteEventConverter ]; } - private function buildInviteToAttendee(Invite $invite): array - { - return [ - 'emailAddress' => [ - 'address' => $invite->getUser()->getEmail(), - 'name' => $invite->getUser()->getLabel(), - ], - 'type' => 'Required', - ]; - } - public function convertAvailabilityToRemoteEvent(array $event): RemoteEvent { $startDate = @@ -193,4 +181,15 @@ class RemoteEventConverter { return new DateTimeZone('UTC'); } + + private function buildInviteToAttendee(Invite $invite): array + { + return [ + 'emailAddress' => [ + 'address' => $invite->getUser()->getEmail(), + 'name' => $invite->getUser()->getLabel(), + ], + 'type' => 'Required', + ]; + } } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php index 80a5dd792..5def925c2 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraphRemoteCalendarConnector.php @@ -28,6 +28,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use function count; class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface { @@ -41,10 +42,10 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface private RemoteEventConverter $remoteEventConverter; - private TranslatorInterface $translator; - private OnBehalfOfUserTokenStorage $tokenStorage; + private TranslatorInterface $translator; + private UrlGeneratorInterface $urlGenerator; private OnBehalfOfUserHttpClient $userHttpClient; @@ -171,7 +172,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface $calendar->getRemoteId(), $this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]), $previousMainUser, - 'calendar_'.$calendar->getRemoteId() + 'calendar_' . $calendar->getRemoteId() ); $this->createCalendarOnRemote($calendar); } else { @@ -189,10 +190,9 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface ->addRemoteAttributes([ 'lastModifiedDateTime' => null, 'changeKey' => null, - 'previousId' => $calendar->getCalendarRange()->getRemoteId() + 'previousId' => $calendar->getCalendarRange()->getRemoteId(), ]) - ->setRemoteId('') - ; + ->setRemoteId(''); } if (null !== $previousCalendarRange) { @@ -209,31 +209,30 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface } } - private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void + private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void { - $eventDatas = []; - $eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar); + $userId = $this->mapCalendarToUser->getUserId($user); - if (0 < count($newInvites)) { - $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar); + if (null === $userId) { + return; } - foreach ($eventDatas as $eventData) { - [ - 'id' => $id, - 'lastModifiedDateTime' => $lastModified, - 'changeKey' => $changeKey - ] = $this->patchOnRemote( - $calendar->getRemoteId(), - $eventData, - $calendar->getMainUser(), - 'calendar_'.$calendar->getId() + try { + $this->machineHttpClient->request( + 'POST', + "users/{$userId}/calendar/events/{$remoteId}/cancel", + [ + 'json' => ['Comment' => $comment], + ] ); - - $calendar->addRemoteAttributes([ - 'lastModifiedDateTime' => $lastModified, - 'changeKey' => $changeKey, + } catch (ClientExceptionInterface $e) { + $this->logger->warning('could not update calendar range to remote', [ + 'exception' => $e->getTraceAsString(), + 'content' => $e->getResponse()->getContent(), + 'calendarRangeId' => $identifier, ]); + + throw $e; } } @@ -330,7 +329,7 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface $calendarRange->setRemoteId($id) ->addRemoteAttributes([ 'lastModifiedDateTime' => $lastModified, - 'changeKey' => $changeKey + 'changeKey' => $changeKey, ]); } @@ -404,33 +403,32 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface ); } - private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void + private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void { - $userId = $this->mapCalendarToUser->getUserId($user); + $eventDatas = []; + $eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar); - if (null === $userId) { - return; + if (0 < count($newInvites)) { + $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar); } - try { - $this->machineHttpClient->request( - 'POST', - "users/{$userId}/calendar/events/{$remoteId}/cancel", - [ - 'json' => ['Comment' => $comment] - ] + foreach ($eventDatas as $eventData) { + [ + 'id' => $id, + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey + ] = $this->patchOnRemote( + $calendar->getRemoteId(), + $eventData, + $calendar->getMainUser(), + 'calendar_' . $calendar->getId() ); - } catch (ClientExceptionInterface $e) { - $this->logger->warning('could not update calendar range to remote', [ - 'exception' => $e->getTraceAsString(), - 'content' => $e->getResponse()->getContent(), - 'calendarRangeId' => $identifier, + + $calendar->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified, + 'changeKey' => $changeKey, ]); - - throw $e; } - - } /** diff --git a/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php new file mode 100644 index 000000000..83caa5f64 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Security/Voter/InviteVoter.php @@ -0,0 +1,35 @@ +getUser() === $subject->getUser(); + } +} diff --git a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml index 98e90d20d..1c055a24e 100644 --- a/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillCalendarBundle/chill.api.specs.yaml @@ -1,204 +1,240 @@ --- -openapi: "3.0.0" -info: - version: "1.0.0" - title: "Chill api" - description: "Api documentation for chill. Currently, work in progress" -servers: - - url: "/api" - description: "Your current dev server" +#openapi: "3.0.0" +#info: +# version: "1.0.0" +# title: "Chill api" +# description: "Api documentation for chill. Currently, work in progress" +#servers: +# - url: "/api" +# description: "Your current dev server" components: - schemas: - Date: - type: object - properties: - datetime: - type: string - format: date-time - User: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - user - username: - type: string - text: - type: string + schemas: + Date: + type: object + properties: + datetime: + type: string + format: date-time + User: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + username: + type: string + text: + type: string paths: - /1.0/calendar/calendar.json: - get: - tags: - - calendar - summary: Return a list of all calendar items - responses: - 200: - description: "ok" + /1.0/calendar/calendar/{id}/answer/{answer}.json: + post: + tags: + - calendar + summary: Answer to a calendar's invite + parameters: + - + in: path + name: id + required: true + description: the calendar id + schema: + type: integer + format: integer + minimum: 0 + - + in: path + name: answer + required: true + description: the answer + schema: + type: string + enum: + - accepted + - declined + - tentative + responses: + 400: + description: bad answer + 403: + description: not invited + 404: + description: not found + 202: + description: accepted - /1.0/calendar/calendar/{id}.json: - get: - tags: - - calendar - summary: Return an calendar item by id - parameters: - - name: id - in: path - required: true - description: The calendar id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/calendar/calendar.json: + get: + tags: + - calendar + summary: Return a list of all calendar items + responses: + 200: + description: "ok" - /1.0/calendar/calendar-range.json: - get: - tags: - - calendar - summary: Return a list of all calendar range items - responses: - 200: - description: "ok" - post: - tags: - - calendar - summary: create a new calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" + /1.0/calendar/calendar/{id}.json: + get: + tags: + - calendar + summary: Return an calendar item by id + parameters: + - name: id + in: path + required: true + description: The calendar id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/calendar/calendar-range/{id}.json: - get: - tags: - - calendar - summary: Return an calendar-range item by id - parameters: - - name: id - in: path - required: true - description: The calendar-range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 404: - description: "not found" - 401: - description: "Unauthorized" - patch: - tags: - - calendar - summary: update a calendar range - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user: - $ref: '#/components/schemas/User' - startDate: - $ref: '#/components/schemas/Date' - endDate: - $ref: '#/components/schemas/Date' - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applyed" - delete: - tags: - - calendar - summary: "Remove a calendar range" - parameters: - - name: id - in: path - required: true - description: The calendar range id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/calendar/calendar-range.json: + get: + tags: + - calendar + summary: Return a list of all calendar range items + responses: + 200: + description: "ok" + post: + tags: + - calendar + summary: create a new calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" - /1.0/calendar/calendar-range-available/{userId}.json: - get: - tags: - - calendar - summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity - parameters: - - name: userId - in: path - required: true - description: The user id - schema: - type: integer - format: integer - minimum: 1 - - name: dateFrom - in: query - required: true - description: The date from, formatted as ISO8601 string - schema: - type: string - format: date-time - - name: dateTo - in: query - required: true - description: The date to, formatted as ISO8601 string - schema: - type: string - format: date-time - responses: - 200: - description: "ok" + /1.0/calendar/calendar-range/{id}.json: + get: + tags: + - calendar + summary: Return an calendar-range item by id + parameters: + - name: id + in: path + required: true + description: The calendar-range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 404: + description: "not found" + 401: + description: "Unauthorized" + patch: + tags: + - calendar + summary: update a calendar range + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user: + $ref: '#/components/schemas/User' + startDate: + $ref: '#/components/schemas/Date' + endDate: + $ref: '#/components/schemas/Date' + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applyed" + delete: + tags: + - calendar + summary: "Remove a calendar range" + parameters: + - name: id + in: path + required: true + description: The calendar range id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + + /1.0/calendar/calendar-range-available/{userId}.json: + get: + tags: + - calendar + summary: Return a list of available calendar range items. Available means calendar-range not being taken by a calendar entity + parameters: + - name: userId + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 + - name: dateFrom + in: query + required: true + description: The date from, formatted as ISO8601 string + schema: + type: string + format: date-time + - name: dateTo + in: query + required: true + description: The date to, formatted as ISO8601 string + schema: + type: string + format: date-time + responses: + 200: + description: "ok"