import { authorizeHousekeeping } from "../../../foundation/authorization"; import { fail, type HousekeepingQuery, ok, } from "../../../foundation/contracts"; import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href"; import { type HotelPrimaryRouteId, type HotelRouteGroup, hotelRouteById, hotelRouteGroup, } from "../routes"; export interface HotelQueryInput { readonly routeId: HotelPrimaryRouteId; readonly params?: Readonly>; readonly list?: Readonly<{ search?: string; pageSize?: number; offset?: number; }>; } export interface HotelQueryItem { readonly id: string; readonly title: string; readonly status?: string; readonly href?: string; readonly description?: string; readonly updatedAt?: string | null; } export interface HotelQueryData { readonly kind: HotelRouteGroup; readonly items: readonly HotelQueryItem[]; readonly total: number; readonly partialDependencies: readonly string[]; } export interface NormalizedHotelQueryInput { readonly routeId: HotelPrimaryRouteId; readonly params: Readonly>; readonly list: Readonly<{ search: string; pageSize: number; offset: number }>; } export interface HotelQueryAdapters { load(input: NormalizedHotelQueryInput): Promise; } const ALLOWED_PARTIAL_DEPENDENCIES = Object.freeze({ "hotel.room-detail": new Set(["rcon"]), "hotel.room-furni": new Set(["rcon"]), "hotel.navigation": new Set(["navigator-publics"]), "hotel.radio.monitoring": new Set(["radio"]), } as Partial>>); function boundedInteger( value: unknown, fallback: number, minimum: number, maximum: number, ): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed)) return fallback; return Math.min(maximum, Math.max(minimum, parsed)); } function normalizeInput(input: HotelQueryInput): NormalizedHotelQueryInput { return { routeId: input.routeId, params: Object.fromEntries( Object.entries(input.params ?? {}).map(([key, value]) => [ key.normalize("NFC").trim().slice(0, 128), value.normalize("NFC").trim().slice(0, 128), ]), ), list: { search: String(input.list?.search ?? "") .normalize("NFC") .trim() .slice(0, 128), pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100), offset: boundedInteger(input.list?.offset, 0, 0, 100_000), }, }; } function isValidData( data: HotelQueryData, input: NormalizedHotelQueryInput, ): boolean { if ( data.kind !== hotelRouteGroup(input.routeId) || !Array.isArray(data.items) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.partialDependencies) ) { return false; } const allowed = ALLOWED_PARTIAL_DEPENDENCIES[input.routeId] ?? new Set(); if (data.partialDependencies.some((dependency) => !allowed.has(dependency))) { return false; } return data.items.every( (item) => typeof item.id === "string" && item.id.length > 0 && typeof item.title === "string" && item.title.length > 0 && (item.href === undefined || isSafeHousekeepingHref(item.href)), ); } export function createHotelQuery( adapters: HotelQueryAdapters, ): HousekeepingQuery { return { id: "hotel.query", owner: "hotel", capability: hotelRouteById("hotel.rooms").capability, async run(context, rawInput) { const route = hotelRouteById(rawInput.routeId); const authorization = authorizeHousekeeping(context, route.capability); if (!authorization.ok) return authorization; const input = normalizeInput(rawInput); try { const data = await adapters.load(input); if (!isValidData(data, input)) throw new Error("invalid Hotel query"); return ok(data, authorization.correlationId); } catch { return fail( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", authorization.correlationId, ); } }, }; } export async function loadHotelRoomsQuery( input: NormalizedHotelQueryInput, ): Promise { const { loadHotelDatabaseQuery } = await import("./hotel-production"); return loadHotelDatabaseQuery(input); } export const hotelQuery = createHotelQuery({ async load(input) { if (input.routeId.startsWith("hotel.radio.")) { const { loadHotelRadioQuery } = await import("./radio"); return loadHotelRadioQuery(input); } return loadHotelRoomsQuery(input); }, });