Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/content/queries/content-queries.ts
T

150 lines
3.8 KiB
TypeScript

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<Record<string, string>>;
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<Record<string, string>>;
readonly list: Readonly<{
search: string;
pageSize: number;
offset: number;
}>;
}
export interface ContentQueryAdapters {
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
}
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<ContentQueryInput, ContentQueryData> {
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);
},
});