Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c55b5d0
7208: Added strategy where busy intervals are retrieved from calendar…
tuj May 5, 2026
3e32edb
Merge branch 'feature/instantbook-cleanup' into feature/instantbook-b…
tuj May 6, 2026
5e27c84
7208: Added variable to .env and README
tuj May 6, 2026
9682960
7208: Applied coding standards
tuj May 6, 2026
a986e16
7208: Updated changelog
tuj May 6, 2026
4caf193
7208: Applied rector
tuj May 6, 2026
23d64d7
7208: Code cleanup
tuj May 6, 2026
df206c3
Merge branch 'feature/instantbook-cleanup' into feature/instantbook-b…
tuj May 6, 2026
4eed0b4
7208: Fixed bugs in instant book template following upgrade
tuj May 6, 2026
aac1540
7208: Fixed test case. Set symfony deprecation helper to weak to avoi…
tuj May 6, 2026
ba68363
7208: Readded interval free check before booking to avoid double book…
tuj May 7, 2026
8ce27aa
7208: Fixed error messages
tuj May 7, 2026
00dc323
7208: Changed interval
tuj May 7, 2026
7b4318b
7208: Fixed test
tuj May 7, 2026
ca263e3
7208: Validated INSTANT_BOOK_BUSY_INTERVALS_SOURCE at construction
turegjorup May 27, 2026
557fc4f
7208: Return 422 instead of 406 for InstantBook feed-source misconfig
turegjorup May 27, 2026
a7ac945
7208: Clarified why the pre-booking Graph check is required
turegjorup May 27, 2026
91d028e
7208: Parameterized busy-intervals cache key by resources and window
turegjorup May 27, 2026
3b9f1e6
7208: Covered the pre-booking conflict check with tests
turegjorup May 27, 2026
e443e98
Merge branch 'release/3.0.0' into feature/instantbook-busy-from-feed
tuj May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <status>` instead.
- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 27 additions & 12 deletions assets/shared/templates/calendar/calendar-single-booking.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,21 @@ 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);
const [currentTime, setCurrentTime] = useState(dayjs());
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;
}
Expand All @@ -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}`,
Expand Down Expand Up @@ -173,7 +172,7 @@ function CalendarSingleBooking({
};

const clickInterval = (interval) => {
if (!apiUrl || !slide || !token || !tenantKey) {
if (!slide || !token || !tenantKey) {
return;
}

Expand All @@ -183,7 +182,7 @@ function CalendarSingleBooking({

setProcessingBooking(true);

fetch(`${apiUrl}${slide["@id"]}/action`, {
fetch(`${slide["@id"]}/action`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -385,7 +392,15 @@ function CalendarSingleBooking({
/>
</p>
)}
{bookingError && (
{bookingError === "conflict" && (
<p>
<FormattedMessage
id="instant_booking_conflict"
defaultMessage="Straksbooking fejlede. Intervallet er optaget."
/>
</p>
)}
{bookingError && bookingError !== "conflict" && (
<p>
<FormattedMessage
id="instant_booking_error"
Expand Down
2 changes: 1 addition & 1 deletion assets/tests/template/template-calendar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ test("calendar-1-single-booking: ui tests", async ({ page }) => {
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();
Expand Down
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)%'
Expand Down
155 changes: 147 additions & 8 deletions src/InteractiveSlide/InstantBook.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
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;
use App\Exceptions\ConflictException;
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;

Expand Down Expand Up @@ -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';
Expand All @@ -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
{
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
*
* @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<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
*
* @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<string, array<int, array{startTime: \DateTime, endTime: \DateTime}>>
*
* @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) {
Expand Down
Loading
Loading