From be4f37cda7b22a89c4f2658c90710ffbbd040ba0 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 06:45:31 +0200 Subject: [PATCH 01/12] 6871: Cleaned up caching bugs --- src/InteractiveSlide/InstantBook.php | 67 +++++++++++++++++++--------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 489575561..7077383fa 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -167,8 +167,11 @@ private function getQuickBookOptions(Tenant $tenant, Slide $slide, InteractionSl $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); - return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, - function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant) { + $siblingEntries = []; + $updatedWatchedResources = []; + + $result = $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant, &$siblingEntries, &$updatedWatchedResources) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); // If any exceptions are thrown we return an empty options entry. @@ -196,7 +199,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); // Get resources that are watched for availability. - $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); + $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, function (CacheItemInterface $item) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + + return []; + }); // Add resource to watchedResources, if not in list. if (!in_array($resource, $watchedResources)) { @@ -207,7 +214,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $result = []; - // Refresh entries for all watched resources. + // Compute entries for all watched resources. foreach ($watchedResources as $key => $watchResource) { $schedule = $schedules[$watchResource] ?? null; @@ -220,20 +227,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, if ($watchResource == $resource) { $result = $entry; } else { - // Refresh cache entry for resources in watch list that are not handled in current request. - $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); - $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, - function (CacheItemInterface $item) use ($entry) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - - return $entry; - } - ); + $siblingEntries[$watchResource] = $entry; } } - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + $updatedWatchedResources = $watchedResources; return $result; } catch (\Throwable) { @@ -242,6 +240,30 @@ function (CacheItemInterface $item) use ($entry) { } } ); + + // Refresh sibling cache entries and watched resources list outside the callback. + // $siblingEntries is only populated on cache miss (when the callback ran). + foreach ($siblingEntries as $watchResource => $entry) { + $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); + $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, + function (CacheItemInterface $item) use ($entry) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + + return $entry; + } + ); + } + + if (!empty($updatedWatchedResources)) { + $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, function (CacheItemInterface $item) use ($updatedWatchedResources) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + + return $updatedWatchedResources; + }); + } + + return $result; } private function createEntry(string $resource, \DateTime $start, ?array $schedules = null): array @@ -290,19 +312,22 @@ private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); - $now = new \DateTime(); - // Make sure that booking requests are not spammed. - $lastRequestDateTime = $this->interactiveSlideCache->get( + // The callback only runs on cache miss (no recent booking exists). + // On cache hit, $isFreshRequest stays false and we rate-limit. + $isFreshRequest = false; + + $this->interactiveSlideCache->get( self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$slide->getId(), - function (CacheItemInterface $item) use ($now): \DateTime { + function (CacheItemInterface $item) use (&$isFreshRequest): bool { + $isFreshRequest = true; $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)); - return $now; + return true; } ); - if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { + if (!$isFreshRequest) { throw new TooManyRequestsException('Service unavailable'); } From 181b3f2fe15226e610e0180c5efb10deeb22badc Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:14:02 +0200 Subject: [PATCH 02/12] 6871: Cached busy intervals calls --- src/InteractiveSlide/InstantBook.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 7077383fa..ac33a5ced 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -41,7 +41,9 @@ class InstantBook implements InteractiveSlideInterface private const string CACHE_KEY_RESOURCES = self::CACHE_PREFIX.'-RESOURCES'; private const string BOOKING_TITLE = 'Straksbooking'; private const array DURATIONS = [15, 30, 60]; + private const string CACHE_KEY_BUSY_INTERVALS_PREFIX = self::CACHE_PREFIX.'-BUSY-INTERVALS'; private const string CACHE_LIFETIME_QUICK_BOOK_OPTIONS = 'PT5M'; + private const string CACHE_LIFETIME_BUSY_INTERVALS = 'PT15M'; private const string CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT = 'PT1M'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 @@ -210,7 +212,13 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $watchedResources[] = $resource; } - $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); + $schedules = $this->interactiveSlideCache->get(self::CACHE_KEY_BUSY_INTERVALS_PREFIX, + function (CacheItemInterface $item) use ($token, $watchedResources, $start, $startPlus1Hour) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS)); + + return $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); + } + ); $result = []; @@ -494,7 +502,7 @@ private function getValueFromInterval(string $key, InteractionSlideRequest $inte $value = $interval[$key] ?? null; if (null === $value) { - throw new BadRequestException("interval.'.$key.' not set."); + throw new BadRequestException("interval.{$key} not set."); } return $value; From 15d6128acbbb0f2916ad83f31af3b8ffdbc3473d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:18:34 +0200 Subject: [PATCH 03/12] 6871: Fixed naming issue --- src/InteractiveSlide/InstantBook.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index ac33a5ced..7b0d58d7f 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -52,7 +52,7 @@ class InstantBook implements InteractiveSlideInterface public function __construct( private readonly InteractiveSlideService $interactiveService, private readonly HttpClientInterface $client, - private readonly KeyVaultService $keyValueService, + private readonly KeyVaultService $keyVaultService, private readonly CacheInterface $interactiveSlideCache, ) {} @@ -107,10 +107,10 @@ public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequ */ private function authenticate(array $configuration): array { - $tenantId = $this->keyValueService->getValue($configuration['tenantId']); - $clientId = $this->keyValueService->getValue($configuration['clientId']); - $username = $this->keyValueService->getValue($configuration['username']); - $password = $this->keyValueService->getValue($configuration['password']); + $tenantId = $this->keyVaultService->getValue($configuration['tenantId']); + $clientId = $this->keyVaultService->getValue($configuration['clientId']); + $username = $this->keyVaultService->getValue($configuration['username']); + $password = $this->keyVaultService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { throw new NotAcceptableException('tenantId, clientId, username, password must all be set.'); @@ -366,7 +366,7 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool { throw new NotAcceptableException('InteractiveSlideConfig has no configuration'); } - $username = $this->keyValueService->getValue($configuration['username']); + $username = $this->keyVaultService->getValue($configuration['username']); $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); @@ -550,7 +550,7 @@ private function getAllowedResources(InteractiveSlideConfig $interactive): array throw new NotAcceptableException('resourceEndpoint not set'); } - $resourceEndpoint = $this->keyValueService->getValue($key); + $resourceEndpoint = $this->keyVaultService->getValue($key); if (null === $resourceEndpoint) { throw new NotAcceptableException('resourceEndpoint value not set'); From e217539a1088397e2599d535fc2e27af02e50428 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:29:00 +0200 Subject: [PATCH 04/12] 6871: Code cleanup --- src/InteractiveSlide/InstantBook.php | 96 +++++++++++++--------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 7b0d58d7f..39dc738f7 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -178,24 +178,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, // If any exceptions are thrown we return an empty options entry. try { - $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - - if (null === $interactiveSlideConfig) { - throw new NotAcceptableException('InteractiveSlideConfig not found'); - } - - $this->checkPermission($interactiveSlideConfig, $resource); - - $feed = $slide->getFeed(); - - if (null === $feed) { - throw new NotAcceptableException('Slide feed not set.'); - } - - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new NotAcceptableException('Resource not in feed resources'); - } - + $interactiveSlideConfig = $this->validateResourceAccess($tenant, $slide, $interactionRequest->implementationClass, $resource); $token = $this->getToken($tenant, $interactiveSlideConfig); $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); @@ -252,23 +235,11 @@ function (CacheItemInterface $item) use ($token, $watchedResources, $start, $sta // Refresh sibling cache entries and watched resources list outside the callback. // $siblingEntries is only populated on cache miss (when the callback ran). foreach ($siblingEntries as $watchResource => $entry) { - $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); - $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, - function (CacheItemInterface $item) use ($entry) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - - return $entry; - } - ); + $this->setCacheValue(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, $entry, self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS); } if (!empty($updatedWatchedResources)) { - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, function (CacheItemInterface $item) use ($updatedWatchedResources) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - - return $updatedWatchedResources; - }); + $this->setCacheValue(self::CACHE_KEY_RESOURCES, $updatedWatchedResources, self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS); } return $result; @@ -339,25 +310,7 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool { throw new TooManyRequestsException('Service unavailable'); } - $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - - if (null === $interactiveSlideConfig) { - throw new NotAcceptableException('InteractiveSlideConfig not found'); - } - - // Optional limiting of available resources. - $this->checkPermission($interactiveSlideConfig, $resource); - - $feed = $slide->getFeed(); - - if (null === $feed) { - throw new NotAcceptableException('Slide feed not set.'); - } - - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new NotAcceptableException('Resource not in feed resources'); - } - + $interactiveSlideConfig = $this->validateResourceAccess($tenant, $slide, $interactionRequest->implementationClass, $resource); $token = $this->getToken($tenant, $interactiveSlideConfig); $configuration = $interactiveSlideConfig->getConfiguration(); @@ -517,6 +470,47 @@ private function getHeaders(string $token): array ]; } + /** + * @throws NotAcceptableException + * @throws ForbiddenException + * @throws InvalidArgumentException + */ + private function validateResourceAccess(Tenant $tenant, Slide $slide, string $implementationClass, string $resource): InteractiveSlideConfig + { + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $implementationClass); + + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found'); + } + + $this->checkPermission($interactiveSlideConfig, $resource); + + $feed = $slide->getFeed(); + + if (null === $feed) { + throw new NotAcceptableException('Slide feed not set.'); + } + + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { + throw new NotAcceptableException('Resource not in feed resources'); + } + + return $interactiveSlideConfig; + } + + /** + * @throws InvalidArgumentException + */ + private function setCacheValue(string $key, mixed $value, string $lifetime): void + { + $this->interactiveSlideCache->delete($key); + $this->interactiveSlideCache->get($key, function (CacheItemInterface $item) use ($value, $lifetime): mixed { + $item->expiresAfter(new \DateInterval($lifetime)); + + return $value; + }); + } + /** * @throws NotAcceptableException * @throws ForbiddenException From 57555cc261ae704e65427c30cb898b9cbc698736 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:30:18 +0200 Subject: [PATCH 05/12] 6871: Removed redundant busy intervals call --- src/InteractiveSlide/InstantBook.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 39dc738f7..6f82f9893 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -324,12 +324,6 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool { $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); $startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); - // Make sure interval is free. - $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); - if (count($busyIntervals[$resource]) > 0) { - throw new ConflictException('Interval booked already'); - } - $requestBody = [ 'subject' => self::BOOKING_TITLE, 'start' => [ @@ -363,6 +357,10 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool { $status = $response->getStatusCode(); + if (409 === $status) { + throw new ConflictException('Interval booked already'); + } + return [ 'status' => $status, 'interval' => [ From 4c753a0583d27ba7682a8a4fc827109a9d60ec1e Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:36:42 +0200 Subject: [PATCH 06/12] 6871: Added pagination handling for get busy intervals --- src/InteractiveSlide/InstantBook.php | 55 +++++++++++++++------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 6f82f9893..5d1d3325b 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -390,40 +390,45 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st ], ]; - $response = $this->client->request('POST', self::ENDPOINT.'/me/calendar/getSchedule', [ - 'headers' => $this->getHeaders($token), - 'body' => json_encode($body), - ]); + $result = []; + $url = self::ENDPOINT.'/me/calendar/getSchedule'; - $data = $response->toArray(); + do { + $response = $this->client->request('POST', $url, [ + 'headers' => $this->getHeaders($token), + 'body' => json_encode($body), + ]); - $scheduleData = $data['value']; + $data = $response->toArray(); - $result = []; + foreach ($data['value'] as $schedule) { + $scheduleId = $schedule['scheduleId'] ?? null; + $scheduleItems = $schedule['scheduleItems'] ?? null; - foreach ($scheduleData as $schedule) { - $scheduleId = $schedule['scheduleId'] ?? null; - $scheduleItems = $schedule['scheduleItems'] ?? null; - - if (null === $scheduleId || null === $scheduleItems) { - continue; - } + if (null === $scheduleId || null === $scheduleItems) { + continue; + } - $result[$scheduleId] = []; + if (!isset($result[$scheduleId])) { + $result[$scheduleId] = []; + } - foreach ($scheduleItems as $scheduleItem) { - $eventStartArray = $scheduleItem['start']; - $eventEndArray = $scheduleItem['end']; + foreach ($scheduleItems as $scheduleItem) { + $eventStartArray = $scheduleItem['start']; + $eventEndArray = $scheduleItem['end']; - $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $result[$scheduleId][] = [ - 'startTime' => $start, - 'endTime' => $end, - ]; + $result[$scheduleId][] = [ + 'startTime' => $start, + 'endTime' => $end, + ]; + } } - } + + $url = $data['@odata.nextLink'] ?? null; + } while (null !== $url); return $result; } From c678969315d0669816ffc5690bbcc65d7fb4dc09 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:48:44 +0200 Subject: [PATCH 07/12] 7208: Fixed controller to use DTO --- assets/shared/redux/generated-api.ts | 3 +- public/api-spec-v2.json | 22 ++++++++++++-- public/api-spec-v2.yaml | 16 +++++++++-- src/Controller/Api/InteractiveController.php | 6 ++-- src/Dto/InteractiveSlideActionInput.php | 3 +- src/Service/InteractiveSlideService.php | 15 ++++------ tests/Service/InteractiveServiceTest.php | 30 +++++++++++--------- 7 files changed, 63 insertions(+), 32 deletions(-) diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts index 63959ac3e..6134d6d74 100644 --- a/assets/shared/redux/generated-api.ts +++ b/assets/shared/redux/generated-api.ts @@ -2475,8 +2475,9 @@ export type SlideSlideInputJsonld = { content?: string[]; }; export type SlideInteractiveSlideActionInputJsonld = { + implementationClass?: string | null; action?: string | null; - data?: string[]; + data?: string[] | null; }; export type ThemeThemeJsonld = { title?: string; diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index df8506599..3c3abc0a6 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -14274,6 +14274,12 @@ "description": "", "deprecated": false, "properties": { + "implementationClass": { + "type": [ + "string", + "null" + ] + }, "action": { "type": [ "string", @@ -14281,7 +14287,10 @@ ] }, "data": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "type": "string" } @@ -14293,6 +14302,12 @@ "description": "", "deprecated": false, "properties": { + "implementationClass": { + "type": [ + "string", + "null" + ] + }, "action": { "type": [ "string", @@ -14300,7 +14315,10 @@ ] }, "data": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "type": "string" } diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 9c1b21eb1..b39e7a2bd 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -9948,12 +9948,18 @@ components: description: '' deprecated: false properties: + implementationClass: + type: + - string + - 'null' action: type: - string - 'null' data: - type: array + type: + - array + - 'null' items: type: string Slide.InteractiveSlideActionInput.jsonld: @@ -9961,12 +9967,18 @@ components: description: '' deprecated: false properties: + implementationClass: + type: + - string + - 'null' action: type: - string - 'null' data: - type: array + type: + - array + - 'null' items: type: string Slide.Slide: diff --git a/src/Controller/Api/InteractiveController.php b/src/Controller/Api/InteractiveController.php index 6cdde602a..eb7697009 100644 --- a/src/Controller/Api/InteractiveController.php +++ b/src/Controller/Api/InteractiveController.php @@ -4,6 +4,7 @@ namespace App\Controller\Api; +use App\Dto\InteractiveSlideActionInput; use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; use App\Exceptions\BadRequestException; @@ -41,9 +42,10 @@ public function __invoke(Request $request, Slide $slide): JsonResponse $tenant = $user->getActiveTenant(); - $requestBody = $request->toArray(); + /** @var InteractiveSlideActionInput $input */ + $input = $request->attributes->get('data'); - $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); + $interactionRequest = $this->interactiveSlideService->parseInteractiveSlideActionInput($input); $actionResult = $this->interactiveSlideService->performAction($tenant, $slide, $interactionRequest); diff --git a/src/Dto/InteractiveSlideActionInput.php b/src/Dto/InteractiveSlideActionInput.php index 25ef131e0..c98fe3fff 100644 --- a/src/Dto/InteractiveSlideActionInput.php +++ b/src/Dto/InteractiveSlideActionInput.php @@ -6,6 +6,7 @@ class InteractiveSlideActionInput { + public ?string $implementationClass = null; public ?string $action = null; - public array $data = []; + public ?array $data = null; } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index 21460e002..d9d4df4c7 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -4,6 +4,7 @@ namespace App\Service; +use App\Dto\InteractiveSlideActionInput; use App\Entity\Tenant; use App\Entity\Tenant\InteractiveSlideConfig; use App\Entity\Tenant\Slide; @@ -29,23 +30,17 @@ public function __construct( ) {} /** - * Create InteractionRequest from the request body. - * - * @param array $requestBody the request body from the http request + * Create InteractionRequest from the DTO. * * @throws BadRequestException */ - public function parseRequestBody(array $requestBody): InteractionSlideRequest + public function parseInteractiveSlideActionInput(InteractiveSlideActionInput $input): InteractionSlideRequest { - $implementationClass = $requestBody['implementationClass'] ?? null; - $action = $requestBody['action'] ?? null; - $data = $requestBody['data'] ?? null; - - if (null === $implementationClass || null === $action || null === $data) { + if (null === $input->implementationClass || null === $input->action || null === $input->data) { throw new BadRequestException('implementationClass, action and/or data not set.'); } - return new InteractionSlideRequest($implementationClass, $action, $data); + return new InteractionSlideRequest($input->implementationClass, $input->action, $input->data); } /** diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 9ce070292..7d8d6a81e 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Service; +use App\Dto\InteractiveSlideActionInput; use App\Entity\Tenant\Slide; use App\Exceptions\BadRequestException; use App\Exceptions\NotAcceptableException; @@ -38,21 +39,21 @@ public function setUp(): void $this->entityManager = $this->container->get('doctrine')->getManager(); } - public function testParseRequestBody(): void + public function testParseInteractiveSlideActionInput(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); $this->expectException(BadRequestException::class); - $interactiveService->parseRequestBody([ - 'test' => 'test', - ]); + $emptyInput = new InteractiveSlideActionInput(); + $interactiveService->parseInteractiveSlideActionInput($emptyInput); - $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => InstantBook::class, - 'action' => 'test', - 'data' => [], - ]); + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'test'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); $correctReturnType = $interactionRequest instanceof InteractionSlideRequest; @@ -68,11 +69,12 @@ public function testPerformAction(): void $slide = new Slide(); - $interactionRequest = $interactiveService->parseRequestBody([ - 'implementationClass' => InstantBook::class, - 'action' => 'ACTION_NOT_EXIST', - 'data' => [], - ]); + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'ACTION_NOT_EXIST'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); $this->expectException(NotAcceptableException::class); $this->expectExceptionMessage('Interactive slide config not found'); From e84b96e46ab1409492dea6c17a176d79bd3aa388 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:34:51 +0200 Subject: [PATCH 08/12] 6871: Added fixes to instant book --- src/InteractiveSlide/InstantBook.php | 16 ++++++++++++---- .../InteractiveSlideInterface.php | 9 +-------- src/Service/InteractiveSlideService.php | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 5d1d3325b..4a7fe071d 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -289,7 +289,11 @@ private function createEntry(string $resource, \DateTime $start, ?array $schedul private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); - $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); + $durationMinutes = (int) $this->getValueFromInterval('durationMinutes', $interactionRequest); + + if (!in_array($durationMinutes, self::DURATIONS, true)) { + throw new BadRequestException('Invalid duration. Allowed values: '.implode(', ', self::DURATIONS)); + } // Make sure that booking requests are not spammed. // The callback only runs on cache miss (no recent booking exists). @@ -297,7 +301,7 @@ private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $isFreshRequest = false; $this->interactiveSlideCache->get( - self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$slide->getId(), + self::CACHE_PREFIX_SPAM_PROTECT_PREFIX.$resource, function (CacheItemInterface $item) use (&$isFreshRequest): bool { $isFreshRequest = true; $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)); @@ -361,6 +365,10 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool { throw new ConflictException('Interval booked already'); } + if ($status >= 400) { + throw new NotAcceptableException('Booking failed with status '.$status); + } + return [ 'status' => $status, 'interval' => [ @@ -418,7 +426,7 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st $eventEndArray = $scheduleItem['end']; $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventEndArray['timeZone'])); $result[$scheduleId][] = [ 'startTime' => $start, @@ -436,7 +444,7 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { - if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { + if (!($scheduleEntry['startTime'] >= $to || $scheduleEntry['endTime'] <= $from)) { return false; } } diff --git a/src/InteractiveSlide/InteractiveSlideInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php index df957842e..05e950d16 100644 --- a/src/InteractiveSlide/InteractiveSlideInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -6,10 +6,6 @@ use App\Entity\Tenant; use App\Entity\Tenant\Slide; -use App\Exceptions\BadRequestException; -use App\Exceptions\ConflictException; -use App\Exceptions\NotAcceptableException; -use App\Exceptions\TooManyRequestsException; interface InteractiveSlideInterface { @@ -18,10 +14,7 @@ public function getConfigOptions(): array; /** * Perform the given InteractionRequest with the given Slide. * - * @throws ConflictException - * @throws BadRequestException - * @throws NotAcceptableException - * @throws TooManyRequestsException + * @throws \Throwable */ public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index d9d4df4c7..b055d5861 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -94,7 +94,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid throw new BadRequestException('Interactive implementation class not found'); } - return $interactiveImplementations[0]; + return reset($interactiveImplementations); } /** From 06d450db68e76643e136bb25d28924baf480fc3e Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 6 May 2026 10:33:45 +0200 Subject: [PATCH 09/12] 7208: Updated changelog --- CHANGELOG.md | 8 ++++++++ phpstan-baseline.neon | 6 ------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2153f71ec..e95743ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types. +- Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect + throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted + typos/string-interpolation issues. +- Added `getBusyIntervals` cache (PT15M) with `@odata.nextLink` pagination and a shared `validateResourceAccess()` + helper, eliminating per-poll Graph calls at the cost of up to 15-minute-stale availability in `quickBookOptions` + (booking still 409s correctly). + ## [3.0.0-rc2] - 2026-05-05 - Fixed `Create Github Release` workflow that failed cleanup because `node_modules/` was owned by root. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 96fb5c500..97136d783 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2892,12 +2892,6 @@ parameters: count: 1 path: src/Service/InteractiveSlideService.php - - - message: '#^Method App\\Service\\InteractiveSlideService\:\:parseRequestBody\(\) has parameter \$requestBody with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Service/InteractiveSlideService.php - - message: '#^Method App\\Service\\InteractiveSlideService\:\:performAction\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue From 33ad8de42d86061e37ce664197b4bfaa393db452 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 May 2026 12:37:26 +0200 Subject: [PATCH 10/12] 6871: Removed @odata.nextLink iteration since this is not in the specification --- src/InteractiveSlide/InstantBook.php | 56 +++++++++++++--------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 4a7fe071d..0b3c5242e 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -398,45 +398,39 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st ], ]; - $result = []; - $url = self::ENDPOINT.'/me/calendar/getSchedule'; - - do { - $response = $this->client->request('POST', $url, [ - 'headers' => $this->getHeaders($token), - 'body' => json_encode($body), - ]); + $response = $this->client->request('POST', self::ENDPOINT.'/me/calendar/getSchedule', [ + 'headers' => $this->getHeaders($token), + 'body' => json_encode($body), + ]); - $data = $response->toArray(); + $data = $response->toArray(); + $result = []; - foreach ($data['value'] as $schedule) { - $scheduleId = $schedule['scheduleId'] ?? null; - $scheduleItems = $schedule['scheduleItems'] ?? null; + foreach ($data['value'] as $schedule) { + $scheduleId = $schedule['scheduleId'] ?? null; + $scheduleItems = $schedule['scheduleItems'] ?? null; - if (null === $scheduleId || null === $scheduleItems) { - continue; - } + if (null === $scheduleId || null === $scheduleItems) { + continue; + } - if (!isset($result[$scheduleId])) { - $result[$scheduleId] = []; - } + if (!isset($result[$scheduleId])) { + $result[$scheduleId] = []; + } - foreach ($scheduleItems as $scheduleItem) { - $eventStartArray = $scheduleItem['start']; - $eventEndArray = $scheduleItem['end']; + foreach ($scheduleItems as $scheduleItem) { + $eventStartArray = $scheduleItem['start']; + $eventEndArray = $scheduleItem['end']; - $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); - $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventEndArray['timeZone'])); + $start = new \DateTime($eventStartArray['dateTime'], new \DateTimeZone($eventStartArray['timeZone'])); + $end = new \DateTime($eventEndArray['dateTime'], new \DateTimeZone($eventEndArray['timeZone'])); - $result[$scheduleId][] = [ - 'startTime' => $start, - 'endTime' => $end, - ]; - } + $result[$scheduleId][] = [ + 'startTime' => $start, + 'endTime' => $end, + ]; } - - $url = $data['@odata.nextLink'] ?? null; - } while (null !== $url); + } return $result; } From d29221e08c95944cc005a837c254ce9e865f62f9 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 May 2026 12:39:56 +0200 Subject: [PATCH 11/12] 6871: Updated changelog --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56833037c..0f94509db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,8 @@ All notable changes to this project will be documented in this file. - Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted typos/string-interpolation issues. -- Added `getBusyIntervals` cache (PT15M) with `@odata.nextLink` pagination and a shared `validateResourceAccess()` - helper, eliminating per-poll Graph calls at the cost of up to 15-minute-stale availability in `quickBookOptions` - (booking still 409s correctly). +- Added `getBusyIntervals` cache (PT15M) with a shared `validateResourceAccess()` helper, eliminating per-poll Graph + calls at the cost of up to 15-minute-stale availability in `quickBookOptions`. ## [3.0.0-rc3] - 2026-05-11 From e11712cc5989b136a31364d7fcb82b69e87189a5 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 May 2026 12:49:44 +0200 Subject: [PATCH 12/12] 6871: Fixed test --- tests/Service/InteractiveServiceTest.php | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 7d8d6a81e..b89599459 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -60,7 +60,7 @@ public function testParseInteractiveSlideActionInput(): void $this->assertTrue($correctReturnType); } - public function testPerformAction(): void + public function testPerformActionWithoutConfig(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); @@ -79,14 +79,30 @@ public function testPerformAction(): void $this->expectException(NotAcceptableException::class); $this->expectExceptionMessage('Interactive slide config not found'); - $tenant = $user->getActiveTenant(); + $interactiveService->performAction($user->getActiveTenant(), $slide, $interactionRequest); + } - $interactiveService->performAction($tenant, $slide, $interactionRequest); + public function testPerformActionWithUnsupportedAction(): void + { + $interactiveService = $this->container->get(InteractiveSlideService::class); + $user = $this->container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); + + $this->assertNotNull($user); + + $slide = new Slide(); + $input = new InteractiveSlideActionInput(); + $input->implementationClass = InstantBook::class; + $input->action = 'ACTION_NOT_EXIST'; + $input->data = []; + + $interactionRequest = $interactiveService->parseInteractiveSlideActionInput($input); + + $tenant = $user->getActiveTenant(); $interactiveService->saveConfiguration($tenant, InstantBook::class, []); $this->expectException(NotAcceptableException::class); - $this->expectExceptionMessage('Action not allowed'); + $this->expectExceptionMessage('Action not supported'); $interactiveService->performAction($tenant, $slide, $interactionRequest); }