Files
EpicNext-Cms/src/features/housekeeping/domains/hotel/queries/rooms.ts
T
Simo 43470ebf82
CI / check (pull_request) Failing after 10s
CI / deploy (pull_request) Skipped
CI / release (pull_request) Skipped
feat(housekeeping): unify complete rebuild in gated preview
2026-08-31 20:37:51 +02:00

159 lines
4.3 KiB
TypeScript

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<Record<string, string>>;
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<Record<string, string>>;
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
}
export interface HotelQueryAdapters {
load(input: NormalizedHotelQueryInput): Promise<HotelQueryData>;
}
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<Record<HotelPrimaryRouteId, ReadonlySet<string>>>);
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<HotelQueryInput, HotelQueryData> {
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<HotelQueryData> {
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);
},
});