Skip to content

Commit a0b3c89

Browse files
authored
Merge pull request #434 from os2display/feature/instantbook-busy-from-feed
feat: Instant Book: Added strategy where busy intervals are retrieved from calendar feed
2 parents ea0bb15 + e443e98 commit a0b3c89

8 files changed

Lines changed: 408 additions & 21 deletions

File tree

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS=300
180180
EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
181181
###< Event Database Api V2 Feed Type ###
182182

183+
###> Instant Book ###
184+
# Source of busy intervals data for instant booking interactive slides (graph or feed).
185+
INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
186+
###< Instant Book ###
187+
183188
###> Admin configuration ###
184189
# API key for Rejseplanen (Danish journey planner) integration.
185190
ADMIN_REJSEPLANEN_APIKEY=

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- Added `INSTANT_BOOK_BUSY_INTERVALS_SOURCE` to select between Graph and the slide's calendar feed as the source of busy
8+
intervals for InstantBook.
9+
- Changed polling interval for instant booking template.
710
- Fixed admin toast leaking a raw `SyntaxError: Unexpected token '<'` when an upload was rejected
811
upstream (e.g. nginx 413); the toast now shows `HTTP <status>` instead.
912
- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,21 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
629629
- EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expiration be for cache entries in
630630
EventDatabaseApiV2FeedType?
631631

632+
#### InstantBook
633+
634+
```dotenv
635+
###> InstantBook ###
636+
INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
637+
###< InstantBook ###
638+
```
639+
640+
- INSTANT_BOOK_BUSY_INTERVALS_SOURCE: Where the InstantBook interactive slide fetches resource
641+
busy-intervals from.
642+
- `graph`: Fetch busy intervals from Microsoft Graph (results cached for 15 minutes).
643+
- `feed`: Fetch busy intervals from the slide's configured calendar-output feed.
644+
645+
**Default**: `graph`.
646+
632647
## Rest API & Relationships
633648

634649
To avoid embedding all relations in REST representations but still allow the clients to minimize the amount of API calls

