feat(housekeeping): deliver hotel operations
This commit is contained in:
1 parent
bfc5bd78a1
commit
abc707cfb2
43 files changed
+3749
-615
No files matched your search
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user