import { authorizeHousekeeping } from "../../../foundation/authorization"; import { fail, type HousekeepingQuery, ok, } from "../../../foundation/contracts"; import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href"; import { type ContentRouteGroup, type ContentRouteId, contentRouteById, contentRouteGroup, } from "../routes"; export interface ContentQueryListInput { readonly search?: string; readonly pageSize?: number; readonly offset?: number; } export interface ContentQueryInput { readonly routeId: ContentRouteId; readonly params?: Readonly>; readonly list?: ContentQueryListInput; } export interface ContentQueryItem { readonly id: string; readonly title: string; readonly status?: string; readonly description?: string; readonly href?: string; readonly updatedAt?: string | null; readonly privatePayload?: unknown; } export interface ContentQueryData { readonly kind: ContentRouteGroup; readonly items: readonly ContentQueryItem[]; readonly total: number; readonly partialDependencies: readonly string[]; } export interface NormalizedContentQueryInput { readonly routeId: ContentRouteId; readonly params: Readonly>; readonly list: Readonly<{ search: string; pageSize: number; offset: number; }>; } export interface ContentQueryAdapters { load(input: NormalizedContentQueryInput): Promise; } 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: ContentQueryInput): NormalizedContentQueryInput { 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: ContentQueryData, input: NormalizedContentQueryInput, ): boolean { if ( data.kind !== contentRouteGroup(input.routeId) || !Array.isArray(data.items) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.partialDependencies) || data.partialDependencies.length !== 0 ) { 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 createContentQuery( adapters: ContentQueryAdapters, ): HousekeepingQuery { return { id: "content.query", owner: "content", capability: contentRouteById("content.editorial.articles").capability, async run(context, input) { const route = contentRouteById(input.routeId); const authorization = authorizeHousekeeping(context, route.capability); if (!authorization.ok) return authorization; const normalized = normalizeInput(input); try { const data = await adapters.load(normalized); if (!isValidData(data, normalized)) { throw new Error("invalid Content query data"); } return ok(data, authorization.correlationId); } catch { return fail( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", authorization.correlationId, ); } }, }; } export const contentQuery = createContentQuery({ async load(input) { const { loadProductionContentQuery } = await import( "./content-queries-production" ); return loadProductionContentQuery(input); }, });