assets/shared/templates/calendar/calendar-single-booking.jsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,22 +64,21 @@ function CalendarSingleBooking({
6464
// Get values from client localstorage.
6565
const token = localStorage.getItem("apiToken");
6666
const tenantKey = localStorage.getItem("tenantKey");
67-
const apiUrl = localStorage.getItem("apiUrl");
6867

6968
const [bookableIntervals, setBookableIntervals] = useState([]);
7069
const [fetchingIntervals, setFetchingIntervals] = useState(false);
7170
const [currentTime, setCurrentTime] = useState(dayjs());
7271
const [bookingResult, setBookingResult] = useState(null);
7372
const [processingBooking, setProcessingBooking] = useState(false);
7473
const [secondsUntilNextEvent, setSecondsUntilNextEvent] = useState(null);
75-
const [bookingError, setBookingError] = useState(false);
74+
const [bookingError, setBookingError] = useState(null);
7675

7776
const fetchBookingIntervals = () => {
7877
if (!instantBookingEnabled) {
7978
return;
8079
}
8180

82-
if (!apiUrl || !slide || !token || !tenantKey) {
81+
if (!slide || !token || !tenantKey) {
8382
setFetchingIntervals(false);
8483
return;
8584
}
@@ -93,7 +92,7 @@ function CalendarSingleBooking({
9392
if (resources.length === 1) {
9493
setFetchingIntervals(true);
9594

96-
fetch(`${apiUrl}${slide["@id"]}/action`, {
95+
fetch(`${slide["@id"]}/action`, {
9796
method: "POST",
9897
headers: {
9998
authorization: `Bearer ${token}`,
@@ -173,7 +172,7 @@ function CalendarSingleBooking({
173172
};
174173

175174
const clickInterval = (interval) => {
176-
if (!apiUrl || !slide || !token || !tenantKey) {
175+
if (!slide || !token || !tenantKey) {
177176
return;
178177
}
179178

@@ -183,7 +182,7 @@ function CalendarSingleBooking({
183182

184183
setProcessingBooking(true);
185184

186-
fetch(`${apiUrl}${slide["@id"]}/action`, {
185+
fetch(`${slide["@id"]}/action`, {
187186
method: "POST",
188187
headers: {
189188
authorization: `Bearer ${token}`,
@@ -198,14 +197,21 @@ function CalendarSingleBooking({
198197
},
199198
}),
200199
})
201-
.then((r) => r.json())
200+
.then((r) => {
201+
if (!r.ok) {
202+
const error = new Error(`Booking failed with status ${r.status}`);
203+
error.status = r.status;
204+
throw error;
205+
}
206+
return r.json();
207+
})
202208
.then((data) => {
203209
setBookingResult(data);
204210
setInstantBookingFromLocalStorage(slide["@id"], data);
205211
})
206-
.catch(() => {
207-
setBookingError(true);
208-
setTimeout(() => setBookingError(false), 10000);
212+
.catch((err) => {
213+
setBookingError(err?.status === 409 ? "conflict" : "generic");
214+
setTimeout(() => setBookingError(null), 10000);
209215
})
210216
.finally(() => {
211217
setProcessingBooking(false);
@@ -217,7 +223,8 @@ function CalendarSingleBooking({
217223
dayjs.extend(localizedFormat);
218224

219225
intervalChecking();
220-
const interval = setInterval(intervalChecking, 5000);
226+
// Check every minute if the current time has changed.
227+
const interval = setInterval(intervalChecking, 60000);
221228

222229
return () => {
223230
if (interval !== null) {
@@ -385,7 +392,15 @@ function CalendarSingleBooking({
385392
/>
386393
</p>
387394
)}
388-
{bookingError && (
395+
{bookingError === "conflict" && (
396+
<p>
397+
<FormattedMessage
398+
id="instant_booking_conflict"
399+
defaultMessage="Straksbooking fejlede. Intervallet er optaget."
400+
/>
401+
</p>
402+
)}
403+
{bookingError && bookingError !== "conflict" && (
389404
<p>
390405
<FormattedMessage
391406
id="instant_booking_error"

assets/tests/template/template-calendar.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ test("calendar-1-single-booking: ui tests", async ({ page }) => {
236236
await expect(page.getByText("Ledigt")).toHaveCount(1);
237237
await expect(page.getByText("Ledigt")).toBeVisible();
238238

239-
await page.waitForTimeout(5500);
239+
await page.clock.runFor(61000);
240240

241241
await expect(page.getByText("Optaget")).toHaveCount(1);
242242
await expect(page.getByText("Optaget")).toBeVisible();

config/services.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ services:
104104
$timezone: '%env(string:CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE)%'
105105
$cacheExpireSeconds: '%env(int:CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS)%'
106106

107+
App\InteractiveSlide\InstantBook:
108+
arguments:
109+
$busyIntervalsSource: '%env(string:INSTANT_BOOK_BUSY_INTERVALS_SOURCE)%'
110+
107111
App\Service\KeyVaultService:
108112
arguments:
109113
$keyVaultSource: '%env(string:KEY_VAULT_SOURCE)%'

src/InteractiveSlide/InstantBook.php

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,21 @@
55
namespace App\InteractiveSlide;
66

77
use App\Entity\Tenant;
8+
use App\Entity\Tenant\Feed;
89
use App\Entity\Tenant\InteractiveSlideConfig;
910
use App\Entity\Tenant\Slide;
1011
use App\Exceptions\BadRequestException;
1112
use App\Exceptions\ConflictException;
1213
use App\Exceptions\ForbiddenException;
1314
use App\Exceptions\NotAcceptableException;
1415
use App\Exceptions\TooManyRequestsException;
16+
use App\Feed\FeedOutputModels;
17+
use App\Service\FeedService;
1518
use App\Service\InteractiveSlideService;
1619
use App\Service\KeyVaultService;
1720
use Psr\Cache\CacheItemInterface;
1821
use Psr\Cache\InvalidArgumentException;
22+
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
1923
use Symfony\Contracts\Cache\CacheInterface;
2024
use Symfony\Contracts\HttpClient\HttpClientInterface;
2125

@@ -45,6 +49,8 @@ class InstantBook implements InteractiveSlideInterface
4549
private const string CACHE_LIFETIME_QUICK_BOOK_OPTIONS = 'PT5M';
4650
private const string CACHE_LIFETIME_BUSY_INTERVALS = 'PT15M';
4751
private const string CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT = 'PT1M';
52+
public const string SOURCE_GRAPH = 'graph';
53+
public const string SOURCE_FEED = 'feed';
4854
// see https://docs.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0
4955
// example 2019-03-15T09:00:00
5056
public const string GRAPH_DATE_FORMAT = 'Y-m-d\TH:i:s';
@@ -54,7 +60,13 @@ public function __construct(
5460
private readonly HttpClientInterface $client,
5561
private readonly KeyVaultService $keyVaultService,
5662
private readonly CacheInterface $interactiveSlideCache,
57-
) {}
63+
private readonly FeedService $feedService,
64+
private readonly string $busyIntervalsSource,
65+
) {
66+
if (!in_array($busyIntervalsSource, [self::SOURCE_GRAPH, self::SOURCE_FEED], true)) {
67+
throw new \InvalidArgumentException(sprintf('Invalid INSTANT_BOOK_BUSY_INTERVALS_SOURCE "%s"; expected "%s" or "%s".', $busyIntervalsSource, self::SOURCE_GRAPH, self::SOURCE_FEED));
68+
}
69+
}
5870

5971
public function getConfigOptions(): array
6072
{
@@ -195,13 +207,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest,
195207
$watchedResources[] = $resource;
196208
}
197209

198-
$schedules = $this->interactiveSlideCache->get(self::CACHE_KEY_BUSY_INTERVALS_PREFIX,
199-
function (CacheItemInterface $item) use ($token, $watchedResources, $start, $startPlus1Hour) {
200-
$item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS));
201-
202-
return $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour);
203-
}
204-
);
210+
$schedules = $this->fetchBusyIntervals($token, $watchedResources, $start, $startPlus1Hour, $slide);
205211

206212
$result = [];
207213

@@ -328,6 +334,8 @@ function (CacheItemInterface $item) use (&$isFreshRequest): bool {
328334
$start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC'));
329335
$startPlusDuration = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC'));
330336

337+
$this->assertSlotFree($token, $resource, $start, $startPlusDuration);
338+
331339
$requestBody = [
332340
'subject' => self::BOOKING_TITLE,
333341
'start' => [
@@ -435,6 +443,137 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st
435443
return $result;
436444
}
437445

446+
/**
447+
* Dispatch busy-intervals lookup according to INSTANT_BOOK_BUSY_INTERVALS_SOURCE.
448+
*
449+
* Both branches return the same shape:
450+
* [resourceId => [['startTime' => DateTime, 'endTime' => DateTime], ...]].
451+
*
452+
* @param string[] $resources
453+
*
454+
* @return array<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
455+
*
456+
* @throws \Throwable
457+
*/
458+
private function fetchBusyIntervals(string $token, array $resources, \DateTime $from, \DateTime $to, Slide $slide): array
459+
{
460+
return match ($this->busyIntervalsSource) {
461+
self::SOURCE_FEED => $this->getBusyIntervalsFromFeed($slide->getFeed(), $resources, $from, $to),
462+
self::SOURCE_GRAPH => $this->getBusyIntervalsCached($token, $resources, $from, $to),
463+
};
464+
}
465+
466+
/**
467+
* Cached wrapper around getBusyIntervals().
468+
*
469+
* The cache key includes the resource set and the from/to window (minute precision) so that
470+
* concurrent requests with different watched resources or windows do not share an entry; the
471+
* previous fixed-key behaviour caused different slides to read each other's data.
472+
*
473+
* @param string[] $resources
474+
*
475+
* @return array<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
476+
*
477+
* @throws InvalidArgumentException
478+
*/
479+
private function getBusyIntervalsCached(string $token, array $resources, \DateTime $from, \DateTime $to): array
480+
{
481+
$sortedResources = $resources;
482+
sort($sortedResources);
483+
484+
$cacheKey = sprintf(
485+
'%s-%s-%s-%s',
486+
self::CACHE_KEY_BUSY_INTERVALS_PREFIX,
487+
hash('xxh128', implode('|', $sortedResources)),
488+
$from->format('YmdHi'),
489+
$to->format('YmdHi'),
490+
);
491+
492+
return $this->interactiveSlideCache->get(
493+
$cacheKey,
494+
function (CacheItemInterface $item) use ($token, $resources, $from, $to) {
495+
$item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_BUSY_INTERVALS));
496+
497+
return $this->getBusyIntervals($token, $resources, $from, $to);
498+
},
499+
);
500+
}
501+
502+
/**
503+
* Verify that the requested slot is free in live Graph state before booking.
504+
*
505+
* This is required, not an optimization: the resources used here are not configured to block
506+
* conflicting bookings at the Graph API level — POSTing a conflicting booking will simply
507+
* succeed rather than return 409. The 409 catch on the booking POST is a backstop for
508+
* resources whose AutomateProcessing setting does reject conflicts.
509+
*
510+
* @throws ConflictException if the slot is already booked
511+
*/
512+
private function assertSlotFree(string $token, string $resource, \DateTime $start, \DateTime $end): void
513+
{
514+
$schedules = $this->getBusyIntervals($token, [$resource], $start, $end);
515+
516+
if (!$this->intervalFree($schedules[$resource] ?? [], $start, $end)) {
517+
throw new ConflictException('Interval booked already');
518+
}
519+
}
520+
521+
/**
522+
* Derive busy intervals from the slide's calendar-output-model feed.
523+
*
524+
* @param string[] $resources resource ids to include in the result
525+
*
526+
* @return array<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
527+
*
528+
* @throws UnprocessableEntityHttpException when the slide is not configured for feed-sourced busy intervals
529+
*/
530+
private function getBusyIntervalsFromFeed(?Feed $feed, array $resources, \DateTime $from, \DateTime $to): array
531+
{
532+
if (null === $feed) {
533+
throw new UnprocessableEntityHttpException('InstantBook (feed source): slide feed not set.');
534+
}
535+
536+
$feedSource = $feed->getFeedSource();
537+
538+
if (null === $feedSource) {
539+
throw new UnprocessableEntityHttpException('InstantBook (feed source): feed source not set on slide feed.');
540+
}
541+
542+
$feedType = $this->feedService->getFeedType($feedSource->getFeedType());
543+
544+
if (FeedOutputModels::CALENDAR_OUTPUT !== $feedType->getSupportedFeedOutputType()) {
545+
throw new UnprocessableEntityHttpException('InstantBook (feed source) requires a calendar-output feed.');
546+
}
547+
548+
$events = $this->feedService->getData($feed) ?? [];
549+
550+
$result = array_fill_keys($resources, []);
551+
$fromTs = $from->getTimestamp();
552+
$toTs = $to->getTimestamp();
553+
554+
foreach ($events as $event) {
555+
$resourceId = $event['resourceId'] ?? null;
556+
557+
if (!is_string($resourceId) || !array_key_exists($resourceId, $result)) {
558+
continue;
559+
}
560+
561+
$startTs = (int) ($event['startTime'] ?? 0);
562+
$endTs = (int) ($event['endTime'] ?? 0);
563+
564+
if ($endTs <= $fromTs || $startTs >= $toTs) {
565+
continue;
566+
}
567+
568+
$result[$resourceId][] = [
569+
'startTime' => new \DateTime('@'.$startTs),
570+
'endTime' => new \DateTime('@'.$endTs),
571+
];
572+
}
573+
574+
return $result;
575+
}
576+
438577
public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool
439578
{
440579
foreach ($schedule as $scheduleEntry) {

0 commit comments

Comments
 (0)