From 2b85f2689c5d691ce9ca81f505b44049dd5307b9 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Fri, 23 May 2025 10:27:03 +0200 Subject: [PATCH 01/13] Initial commit --- src/Feed/BrndFeedType.php | 260 +++++++++++++++++++++ src/Feed/FeedOutputModels.php | 23 +- src/Feed/SourceType/Brnd/ApiClient.php | 175 ++++++++++++++ src/Feed/SourceType/Brnd/BrndException.php | 11 + src/Feed/SourceType/Brnd/SecretsDTO.php | 33 +++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 src/Feed/BrndFeedType.php create mode 100644 src/Feed/SourceType/Brnd/ApiClient.php create mode 100644 src/Feed/SourceType/Brnd/BrndException.php create mode 100644 src/Feed/SourceType/Brnd/SecretsDTO.php diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php new file mode 100644 index 000000000..0d42f2c04 --- /dev/null +++ b/src/Feed/BrndFeedType.php @@ -0,0 +1,260 @@ +feedService->getFeedSourceConfigUrl($feedSource, 'sport-center'); + + return [ + [ + 'key' => 'brnd-sport-center-id', + 'input' => 'input', + 'type' => 'text', + 'name' => 'sport_center_id', + 'label' => 'Sport Center ID', + 'formGroupClasses' => 'mb-3', + ], + ]; + } + + public function getData(Feed $feed): array + { + $result = [ + 'title' => 'BRND Booking', + 'entries' => [], + ]; + + $configuration = $feed->getConfiguration(); + $feedSource = $feed->getFeedSource(); + + if (null == $feedSource) { + return $result; + } + + $secrets = new SecretsDTO($feedSource); + + $baseUri = $secrets->apiBaseUri; + $recipients = $configuration['recipients'] ?? []; + $publishers = $configuration['publishers'] ?? []; + $pageSize = isset($configuration['page_size']) ? (int) $configuration['page_size'] : 10; + + if (empty($baseUri) || 0 === count($recipients)) { + return $result; + } + + $feedSource = $feed->getFeedSource(); + + if (null === $feedSource) { + return $result; + } + + $entries = $this->apiClient->getFeedEntriesNews($feedSource, $recipients, $publishers, $pageSize); + + foreach ($entries as $entry) { + $item = new Item(); + $item->setTitle($entry->fields->title); + + $crawler = new Crawler($entry->fields->description); + $summary = ''; + foreach ($crawler as $domElement) { + $summary .= $domElement->textContent; + } + $item->setSummary($summary); + + $item->setPublicId((string) $entry->id); + + $link = sprintf('%s/feedentry/%s', $baseUri, $entry->id); + $item->setLink($link); + + if (null !== $entry->fields->body) { + $crawler = new Crawler($entry->fields->body); + $content = ''; + foreach ($crawler as $domElement) { + $content .= $domElement->textContent; + } + } else { + $content = $item->getSummary(); + } + $item->setContent($content); + + $updated = $entry->updated ?? $entry->publishDate; + $item->setLastModified(new \DateTime($updated)); + + $author = new Item\Author(); + $author->setName($entry->publisher->name); + $item->setAuthor($author); + + if (null !== $entry->fields->galleryItems) { + try { + $galleryItems = json_decode($entry->fields->galleryItems, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $galleryItems = []; + } + + foreach ($galleryItems as $galleryItem) { + $media = new Item\Media(); + + $large = sprintf('%s/api/files/%s/thumbnail/large', $baseUri, $galleryItem['id']); + $media->setUrl($large); + + $small = sprintf('%s/api/files/%s/thumbnail/small', $baseUri, $galleryItem['id']); + $media->setThumbnail($small); + + $item->addMedia($media); + } + } + + foreach ($entry->recipients as $recipient) { + $category = new Category(); + $category->setLabel($recipient->name); + + $item->addCategory($category); + } + + $result['entries'][] = $item->toArray(); + } + + return $result; + } + + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + switch ($name) { + case 'allowed-recipients': + $allowedIds = $feedSource->getSecrets()['allowed_recipients'] ?? []; + $allGroupOptions = $this->getConfigOptions($request, $feedSource, 'recipients'); + + if (null === $allGroupOptions) { + return []; + } + + return array_values(array_filter($allGroupOptions, fn (ConfigOption $group) => in_array($group->value, $allowedIds))); + case 'recipients': + $id = self::getIdKey($feedSource); + + /** @var CacheItemInterface $cacheItem */ + $cacheItem = $this->feedsCache->getItem('colibo_feed_entry_groups_'.$id); + + if ($cacheItem->isHit()) { + $groups = $cacheItem->get(); + } else { + $groups = $this->apiClient->getSearchGroups($feedSource); + + $groups = array_map(fn (array $item) => new ConfigOption( + Ulid::generate(), + sprintf('%s (%d)', $item['model']['title'], $item['model']['id']), + (string) $item['model']['id'] + ), $groups); + + usort($groups, fn ($a, $b) => strcmp($a->title, $b->title)); + + $cacheItem->set($groups); + $cacheItem->expiresAfter(self::CACHE_TTL); + $this->feedsCache->save($cacheItem->set($groups)); + } + + return $groups; + default: + return null; + } + } + + public function getRequiredSecrets(): array + { + return [ + 'api_base_uri' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'string_array', + 'exposeValue' => true, + ], + ]; + } + + public function getRequiredConfiguration(): array + { + return ['recipients', 'page_size']; + } + + public function getSupportedFeedOutputType(): string + { + return self::SUPPORTED_FEED_TYPE; + } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'api_base_uri' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'required' => ['api_base_uri', 'client_id', 'client_secret'], + ]; + } + + public static function getIdKey(FeedSource $feedSource): string + { + $ulid = $feedSource->getId(); + assert(null !== $ulid); + + return $ulid->toBase32(); + } +} diff --git a/src/Feed/FeedOutputModels.php b/src/Feed/FeedOutputModels.php index 0458c7231..283bf3a2b 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -61,4 +61,25 @@ class FeedOutputModels * ] */ final public const string RSS_OUTPUT = 'rss'; -} + + /** + * Data example: + * + * [ + * { + * bookingcode: "BKN-367310", + * analeg: "Humlehøj Hallen", + * date: "2025-05-01", + * displayStart: "09:00", + * displayEnd: "10:45", + * displayTime: "09:00 - 10:45", + * bookingBy: "Børnegården Humle-Tumle", + * start: "2025-05-01T09:00", + * end: "2025-05-01T10:45", + * activityGroupName: "Kampsport", + * activityName: "Brydning", + * } + * ] + */ + final public const string BRND_BOOKING_OUTPUT = 'brnd-booking'; +} \ No newline at end of file diff --git a/src/Feed/SourceType/Brnd/ApiClient.php b/src/Feed/SourceType/Brnd/ApiClient.php new file mode 100644 index 000000000..c19a5c1e8 --- /dev/null +++ b/src/Feed/SourceType/Brnd/ApiClient.php @@ -0,0 +1,175 @@ + */ + private array $apiClients = []; + + public function __construct( + private readonly CacheItemPoolInterface $feedsCache, + private readonly LoggerInterface $logger, + ) {} + + + /** + * Retrieve bookings based on the given feed source and sportCenterId. + * + * @param FeedSource $feedSource + * @param string $sportCenterId + * @param string $startDate + * @param string $endDate + * + * @return array + */ + public function getBookingInfo(FeedSource $feedSource, string $sportCenterId): array + { + try { + $responseData = $this->getBookingInfoPage($feedSource, $sportCenterId)->toArray(); + + $bookings = $responseData['data']; + + return $bookings; + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * @param FeedSource $feedSource + * @param string $sportCenterId + * @param string $startDate + * @param string $endDate + * + * @return ResponseInterface + * + * @throws BrndException + */ + private function getBookingInfoPage( + FeedSource $feedSource, + string $sportCenterId, + ?string $startDate = null, + ?string $endDate = null + ): ResponseInterface { + $startDate = $startDate ?? date('Y-m-d'); + $endDate = $endDate ?? date('Y-m-d'); + + try { + $client = $this->getApiClient($feedSource); + + return $client->request('POST', '/v1.0/booking-info', [ + 'body' => [ + 'sportCenterId' => $sportCenterId, + 'startDate' => $startDate, + 'endDate' => $endDate, + ], + ]); + } catch (BrndException $exception) { + throw $exception; + } catch (\Throwable $throwable) { + throw new BrndException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + /** + * Get an authenticated scoped API client for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return HttpClientInterface + * + * @throws BrndException + */ + private function getApiClient(FeedSource $feedSource): HttpClientInterface + { + $id = BrndFeedType::getIdKey($feedSource); + + if (array_key_exists($id, $this->apiClients)) { + return $this->apiClients[$id]; + } + + $secrets = new SecretsDTO($feedSource); + $this->apiClients[$id] = HttpClient::createForBaseUri($secrets->apiBaseUri)->withOptions([ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer '.$this->fetchToken($feedSource), + 'Accept' => '*/*', + ], + ]); + + return $this->apiClients[$id]; + } + + /** + * Get the auth token for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return string + * + * @throws BrndException + */ + private function fetchToken(FeedSource $feedSource): string + { + $id = BrndFeedType::getIdKey($feedSource); + + /** @var CacheItemInterface $cacheItem */ + $cacheItem = $this->feedsCache->getItem('brnd_token_'.$id); + + if ($cacheItem->isHit()) { + /** @var string $token */ + $token = $cacheItem->get(); + } else { + try { + $secrets = new SecretsDTO($feedSource); + $client = HttpClient::createForBaseUri($secrets->apiBaseUri); + + $response = $client->request('POST', '/v1.0/generate-token', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => '*/*', + ], + 'body' => [ + 'associationType' => self::ASSOCIATION_TYPE, + 'apiAuthKey' => $secrets->apiAuthKey, + ], + ]); + + $content = $response->getContent(); + $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + + $token = $contentDecoded->access_token; + + // Expire cache 5 min before token expire + $expireSeconds = intval($contentDecoded->expires_in - 300); + + $cacheItem->set($token); + $cacheItem->expiresAfter($expireSeconds); + $this->feedsCache->save($cacheItem); + } catch (\Throwable $throwable) { + throw new BrndException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + return $token; + } +} diff --git a/src/Feed/SourceType/Brnd/BrndException.php b/src/Feed/SourceType/Brnd/BrndException.php new file mode 100644 index 000000000..d5cbd67b6 --- /dev/null +++ b/src/Feed/SourceType/Brnd/BrndException.php @@ -0,0 +1,11 @@ +getSecrets(); + + if (null === $secrets) { + throw new \RuntimeException('No secrets found for feed source.'); + } + + if (!isset($secrets['api_base_uri'], $secrets['api_auth_key'])) { + throw new \RuntimeException('Missing required secrets for feed source.'); + } + + if (false === filter_var($secrets['api_base_uri'], FILTER_VALIDATE_URL)) { + throw new \RuntimeException('Invalid api_endpoint.'); + } + + $this->apiBaseUri = rtrim((string) $secrets['api_base_uri'], '/'); + $this->apiAuthKey = $secrets['api_auth_key']; + } +} From 45b3f9421eb82612dc583079fab893da0cdc9282 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 27 May 2025 16:11:50 +0200 Subject: [PATCH 02/13] Change API endpoint --- src/Feed/BrndFeedType.php | 161 ++++++------------------ src/Feed/FeedOutputModels.php | 25 ++-- src/Feed/SourceType/Brnd/ApiClient.php | 52 +++++--- src/Feed/SourceType/Brnd/SecretsDTO.php | 4 +- 4 files changed, 86 insertions(+), 156 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 0d42f2c04..478f8a15c 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -27,7 +27,7 @@ class BrndFeedType implements FeedTypeInterface { public const int CACHE_TTL = 3600; - final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::RSS_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::BRND_BOOKING_OUTPUT; public function __construct( private readonly FeedService $feedService, @@ -55,7 +55,7 @@ public function getData(Feed $feed): array { $result = [ 'title' => 'BRND Booking', - 'entries' => [], + 'bookings' => [], ]; $configuration = $feed->getConfiguration(); @@ -68,11 +68,9 @@ public function getData(Feed $feed): array $secrets = new SecretsDTO($feedSource); $baseUri = $secrets->apiBaseUri; - $recipients = $configuration['recipients'] ?? []; - $publishers = $configuration['publishers'] ?? []; - $pageSize = isset($configuration['page_size']) ? (int) $configuration['page_size'] : 10; + $sportCenterId = $configuration['sport_center_id'] ?? null; - if (empty($baseUri) || 0 === count($recipients)) { + if (empty($baseUri) || empty($sportCenterId)) { return $result; } @@ -82,115 +80,36 @@ public function getData(Feed $feed): array return $result; } - $entries = $this->apiClient->getFeedEntriesNews($feedSource, $recipients, $publishers, $pageSize); - - foreach ($entries as $entry) { - $item = new Item(); - $item->setTitle($entry->fields->title); - - $crawler = new Crawler($entry->fields->description); - $summary = ''; - foreach ($crawler as $domElement) { - $summary .= $domElement->textContent; - } - $item->setSummary($summary); - - $item->setPublicId((string) $entry->id); - - $link = sprintf('%s/feedentry/%s', $baseUri, $entry->id); - $item->setLink($link); - - if (null !== $entry->fields->body) { - $crawler = new Crawler($entry->fields->body); - $content = ''; - foreach ($crawler as $domElement) { - $content .= $domElement->textContent; - } - } else { - $content = $item->getSummary(); - } - $item->setContent($content); - - $updated = $entry->updated ?? $entry->publishDate; - $item->setLastModified(new \DateTime($updated)); - - $author = new Item\Author(); - $author->setName($entry->publisher->name); - $item->setAuthor($author); - - if (null !== $entry->fields->galleryItems) { - try { - $galleryItems = json_decode($entry->fields->galleryItems, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException) { - $galleryItems = []; - } - - foreach ($galleryItems as $galleryItem) { - $media = new Item\Media(); - - $large = sprintf('%s/api/files/%s/thumbnail/large', $baseUri, $galleryItem['id']); - $media->setUrl($large); - - $small = sprintf('%s/api/files/%s/thumbnail/small', $baseUri, $galleryItem['id']); - $media->setThumbnail($small); - - $item->addMedia($media); - } - } - - foreach ($entry->recipients as $recipient) { - $category = new Category(); - $category->setLabel($recipient->name); - - $item->addCategory($category); - } - - $result['entries'][] = $item->toArray(); - } + $bookings = $this->apiClient->getInfomonitorBookingsDetails($feedSource, $sportCenterId); + + $result['bookings'] = array_map([$this, 'parseBrndBooking'], $bookings); return $result; } - public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + private function parseBrndBooking(array $booking): array { - switch ($name) { - case 'allowed-recipients': - $allowedIds = $feedSource->getSecrets()['allowed_recipients'] ?? []; - $allGroupOptions = $this->getConfigOptions($request, $feedSource, 'recipients'); - - if (null === $allGroupOptions) { - return []; - } - - return array_values(array_filter($allGroupOptions, fn (ConfigOption $group) => in_array($group->value, $allowedIds))); - case 'recipients': - $id = self::getIdKey($feedSource); - - /** @var CacheItemInterface $cacheItem */ - $cacheItem = $this->feedsCache->getItem('colibo_feed_entry_groups_'.$id); - - if ($cacheItem->isHit()) { - $groups = $cacheItem->get(); - } else { - $groups = $this->apiClient->getSearchGroups($feedSource); - - $groups = array_map(fn (array $item) => new ConfigOption( - Ulid::generate(), - sprintf('%s (%d)', $item['model']['title'], $item['model']['id']), - (string) $item['model']['id'] - ), $groups); - - usort($groups, fn ($a, $b) => strcmp($a->title, $b->title)); - - $cacheItem->set($groups); - $cacheItem->expiresAfter(self::CACHE_TTL); - $this->feedsCache->save($cacheItem->set($groups)); - } + return [ + 'bookingcode' => $booking['ansøgning'] ?? '', + 'remarks' => $booking['bemærkninger'] ?? '', + 'date' => $booking['dato'] ?? '', + 'start' => $booking['starttid'] ?? '', + 'end' => $booking['sluttid'] ?? '', + 'complex' => $booking['anlæg'] ?? '', + 'area' => $booking['område'] ?? '', + 'facility' => $booking['facilitet'] ?? '', + 'activity' => $booking['aktivitet'] ?? '', + 'team' => $booking['hold'] ?? '', + 'status' => $booking['status'] ?? '', + 'checkIn' => $booking['checK_IN'] ?? '', + 'bookingBy' => $booking['ansøgt_af'] ?? '', + 'changingRooms' => $booking['omklædningsrum'] ?? '', + ]; + } - return $groups; - default: - return null; - } + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + return null; } public function getRequiredSecrets(): array @@ -200,22 +119,18 @@ public function getRequiredSecrets(): array 'type' => 'string', 'exposeValue' => true, ], - 'client_id' => [ + 'company_id' => [ 'type' => 'string', ], - 'client_secret' => [ + 'api_auth_key' => [ 'type' => 'string', ], - 'allowed_recipients' => [ - 'type' => 'string_array', - 'exposeValue' => true, - ], ]; } public function getRequiredConfiguration(): array { - return ['recipients', 'page_size']; + return ['sport_center_id']; } public function getSupportedFeedOutputType(): string @@ -233,20 +148,14 @@ public function getSchema(): array 'type' => 'string', 'format' => 'uri', ], - 'client_id' => [ + 'company_id' => [ 'type' => 'string', ], - 'client_secret' => [ + 'api_auth_key' => [ 'type' => 'string', ], - 'allowed_recipients' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - ], - ], ], - 'required' => ['api_base_uri', 'client_id', 'client_secret'], + 'required' => ['api_base_uri', 'company_id', 'api_auth_key'], ]; } @@ -258,3 +167,5 @@ public static function getIdKey(FeedSource $feedSource): string return $ulid->toBase32(); } } + + diff --git a/src/Feed/FeedOutputModels.php b/src/Feed/FeedOutputModels.php index 283bf3a2b..e34cc96e8 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -67,17 +67,20 @@ class FeedOutputModels * * [ * { - * bookingcode: "BKN-367310", - * analeg: "Humlehøj Hallen", - * date: "2025-05-01", - * displayStart: "09:00", - * displayEnd: "10:45", - * displayTime: "09:00 - 10:45", - * bookingBy: "Børnegården Humle-Tumle", - * start: "2025-05-01T09:00", - * end: "2025-05-01T10:45", - * activityGroupName: "Kampsport", - * activityName: "Brydning", + * bookingcode: "BKN-363612", + * remarks: "Kinesisk undervisning", + * date: "05/25/2025 00:00:00", + * start: "08:30:00.0000000", + * end: "12:30:00.0000000", + * complex: "Multikulturhuset", + * area: "Mødelokaler", + * facility: "M3.2 - Max 6 personer", + * activity: "Møder", + * team: "", + * status: "Tildelt tid", + * checkIn: "0", + * bookingBy: "Engangsbruger", + * changingRooms: "" * } * ] */ diff --git a/src/Feed/SourceType/Brnd/ApiClient.php b/src/Feed/SourceType/Brnd/ApiClient.php index c19a5c1e8..f0651aa09 100644 --- a/src/Feed/SourceType/Brnd/ApiClient.php +++ b/src/Feed/SourceType/Brnd/ApiClient.php @@ -15,7 +15,11 @@ class ApiClient { - private const string ASSOCIATION_TYPE = 'Company'; + private const string AUTH_ASSOCIATION_TYPE = 'Company'; + private const string BOOKINGS_ASSOCIATION_TYPE = 'Sportcenter'; + private const int STATUS_CANCELLED = 5; + private const int STATUS_ALLOCATED = 4; + /** @var array */ private array $apiClients = []; @@ -27,21 +31,19 @@ public function __construct( /** - * Retrieve bookings based on the given feed source and sportCenterId. + * Retrieve todays bookings from Infomonitor Booking API for a given sportCenterId. * * @param FeedSource $feedSource * @param string $sportCenterId - * @param string $startDate - * @param string $endDate * * @return array */ - public function getBookingInfo(FeedSource $feedSource, string $sportCenterId): array + public function getInfomonitorBookingsDetails(FeedSource $feedSource, string $sportCenterId): array { try { - $responseData = $this->getBookingInfoPage($feedSource, $sportCenterId)->toArray(); + $responseData = $this->getInfomonitorBookingsDetailsData($feedSource, $sportCenterId)->toArray(); - $bookings = $responseData['data']; + $bookings = $responseData['data']['infoBookingDetails']; return $bookings; } catch (\Throwable $throwable) { @@ -57,30 +59,42 @@ public function getBookingInfo(FeedSource $feedSource, string $sportCenterId): a /** * @param FeedSource $feedSource * @param string $sportCenterId - * @param string $startDate - * @param string $endDate + * @param string|null $date Optional. Defaults to today if not provided (Y-m-d). + * @param string|null $startTime Optional. Time in HH:MM format. Defaults to empty string if not provided (no time filter applied). + * @param string|null $endTime Optional. Time in HH:MM format. Defaults to empty string if not provided (no time filter applied). + * @param int[]|null $bookingStatusCodes Optional. Array of booking status codes to filter by. * * @return ResponseInterface * * @throws BrndException */ - private function getBookingInfoPage( + private function getInfomonitorBookingsDetailsData( FeedSource $feedSource, string $sportCenterId, - ?string $startDate = null, - ?string $endDate = null + ?string $date = null, + ?string $startTime = null, + ?string $endTime = null, + ?array $bookingStatusCodes = null ): ResponseInterface { - $startDate = $startDate ?? date('Y-m-d'); - $endDate = $endDate ?? date('Y-m-d'); + $secrets = new SecretsDTO($feedSource); + $defaultStatusCodes = [self::STATUS_ALLOCATED, self::STATUS_CANCELLED]; + $date = $date ?? date('Y-m-d'); + $startTime = $startTime ?? ''; + $endTime = $endTime ?? ''; + $bookingStatusCodes = implode(',', $bookingStatusCodes ?? $defaultStatusCodes); try { $client = $this->getApiClient($feedSource); - return $client->request('POST', '/v1.0/booking-info', [ + return $client->request('POST', '/v1.0/get-infomonitor-bookings-details', [ 'body' => [ - 'sportCenterId' => $sportCenterId, - 'startDate' => $startDate, - 'endDate' => $endDate, + 'companyID' => $secrets->companyId, + 'associationID' => $sportCenterId, + 'associationType' => self::BOOKINGS_ASSOCIATION_TYPE, + 'date' => $date, + 'startTime' => $startTime, + 'endTime' => $endTime, + 'statusID' => $bookingStatusCodes, ], ]); } catch (BrndException $exception) { @@ -149,7 +163,7 @@ private function fetchToken(FeedSource $feedSource): string 'Accept' => '*/*', ], 'body' => [ - 'associationType' => self::ASSOCIATION_TYPE, + 'associationType' => self::AUTH_ASSOCIATION_TYPE, 'apiAuthKey' => $secrets->apiAuthKey, ], ]); diff --git a/src/Feed/SourceType/Brnd/SecretsDTO.php b/src/Feed/SourceType/Brnd/SecretsDTO.php index 2e5d266ad..261c20de9 100644 --- a/src/Feed/SourceType/Brnd/SecretsDTO.php +++ b/src/Feed/SourceType/Brnd/SecretsDTO.php @@ -10,6 +10,7 @@ { public string $apiBaseUri; public string $apiAuthKey; + public string $companyId; public function __construct(FeedSource $feedSource) { @@ -19,7 +20,7 @@ public function __construct(FeedSource $feedSource) throw new \RuntimeException('No secrets found for feed source.'); } - if (!isset($secrets['api_base_uri'], $secrets['api_auth_key'])) { + if (!isset($secrets['api_base_uri'], $secrets['company_id'], $secrets['api_auth_key'])) { throw new \RuntimeException('Missing required secrets for feed source.'); } @@ -28,6 +29,7 @@ public function __construct(FeedSource $feedSource) } $this->apiBaseUri = rtrim((string) $secrets['api_base_uri'], '/'); + $this->companyId = $secrets['company_id']; $this->apiAuthKey = $secrets['api_auth_key']; } } From e90f378dca84cd025a2e506f7fbf318d7c1eed03 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 3 Jun 2025 14:32:07 +0200 Subject: [PATCH 03/13] Change secrets settings --- src/Feed/BrndFeedType.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 478f8a15c..a82862c04 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -121,9 +121,11 @@ public function getRequiredSecrets(): array ], 'company_id' => [ 'type' => 'string', + 'exposeValue' => true, ], 'api_auth_key' => [ 'type' => 'string', + 'exposeValue' => true, ], ]; } From 0519fee5b67b4033e297031f0a0075feebe7ca57 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 17 Jun 2025 13:59:35 +0200 Subject: [PATCH 04/13] Change time formats --- src/Feed/BrndFeedType.php | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index a82862c04..7617fdacf 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -45,7 +45,7 @@ public function getAdminFormOptions(FeedSource $feedSource): array 'input' => 'input', 'type' => 'text', 'name' => 'sport_center_id', - 'label' => 'Sport Center ID', + 'label' => 'Sportcenter ID', 'formGroupClasses' => 'mb-3', ], ]; @@ -89,12 +89,29 @@ public function getData(Feed $feed): array private function parseBrndBooking(array $booking): array { + // Parse start time + $startDateTime = null; + if (!empty($booking['date']) && !empty($booking['starttid'])) { + $startDateTime = \DateTimeImmutable::createFromFormat( + 'm/d/Y H:i:s.u', + preg_replace('/\.\d+$/', '', $booking['date']) . ' ' . substr($booking['starttid'], 0, 8) . '.0' + ); + } + + // Parse end time + $endDateTime = null; + if (!empty($booking['date']) && !empty($booking['sluttid'])) { + $endDateTime = \DateTimeImmutable::createFromFormat( + 'm/d/Y H:i:s.u', + preg_replace('/\.\d+$/', '', $booking['date']) . ' ' . substr($booking['sluttid'], 0, 8) . '.0' + ); + } + return [ 'bookingcode' => $booking['ansøgning'] ?? '', 'remarks' => $booking['bemærkninger'] ?? '', - 'date' => $booking['dato'] ?? '', - 'start' => $booking['starttid'] ?? '', - 'end' => $booking['sluttid'] ?? '', + 'startTime' => $startDateTime ? $startDateTime->getTimestamp() : null, + 'endTime' => $endDateTime ? $endDateTime->getTimestamp() : null, 'complex' => $booking['anlæg'] ?? '', 'area' => $booking['område'] ?? '', 'facility' => $booking['facilitet'] ?? '', From 5d60da15f46096d30774be76a5f88046ca3c93e0 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 1 Jul 2025 15:05:53 +0200 Subject: [PATCH 05/13] Various bugfixes --- src/Feed/BrndFeedType.php | 21 ++++++++------- src/Feed/SourceType/Brnd/ApiClient.php | 36 +++++++++++++++----------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 7617fdacf..954f654b2 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -91,20 +91,21 @@ private function parseBrndBooking(array $booking): array { // Parse start time $startDateTime = null; - if (!empty($booking['date']) && !empty($booking['starttid'])) { - $startDateTime = \DateTimeImmutable::createFromFormat( - 'm/d/Y H:i:s.u', - preg_replace('/\.\d+$/', '', $booking['date']) . ' ' . substr($booking['starttid'], 0, 8) . '.0' - ); + if (!empty($booking['dato']) && !empty($booking['starttid'])) { + // Trim starttid to 6 digits after dot for microseconds + $starttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['starttid']); + $dateOnly = substr($booking['dato'], 0, 10); + $dateTimeString = $dateOnly . ' ' . $starttid; + $startDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); } // Parse end time $endDateTime = null; - if (!empty($booking['date']) && !empty($booking['sluttid'])) { - $endDateTime = \DateTimeImmutable::createFromFormat( - 'm/d/Y H:i:s.u', - preg_replace('/\.\d+$/', '', $booking['date']) . ' ' . substr($booking['sluttid'], 0, 8) . '.0' - ); + if (!empty($booking['dato']) && !empty($booking['sluttid'])) { + $sluttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['sluttid']); + $dateOnly = substr($booking['dato'], 0, 10); + $dateTimeString = $dateOnly . ' ' . $sluttid; + $endDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); } return [ diff --git a/src/Feed/SourceType/Brnd/ApiClient.php b/src/Feed/SourceType/Brnd/ApiClient.php index f0651aa09..79dbf62d8 100644 --- a/src/Feed/SourceType/Brnd/ApiClient.php +++ b/src/Feed/SourceType/Brnd/ApiClient.php @@ -19,7 +19,7 @@ class ApiClient private const string BOOKINGS_ASSOCIATION_TYPE = 'Sportcenter'; private const int STATUS_CANCELLED = 5; private const int STATUS_ALLOCATED = 4; - + private const int TOKEN_TTL = 1200; /** @var array */ private array $apiClients = []; @@ -29,7 +29,6 @@ public function __construct( private readonly LoggerInterface $logger, ) {} - /** * Retrieve todays bookings from Infomonitor Booking API for a given sportCenterId. * @@ -38,12 +37,22 @@ public function __construct( * * @return array */ - public function getInfomonitorBookingsDetails(FeedSource $feedSource, string $sportCenterId): array + public function getInfomonitorBookingsDetails( + FeedSource $feedSource, + string $sportCenterId + ): array { try { $responseData = $this->getInfomonitorBookingsDetailsData($feedSource, $sportCenterId)->toArray(); - $bookings = $responseData['data']['infoBookingDetails']; + $bookings = []; + if (isset($responseData['data']) && is_array($responseData['data'])) { + foreach ($responseData['data'] as $item) { + if (isset($item['infoBookingDetails']) && is_array($item['infoBookingDetails'])) { + $bookings = array_merge($bookings, $item['infoBookingDetails']); + } + } + } return $bookings; } catch (\Throwable $throwable) { @@ -87,7 +96,7 @@ private function getInfomonitorBookingsDetailsData( $client = $this->getApiClient($feedSource); return $client->request('POST', '/v1.0/get-infomonitor-bookings-details', [ - 'body' => [ + 'json' => [ 'companyID' => $secrets->companyId, 'associationID' => $sportCenterId, 'associationType' => self::BOOKINGS_ASSOCIATION_TYPE, @@ -156,26 +165,24 @@ private function fetchToken(FeedSource $feedSource): string try { $secrets = new SecretsDTO($feedSource); $client = HttpClient::createForBaseUri($secrets->apiBaseUri); - - $response = $client->request('POST', '/v1.0/generate-token', [ + $requestOptions = [ 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => '*/*', ], - 'body' => [ + 'json' => [ 'associationType' => self::AUTH_ASSOCIATION_TYPE, 'apiAuthKey' => $secrets->apiAuthKey, ], - ]); + ]; + $response = $client->request('POST', '/v1.0/generate-token', $requestOptions); - $content = $response->getContent(); + $content = $response->getContent(false); // Don't throw on non-2xx $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); - - $token = $contentDecoded->access_token; + $token = $contentDecoded->data->access_token; // Expire cache 5 min before token expire - $expireSeconds = intval($contentDecoded->expires_in - 300); - + $expireSeconds = intval(self::TOKEN_TTL - 300); $cacheItem->set($token); $cacheItem->expiresAfter($expireSeconds); $this->feedsCache->save($cacheItem); @@ -183,7 +190,6 @@ private function fetchToken(FeedSource $feedSource): string throw new BrndException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); } } - return $token; } } From 5a27c01ab5635b602ba5ba74ae276b4006f0e09a Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Fri, 4 Jul 2025 13:39:00 +0200 Subject: [PATCH 06/13] Better data example --- src/Feed/FeedOutputModels.php | 43 +++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/Feed/FeedOutputModels.php b/src/Feed/FeedOutputModels.php index e34cc96e8..e47dd4ea9 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -62,27 +62,42 @@ class FeedOutputModels */ final public const string RSS_OUTPUT = 'rss'; - /** + /** * Data example: - * * [ * { - * bookingcode: "BKN-363612", - * remarks: "Kinesisk undervisning", - * date: "05/25/2025 00:00:00", - * start: "08:30:00.0000000", - * end: "12:30:00.0000000", - * complex: "Multikulturhuset", - * area: "Mødelokaler", - * facility: "M3.2 - Max 6 personer", - * activity: "Møder", - * team: "", + * activity: "Svømning", + * area: "Svømmehal", + * bookingBy: "Offentlig svømning", + * bookingcode: "BKN-363973", + * changingRooms: "", + * checkIn: "0", + * complex: "Humlehøj Hallen", + * endTime: 1751615100, + * facility: "Svømmehal", + * remarks: "", + * startTime: 1751608800, * status: "Tildelt tid", + * team: "" + * }, + * { + * activity: "Undervisning", + * area: "Mødelokaler", + * bookingBy: "Svømmeklubben Sønderborg", + * bookingcode: "BKN-388946", + * changingRooms: "", * checkIn: "0", - * bookingBy: "Engangsbruger", - * changingRooms: "" + * complex: "Humlehøj Hallen", + * endTime: 1751641200, + * facility: "Mødelokale 1+2", + * remarks: "", + * startTime: 1751630400, + * status: "Tildelt tid", + * team: "" * } * ] + * + * Start/end time are unix timestamps. */ final public const string BRND_BOOKING_OUTPUT = 'brnd-booking'; } \ No newline at end of file From ac26287565c354ef66ea42f8d5fdfadde01bbc34 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Thu, 18 Sep 2025 13:37:04 +0200 Subject: [PATCH 07/13] Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e084301a..59c1bdf1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. - [#260](https://github.com/os2display/display-api-service/pull/260) - Changed how exceptions are handled in InstantBook. +- [#313](https://github.com/os2display/display-api-service/pull/313) + - Add BRND booking feed type ## [2.5.1] - 2025-06-23 From a90f6c731ed5cd5e5cdc9b2cc556c6e02169085e Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Thu, 18 Sep 2025 13:37:57 +0200 Subject: [PATCH 08/13] Update CHANGELOG --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c1bdf1f..983a23e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - +- [#313](https://github.com/os2display/display-api-service/pull/313) + - Add BRND booking feed type + ## [2.5.2] - 2025-09-25 - [#260](https://github.com/os2display/display-api-service/pull/260) - Changed how exceptions are handled in InstantBook. -- [#313](https://github.com/os2display/display-api-service/pull/313) - - Add BRND booking feed type ## [2.5.1] - 2025-06-23 From a56cc6eb731a18a1c4088cbb3ae59d61c6b81b2c Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Thu, 18 Sep 2025 13:43:15 +0200 Subject: [PATCH 09/13] Fix markdownlint errors --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983a23e39..552027526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + - [#313](https://github.com/os2display/display-api-service/pull/313) - Add BRND booking feed type From 64f6dadbb6964b584d4b248819e1cf40f7a70ccb Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Thu, 18 Sep 2025 13:49:13 +0200 Subject: [PATCH 10/13] Fix psalm errors --- src/Feed/BrndFeedType.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 954f654b2..1d6bbdaff 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -70,7 +70,7 @@ public function getData(Feed $feed): array $baseUri = $secrets->apiBaseUri; $sportCenterId = $configuration['sport_center_id'] ?? null; - if (empty($baseUri) || empty($sportCenterId)) { + if ($baseUri === null || $baseUri === '' || $sportCenterId === null || $sportCenterId === '') { return $result; } @@ -91,7 +91,7 @@ private function parseBrndBooking(array $booking): array { // Parse start time $startDateTime = null; - if (!empty($booking['dato']) && !empty($booking['starttid'])) { + if (!empty($booking['dato']) && isset($booking['starttid']) && is_string($booking['starttid'])) { // Trim starttid to 6 digits after dot for microseconds $starttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['starttid']); $dateOnly = substr($booking['dato'], 0, 10); @@ -101,7 +101,7 @@ private function parseBrndBooking(array $booking): array // Parse end time $endDateTime = null; - if (!empty($booking['dato']) && !empty($booking['sluttid'])) { + if (!empty($booking['dato']) && isset($booking['sluttid']) && is_string($booking['sluttid'])) { $sluttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['sluttid']); $dateOnly = substr($booking['dato'], 0, 10); $dateTimeString = $dateOnly . ' ' . $sluttid; From 13597d1cb2d9929aaafea67b7ccdcfc96acda6f6 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Thu, 18 Sep 2025 14:00:42 +0200 Subject: [PATCH 11/13] Fix psalm errors --- src/Feed/BrndFeedType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 1d6bbdaff..e34361dc6 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -70,7 +70,7 @@ public function getData(Feed $feed): array $baseUri = $secrets->apiBaseUri; $sportCenterId = $configuration['sport_center_id'] ?? null; - if ($baseUri === null || $baseUri === '' || $sportCenterId === null || $sportCenterId === '') { + if ($baseUri === '' || $sportCenterId === null || $sportCenterId === '') { return $result; } From fb5d3c0f56896e19ea21021999b7fd22eef7feb5 Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 23 Sep 2025 12:36:41 +0200 Subject: [PATCH 12/13] Correct php-cs-fixer errors --- src/Feed/BrndFeedType.php | 18 +++++------------- src/Feed/FeedOutputModels.php | 2 +- src/Feed/SourceType/Brnd/ApiClient.php | 8 ++++---- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index e34361dc6..a736f0b44 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -6,17 +6,11 @@ use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; -use App\Feed\OutputModel\ConfigOption; use App\Feed\SourceType\Brnd\ApiClient; use App\Feed\SourceType\Brnd\SecretsDTO; use App\Service\FeedService; -use FeedIo\Feed\Item; -use FeedIo\Feed\Node\Category; -use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Uid\Ulid; /** * Brnd Bookingsystem Feed. @@ -70,7 +64,7 @@ public function getData(Feed $feed): array $baseUri = $secrets->apiBaseUri; $sportCenterId = $configuration['sport_center_id'] ?? null; - if ($baseUri === '' || $sportCenterId === null || $sportCenterId === '') { + if ('' === $baseUri || null === $sportCenterId || '' === $sportCenterId) { return $result; } @@ -81,7 +75,7 @@ public function getData(Feed $feed): array } $bookings = $this->apiClient->getInfomonitorBookingsDetails($feedSource, $sportCenterId); - + $result['bookings'] = array_map([$this, 'parseBrndBooking'], $bookings); return $result; @@ -95,7 +89,7 @@ private function parseBrndBooking(array $booking): array // Trim starttid to 6 digits after dot for microseconds $starttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['starttid']); $dateOnly = substr($booking['dato'], 0, 10); - $dateTimeString = $dateOnly . ' ' . $starttid; + $dateTimeString = $dateOnly.' '.$starttid; $startDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); } @@ -104,7 +98,7 @@ private function parseBrndBooking(array $booking): array if (!empty($booking['dato']) && isset($booking['sluttid']) && is_string($booking['sluttid'])) { $sluttid = preg_replace('/\.(\d{6})\d+$/', '.$1', $booking['sluttid']); $dateOnly = substr($booking['dato'], 0, 10); - $dateTimeString = $dateOnly . ' ' . $sluttid; + $dateTimeString = $dateOnly.' '.$sluttid; $endDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); } @@ -186,6 +180,4 @@ public static function getIdKey(FeedSource $feedSource): string return $ulid->toBase32(); } -} - - +} \ No newline at end of file diff --git a/src/Feed/FeedOutputModels.php b/src/Feed/FeedOutputModels.php index e47dd4ea9..af1090139 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -96,7 +96,7 @@ class FeedOutputModels * team: "" * } * ] - * + * * Start/end time are unix timestamps. */ final public const string BRND_BOOKING_OUTPUT = 'brnd-booking'; diff --git a/src/Feed/SourceType/Brnd/ApiClient.php b/src/Feed/SourceType/Brnd/ApiClient.php index 79dbf62d8..f65c76080 100644 --- a/src/Feed/SourceType/Brnd/ApiClient.php +++ b/src/Feed/SourceType/Brnd/ApiClient.php @@ -39,9 +39,8 @@ public function __construct( */ public function getInfomonitorBookingsDetails( FeedSource $feedSource, - string $sportCenterId - ): array - { + string $sportCenterId, + ): array { try { $responseData = $this->getInfomonitorBookingsDetailsData($feedSource, $sportCenterId)->toArray(); @@ -83,7 +82,7 @@ private function getInfomonitorBookingsDetailsData( ?string $date = null, ?string $startTime = null, ?string $endTime = null, - ?array $bookingStatusCodes = null + ?array $bookingStatusCodes = null, ): ResponseInterface { $secrets = new SecretsDTO($feedSource); $defaultStatusCodes = [self::STATUS_ALLOCATED, self::STATUS_CANCELLED]; @@ -190,6 +189,7 @@ private function fetchToken(FeedSource $feedSource): string throw new BrndException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); } } + return $token; } } From e2b4b67d2da45d1b2f4301c6652a6990a6844dca Mon Sep 17 00:00:00 2001 From: Agnete Moos Date: Tue, 23 Sep 2025 13:03:37 +0200 Subject: [PATCH 13/13] Correct php-cs-fixer errors --- src/Feed/BrndFeedType.php | 2 +- src/Feed/FeedOutputModels.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index a736f0b44..26a2fa805 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -180,4 +180,4 @@ public static function getIdKey(FeedSource $feedSource): string return $ulid->toBase32(); } -} \ No newline at end of file +} diff --git a/src/Feed/FeedOutputModels.php b/src/Feed/FeedOutputModels.php index af1090139..b318f75ea 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -100,4 +100,4 @@ class FeedOutputModels * Start/end time are unix timestamps. */ final public const string BRND_BOOKING_OUTPUT = 'brnd-booking'; -} \ No newline at end of file +}