diff --git a/.env b/.env index caf53dd02..ea4dfd90c 100644 --- a/.env +++ b/.env @@ -180,6 +180,11 @@ CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS=300 EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300 ###< Event Database Api V2 Feed Type ### +###> Instant Book ### +# Source of busy intervals data for instant booking interactive slides (graph or feed). +INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph +###< Instant Book ### + ###> Admin configuration ### # API key for Rejseplanen (Danish journey planner) integration. ADMIN_REJSEPLANEN_APIKEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 2065790f1..6c2b2bf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Added `INSTANT_BOOK_BUSY_INTERVALS_SOURCE` to select between Graph and the slide's calendar feed as the source of busy + intervals for InstantBook. +- Changed polling interval for instant booking template. - Fixed admin toast leaking a raw `SyntaxError: Unexpected token '<'` when an upload was rejected upstream (e.g. nginx 413); the toast now shows `HTTP ` instead. - Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var. diff --git a/README.md b/README.md index 4e9de902a..7281f5f12 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,21 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300 - EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expiration be for cache entries in EventDatabaseApiV2FeedType? +#### InstantBook + +```dotenv +###> InstantBook ### +INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph +###< InstantBook ### +``` + +- INSTANT_BOOK_BUSY_INTERVALS_SOURCE: Where the InstantBook interactive slide fetches resource + busy-intervals from. + - `graph`: Fetch busy intervals from Microsoft Graph (results cached for 15 minutes). + - `feed`: Fetch busy intervals from the slide's configured calendar-output feed. + + **Default**: `graph`. + ## Rest API & Relationships To avoid embedding all relations in REST representations but still allow the clients to minimize the amount of API calls diff --git a/assets/shared/templates/calendar/calendar-single-booking.jsx b/assets/shared/templates/calendar/calendar-single-booking.jsx index b9e5d6156..1db066f54 100644 --- a/assets/shared/templates/calendar/calendar-single-booking.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking.jsx @@ -64,7 +64,6 @@ function CalendarSingleBooking({ // Get values from client localstorage. const token = localStorage.getItem("apiToken"); const tenantKey = localStorage.getItem("tenantKey"); - const apiUrl = localStorage.getItem("apiUrl"); const [bookableIntervals, setBookableIntervals] = useState([]); const [fetchingIntervals, setFetchingIntervals] = useState(false); @@ -72,14 +71,14 @@ function CalendarSingleBooking({ const [bookingResult, setBookingResult] = useState(null); const [processingBooking, setProcessingBooking] = useState(false); const [secondsUntilNextEvent, setSecondsUntilNextEvent] = useState(null); - const [bookingError, setBookingError] = useState(false); + const [bookingError, setBookingError] = useState(null); const fetchBookingIntervals = () => { if (!instantBookingEnabled) { return; } - if (!apiUrl || !slide || !token || !tenantKey) { + if (!slide || !token || !tenantKey) { setFetchingIntervals(false); return; } @@ -93,7 +92,7 @@ function CalendarSingleBooking({ if (resources.length === 1) { setFetchingIntervals(true); - fetch(`${apiUrl}${slide["@id"]}/action`, { + fetch(`${slide["@id"]}/action`, { method: "POST", headers: { authorization: `Bearer ${token}`, @@ -173,7 +172,7 @@ function CalendarSingleBooking({ }; const clickInterval = (interval) => { - if (!apiUrl || !slide || !token || !tenantKey) { + if (!slide || !token || !tenantKey) { return; } @@ -183,7 +182,7 @@ function CalendarSingleBooking({ setProcessingBooking(true); - fetch(`${apiUrl}${slide["@id"]}/action`, { + fetch(`${slide["@id"]}/action`, { method: "POST", headers: { authorization: `Bearer ${token}`, @@ -198,14 +197,21 @@ function CalendarSingleBooking({ }, }), }) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + const error = new Error(`Booking failed with status ${r.status}`); + error.status = r.status; + throw error; + } + return r.json(); + }) .then((data) => { setBookingResult(data); setInstantBookingFromLocalStorage(slide["@id"], data); }) - .catch(() => { - setBookingError(true); - setTimeout(() => setBookingError(false), 10000); + .catch((err) => { + setBookingError(err?.status === 409 ? "conflict" : "generic"); + setTimeout(() => setBookingError(null), 10000); }) .finally(() => { setProcessingBooking(false); @@ -217,7 +223,8 @@ function CalendarSingleBooking({ dayjs.extend(localizedFormat); intervalChecking(); - const interval = setInterval(intervalChecking, 5000); + // Check every minute if the current time has changed. + const interval = setInterval(intervalChecking, 60000); return () => { if (interval !== null) { @@ -385,7 +392,15 @@ function CalendarSingleBooking({ />

)} - {bookingError && ( + {bookingError === "conflict" && ( +

+ +

+ )} + {bookingError && bookingError !== "conflict" && (

{ await expect(page.getByText("Ledigt")).toHaveCount(1); await expect(page.getByText("Ledigt")).toBeVisible(); - await page.waitForTimeout(5500); + await page.clock.runFor(61000); await expect(page.getByText("Optaget")).toHaveCount(1); await expect(page.getByText("Optaget")).toBeVisible(); diff --git a/config/services.yaml b/config/services.yaml index 2092f896d..cc13a9e48 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -104,6 +104,10 @@ services: $timezone: '%env(string:CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE)%' $cacheExpireSeconds: '%env(int:CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS)%' + App\InteractiveSlide\InstantBook: + arguments: + $busyIntervalsSource: '%env(string:INSTANT_BOOK_BUSY_INTERVALS_SOURCE)%' + App\Service\KeyVaultService: arguments: $keyVaultSource: '%env(string:KEY_VAULT_SOURCE)%' diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 0b3c5242e..60f4532ea 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -5,6 +5,7 @@ namespace App\InteractiveSlide; use App\Entity\Tenant; +use App\Entity\Tenant\Feed; use App\Entity\Tenant\InteractiveSlideConfig; use App\Entity\Tenant\Slide; use App\Exceptions\BadRequestException; @@ -12,10 +13,13 @@ use App\Exceptions\ForbiddenException; use App\Exceptions\NotAcceptableException; use App\Exceptions\TooManyRequestsException; +use App\Feed\FeedOutputModels; +use App\Service\FeedService; use App\Service\InteractiveSlideService; use App\Service\KeyVaultService; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -45,6 +49,8 @@ class InstantBook implements InteractiveSlideInterface 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'; + public const string SOURCE_GRAPH = 'graph'; + public const string SOURCE_FEED = 'feed'; // see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0 // example 2019-03-15T09:00:00 public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s'; @@ -54,7 +60,13 @@ public function __construct( private readonly HttpClientInterface $client, private readonly KeyVaultService $keyVaultService, private readonly CacheInterface $interactiveSlideCache, - ) {} + private readonly FeedService $feedService, + private readonly string $busyIntervalsSource, + ) { + if (!in_array($busyIntervalsSource, [self::SOURCE_GRAPH, self::SOURCE_FEED], true)) { + throw new \InvalidArgumentException(sprintf('Invalid INSTANT_BOOK_BUSY_INTERVALS_SOURCE "%s"; expected "%s" or "%s".', $busyIntervalsSource, self::SOURCE_GRAPH, self::SOURCE_FEED)); + } + } public function getConfigOptions(): array { @@ -195,13 +207,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $watchedResources[] = $resource; } - $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); - } - ); + $schedules = $this->fetchBusyIntervals($token, $watchedResources, $start, $startPlus1Hour, $slide); $result = []; @@ -328,6 +334,8 @@ 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')); + $this->assertSlotFree($token, $resource, $start, $startPlusDuration); + $requestBody = [ 'subject' => self::BOOKING_TITLE, 'start' => [ @@ -435,6 +443,137 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st return $result; } + /** + * Dispatch busy-intervals lookup according to INSTANT_BOOK_BUSY_INTERVALS_SOURCE. + * + * Both branches return the same shape: + * [resourceId => [['startTime' => DateTime, 'endTime' => DateTime], ...]]. + * + * @param string[] $resources + * + * @return array> + * + * @throws \Throwable + */ + private function fetchBusyIntervals(string $token, array $resources, \DateTime $from, \DateTime $to, Slide $slide): array + { + return match ($this->busyIntervalsSource) { + self::SOURCE_FEED => $this->getBusyIntervalsFromFeed($slide->getFeed(), $resources, $from, $to), + self::SOURCE_GRAPH => $this->getBusyIntervalsCached($token, $resources, $from, $to), + }; + } + + /** + * Cached wrapper around getBusyIntervals(). + * + * The cache key includes the resource set and the from/to window (minute precision) so that + * concurrent requests with different watched resources or windows do not share an entry; the + * previous fixed-key behaviour caused different slides to read each other's data. + * + * @param string[] $resources + * + * @return array> + * + * @throws InvalidArgumentException + */ + private function getBusyIntervalsCached(string $token, array $resources, \DateTime $from, \DateTime $to): array + { + $sortedResources = $resources; + sort($sortedResources); + + $cacheKey = sprintf( + '%s-%s-%s-%s', + self::CACHE_KEY_BUSY_INTERVALS_PREFIX, + hash('xxh128', implode('|', $sortedResources)), + $from->format('YmdHi'), + $to->format('YmdHi'), + ); + + return $this->interactiveSlideCache->get( + $cacheKey, + function (CacheItemInterface $item) use ($token, $resources, $from, $to) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS)); + + return $this->getBusyIntervals($token, $resources, $from, $to); + }, + ); + } + + /** + * Verify that the requested slot is free in live Graph state before booking. + * + * This is required, not an optimization: the resources used here are not configured to block + * conflicting bookings at the Graph API level — POSTing a conflicting booking will simply + * succeed rather than return 409. The 409 catch on the booking POST is a backstop for + * resources whose AutomateProcessing setting does reject conflicts. + * + * @throws ConflictException if the slot is already booked + */ + private function assertSlotFree(string $token, string $resource, \DateTime $start, \DateTime $end): void + { + $schedules = $this->getBusyIntervals($token, [$resource], $start, $end); + + if (!$this->intervalFree($schedules[$resource] ?? [], $start, $end)) { + throw new ConflictException('Interval booked already'); + } + } + + /** + * Derive busy intervals from the slide's calendar-output-model feed. + * + * @param string[] $resources resource ids to include in the result + * + * @return array> + * + * @throws UnprocessableEntityHttpException when the slide is not configured for feed-sourced busy intervals + */ + private function getBusyIntervalsFromFeed(?Feed $feed, array $resources, \DateTime $from, \DateTime $to): array + { + if (null === $feed) { + throw new UnprocessableEntityHttpException('InstantBook (feed source): slide feed not set.'); + } + + $feedSource = $feed->getFeedSource(); + + if (null === $feedSource) { + throw new UnprocessableEntityHttpException('InstantBook (feed source): feed source not set on slide feed.'); + } + + $feedType = $this->feedService->getFeedType($feedSource->getFeedType()); + + if (FeedOutputModels::CALENDAR_OUTPUT !== $feedType->getSupportedFeedOutputType()) { + throw new UnprocessableEntityHttpException('InstantBook (feed source) requires a calendar-output feed.'); + } + + $events = $this->feedService->getData($feed) ?? []; + + $result = array_fill_keys($resources, []); + $fromTs = $from->getTimestamp(); + $toTs = $to->getTimestamp(); + + foreach ($events as $event) { + $resourceId = $event['resourceId'] ?? null; + + if (!is_string($resourceId) || !array_key_exists($resourceId, $result)) { + continue; + } + + $startTs = (int) ($event['startTime'] ?? 0); + $endTs = (int) ($event['endTime'] ?? 0); + + if ($endTs <= $fromTs || $startTs >= $toTs) { + continue; + } + + $result[$resourceId][] = [ + 'startTime' => new \DateTime('@'.$startTs), + 'endTime' => new \DateTime('@'.$endTs), + ]; + } + + return $result; + } + public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { diff --git a/tests/Interactive/InstantBookTest.php b/tests/Interactive/InstantBookTest.php index 17b405b17..95da2d24e 100644 --- a/tests/Interactive/InstantBookTest.php +++ b/tests/Interactive/InstantBookTest.php @@ -4,11 +4,24 @@ namespace App\Tests\Interactive; +use App\Entity\Tenant\Feed; +use App\Entity\Tenant\FeedSource; +use App\Exceptions\ConflictException; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; use App\InteractiveSlide\InstantBook; +use App\Service\FeedService; +use App\Service\InteractiveSlideService; +use App\Service\KeyVaultService; use Doctrine\ORM\EntityManager; use Hautelook\AliceBundle\PhpUnit\BaseDatabaseTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; class InstantBookTest extends KernelTestCase { @@ -49,4 +62,197 @@ public function testIntervalFree(): void $intervalFree = $service->intervalFree($schedules, new \DateTime('+15 minutes'), new \DateTime('+45 minutes')); $this->assertFalse($intervalFree); } + + public function testGetBusyIntervalsFromFeedFiltersByResourceAndWindow(): void + { + $from = new \DateTime('2026-01-01T10:00:00', new \DateTimeZone('UTC')); + $to = new \DateTime('2026-01-01T11:00:00', new \DateTimeZone('UTC')); + + $events = [ + // In-window event for resource A. + ['resourceId' => 'a@example.com', 'startTime' => $from->getTimestamp() + 600, 'endTime' => $from->getTimestamp() + 1800], + // In-window event for resource B. + ['resourceId' => 'b@example.com', 'startTime' => $from->getTimestamp() + 1200, 'endTime' => $from->getTimestamp() + 2400], + // Out-of-window event (entirely before from). + ['resourceId' => 'a@example.com', 'startTime' => $from->getTimestamp() - 7200, 'endTime' => $from->getTimestamp() - 3600], + // Event for an unwatched resource. + ['resourceId' => 'c@example.com', 'startTime' => $from->getTimestamp() + 600, 'endTime' => $from->getTimestamp() + 1800], + ]; + + $instantBook = $this->buildInstantBookWithFeedData($events, FeedOutputModels::CALENDAR_OUTPUT); + + $feed = $this->buildFeedWithSource(\App\Feed\CalendarApiFeedType::class); + + $result = $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com', 'b@example.com'], $from, $to]); + + $this->assertSame(['a@example.com', 'b@example.com'], array_keys($result)); + $this->assertCount(1, $result['a@example.com']); + $this->assertCount(1, $result['b@example.com']); + $this->assertInstanceOf(\DateTime::class, $result['a@example.com'][0]['startTime']); + $this->assertInstanceOf(\DateTime::class, $result['a@example.com'][0]['endTime']); + $this->assertSame($from->getTimestamp() + 600, $result['a@example.com'][0]['startTime']->getTimestamp()); + } + + public function testGetBusyIntervalsFromFeedReturnsEmptyForResourcesWithoutEvents(): void + { + $from = new \DateTime('2026-01-01T10:00:00', new \DateTimeZone('UTC')); + $to = new \DateTime('2026-01-01T11:00:00', new \DateTimeZone('UTC')); + + $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::CALENDAR_OUTPUT); + $feed = $this->buildFeedWithSource(\App\Feed\CalendarApiFeedType::class); + + $result = $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com'], $from, $to]); + + $this->assertSame(['a@example.com' => []], $result); + } + + public function testGetBusyIntervalsFromFeedRejectsNonCalendarFeed(): void + { + $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::RSS_OUTPUT); + $feed = $this->buildFeedWithSource(\App\Feed\RssFeedType::class); + + $this->expectException(UnprocessableEntityHttpException::class); + + $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [$feed, ['a@example.com'], new \DateTime(), new \DateTime('+1 hour')]); + } + + public function testGetBusyIntervalsFromFeedRejectsNullFeed(): void + { + $instantBook = $this->buildInstantBookWithFeedData([], FeedOutputModels::CALENDAR_OUTPUT); + + $this->expectException(UnprocessableEntityHttpException::class); + + $this->invokePrivate($instantBook, 'getBusyIntervalsFromFeed', [null, ['a@example.com'], new \DateTime(), new \DateTime('+1 hour')]); + } + + public function testAssertSlotFreeThrowsConflictWhenResourceIsBusy(): void + { + $start = new \DateTime('2030-01-01T10:00:00', new \DateTimeZone('UTC')); + $end = (clone $start)->modify('+30 minutes'); + $resource = 'room-a@example.com'; + + $instantBook = $this->buildInstantBookWithGraphSchedule([ + 'value' => [[ + 'scheduleId' => $resource, + 'scheduleItems' => [[ + 'start' => ['dateTime' => '2030-01-01T10:10:00', 'timeZone' => 'UTC'], + 'end' => ['dateTime' => '2030-01-01T10:20:00', 'timeZone' => 'UTC'], + ]], + ]], + ]); + + $this->expectException(ConflictException::class); + + $this->invokePrivate($instantBook, 'assertSlotFree', ['fake-token', $resource, $start, $end]); + } + + public function testAssertSlotFreePassesWhenScheduleIsEmpty(): void + { + $start = new \DateTime('2030-01-01T10:00:00', new \DateTimeZone('UTC')); + $end = (clone $start)->modify('+30 minutes'); + $resource = 'room-a@example.com'; + + $instantBook = $this->buildInstantBookWithGraphSchedule([ + 'value' => [[ + 'scheduleId' => $resource, + 'scheduleItems' => [], + ]], + ]); + + $this->invokePrivate($instantBook, 'assertSlotFree', ['fake-token', $resource, $start, $end]); + $this->expectNotToPerformAssertions(); + } + + private function buildInstantBookWithFeedData(array $events, string $outputType): InstantBook + { + $feedType = new class($outputType) implements FeedTypeInterface { + public function __construct( + private readonly string $outputType, + ) {} + + public function getData(Feed $feed): array + { + return []; + } + + public function getAdminFormOptions(FeedSource $feedSource): array + { + return []; + } + + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + return null; + } + + public function getRequiredSecrets(): array + { + return []; + } + + public function getRequiredConfiguration(): array + { + return []; + } + + public function getSupportedFeedOutputType(): string + { + return $this->outputType; + } + + public function getSchema(): array + { + return []; + } + }; + + $feedService = $this->createMock(FeedService::class); + $feedService->method('getFeedType')->willReturn($feedType); + $feedService->method('getData')->willReturn($events); + + return new InstantBook( + $this->container->get(InteractiveSlideService::class), + $this->createMock(HttpClientInterface::class), + $this->container->get(KeyVaultService::class), + $this->createMock(CacheInterface::class), + $feedService, + InstantBook::SOURCE_FEED, + ); + } + + private function buildInstantBookWithGraphSchedule(array $scheduleResponse): InstantBook + { + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn($scheduleResponse); + + $client = $this->createMock(HttpClientInterface::class); + $client->method('request')->willReturn($response); + + return new InstantBook( + $this->container->get(InteractiveSlideService::class), + $client, + $this->container->get(KeyVaultService::class), + $this->createMock(CacheInterface::class), + $this->createMock(FeedService::class), + InstantBook::SOURCE_GRAPH, + ); + } + + private function buildFeedWithSource(string $feedTypeClassName): Feed + { + $feedSource = new FeedSource(); + $feedSource->setFeedType($feedTypeClassName); + + $feed = new Feed(); + $feed->setFeedSource($feedSource); + + return $feed; + } + + private function invokePrivate(object $target, string $method, array $args): mixed + { + $ref = new \ReflectionMethod($target, $method); + + return $ref->invokeArgs($target, $args); + } }