diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e084301..55202752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ 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) diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php new file mode 100644 index 00000000..26a2fa80 --- /dev/null +++ b/src/Feed/BrndFeedType.php @@ -0,0 +1,183 @@ +feedService->getFeedSourceConfigUrl($feedSource, 'sport-center'); + + return [ + [ + 'key' => 'brnd-sport-center-id', + 'input' => 'input', + 'type' => 'text', + 'name' => 'sport_center_id', + 'label' => 'Sportcenter ID', + 'formGroupClasses' => 'mb-3', + ], + ]; + } + + public function getData(Feed $feed): array + { + $result = [ + 'title' => 'BRND Booking', + 'bookings' => [], + ]; + + $configuration = $feed->getConfiguration(); + $feedSource = $feed->getFeedSource(); + + if (null == $feedSource) { + return $result; + } + + $secrets = new SecretsDTO($feedSource); + + $baseUri = $secrets->apiBaseUri; + $sportCenterId = $configuration['sport_center_id'] ?? null; + + if ('' === $baseUri || null === $sportCenterId || '' === $sportCenterId) { + return $result; + } + + $feedSource = $feed->getFeedSource(); + + if (null === $feedSource) { + return $result; + } + + $bookings = $this->apiClient->getInfomonitorBookingsDetails($feedSource, $sportCenterId); + + $result['bookings'] = array_map([$this, 'parseBrndBooking'], $bookings); + + return $result; + } + + private function parseBrndBooking(array $booking): array + { + // Parse start time + $startDateTime = null; + 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); + $dateTimeString = $dateOnly.' '.$starttid; + $startDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); + } + + // Parse end time + $endDateTime = null; + 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; + $endDateTime = \DateTimeImmutable::createFromFormat('m/d/Y H:i:s.u', $dateTimeString); + } + + return [ + 'bookingcode' => $booking['ansøgning'] ?? '', + 'remarks' => $booking['bemærkninger'] ?? '', + 'startTime' => $startDateTime ? $startDateTime->getTimestamp() : null, + 'endTime' => $endDateTime ? $endDateTime->getTimestamp() : null, + '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'] ?? '', + ]; + } + + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + return null; + } + + public function getRequiredSecrets(): array + { + return [ + 'api_base_uri' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'company_id' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'api_auth_key' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + ]; + } + + public function getRequiredConfiguration(): array + { + return ['sport_center_id']; + } + + 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', + ], + 'company_id' => [ + 'type' => 'string', + ], + 'api_auth_key' => [ + 'type' => 'string', + ], + ], + 'required' => ['api_base_uri', 'company_id', 'api_auth_key'], + ]; + } + + 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 0458c723..b318f75e 100644 --- a/src/Feed/FeedOutputModels.php +++ b/src/Feed/FeedOutputModels.php @@ -61,4 +61,43 @@ class FeedOutputModels * ] */ final public const string RSS_OUTPUT = 'rss'; + + /** + * Data example: + * [ + * { + * 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", + * 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'; } diff --git a/src/Feed/SourceType/Brnd/ApiClient.php b/src/Feed/SourceType/Brnd/ApiClient.php new file mode 100644 index 00000000..f65c7608 --- /dev/null +++ b/src/Feed/SourceType/Brnd/ApiClient.php @@ -0,0 +1,195 @@ + */ + private array $apiClients = []; + + public function __construct( + private readonly CacheItemPoolInterface $feedsCache, + private readonly LoggerInterface $logger, + ) {} + + /** + * Retrieve todays bookings from Infomonitor Booking API for a given sportCenterId. + * + * @param FeedSource $feedSource + * @param string $sportCenterId + * + * @return array + */ + public function getInfomonitorBookingsDetails( + FeedSource $feedSource, + string $sportCenterId, + ): array { + try { + $responseData = $this->getInfomonitorBookingsDetailsData($feedSource, $sportCenterId)->toArray(); + + $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) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * @param FeedSource $feedSource + * @param string $sportCenterId + * @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 getInfomonitorBookingsDetailsData( + FeedSource $feedSource, + string $sportCenterId, + ?string $date = null, + ?string $startTime = null, + ?string $endTime = null, + ?array $bookingStatusCodes = null, + ): ResponseInterface { + $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/get-infomonitor-bookings-details', [ + 'json' => [ + 'companyID' => $secrets->companyId, + 'associationID' => $sportCenterId, + 'associationType' => self::BOOKINGS_ASSOCIATION_TYPE, + 'date' => $date, + 'startTime' => $startTime, + 'endTime' => $endTime, + 'statusID' => $bookingStatusCodes, + ], + ]); + } 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); + $requestOptions = [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => '*/*', + ], + 'json' => [ + 'associationType' => self::AUTH_ASSOCIATION_TYPE, + 'apiAuthKey' => $secrets->apiAuthKey, + ], + ]; + $response = $client->request('POST', '/v1.0/generate-token', $requestOptions); + + $content = $response->getContent(false); // Don't throw on non-2xx + $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + $token = $contentDecoded->data->access_token; + + // Expire cache 5 min before token expire + $expireSeconds = intval(self::TOKEN_TTL - 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 00000000..d5cbd67b --- /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['company_id'], $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->companyId = $secrets['company_id']; + $this->apiAuthKey = $secrets['api_auth_key']; + } +}