55namespace App \InteractiveSlide ;
66
77use App \Entity \Tenant ;
8+ use App \Entity \Tenant \Feed ;
89use App \Entity \Tenant \InteractiveSlideConfig ;
910use App \Entity \Tenant \Slide ;
1011use App \Exceptions \BadRequestException ;
1112use App \Exceptions \ConflictException ;
1213use App \Exceptions \ForbiddenException ;
1314use App \Exceptions \NotAcceptableException ;
1415use App \Exceptions \TooManyRequestsException ;
16+ use App \Feed \FeedOutputModels ;
17+ use App \Service \FeedService ;
1518use App \Service \InteractiveSlideService ;
1619use App \Service \KeyVaultService ;
1720use Psr \Cache \CacheItemInterface ;
1821use Psr \Cache \InvalidArgumentException ;
22+ use Symfony \Component \HttpKernel \Exception \UnprocessableEntityHttpException ;
1923use Symfony \Contracts \Cache \CacheInterface ;
2024use 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