191 lines
5.2 KiB
TypeScript
191 lines
5.2 KiB
TypeScript
/**
|
|
* Client fetch for curated rule templates (GET /api/templates).
|
|
*/
|
|
|
|
export type RuleTemplateDto = {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
category: string | null;
|
|
description: string | null;
|
|
body: unknown;
|
|
sortOrder: number;
|
|
featured: boolean;
|
|
};
|
|
|
|
type TemplatesResponse = {
|
|
templates?: RuleTemplateDto[];
|
|
scores?: Record<string, TemplateFacetScoreDto>;
|
|
};
|
|
|
|
function parseScoresPayload(
|
|
raw: unknown,
|
|
): Record<string, TemplateFacetScoreDto> {
|
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
return raw as Record<string, TemplateFacetScoreDto>;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async function getTemplatesJson(
|
|
queryString: string,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<
|
|
| { ok: true; data: TemplatesResponse & { error?: string }; statusOk: boolean }
|
|
| { ok: false; error: "network" | "aborted" }
|
|
> {
|
|
const url =
|
|
queryString.length > 0 ? `/api/templates?${queryString}` : "/api/templates";
|
|
try {
|
|
const res = await fetch(url, {
|
|
credentials: "include",
|
|
signal,
|
|
});
|
|
const data = (await res.json()) as TemplatesResponse & { error?: string };
|
|
return { ok: true, data, statusOk: res.ok };
|
|
} catch (e) {
|
|
if (isAbortError(e)) {
|
|
return { ok: false, error: "aborted" };
|
|
}
|
|
return { ok: false, error: "network" };
|
|
}
|
|
}
|
|
|
|
/** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */
|
|
export type TemplateFacetScoreDto = {
|
|
score: number;
|
|
matchedFacets: string[];
|
|
};
|
|
|
|
export type RankedTemplatesFetchResult = {
|
|
templates: RuleTemplateDto[];
|
|
scores: Record<string, TemplateFacetScoreDto>;
|
|
};
|
|
|
|
export type FetchTemplatesOptions = {
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
function isAbortError(e: unknown): boolean {
|
|
return (
|
|
(e instanceof DOMException && e.name === "AbortError") ||
|
|
(e instanceof Error && e.name === "AbortError")
|
|
);
|
|
}
|
|
|
|
/** For callers that `catch` around `fetchTemplates` / `fetchTemplateBySlug`. */
|
|
export function isTemplatesFetchAborted(e: unknown): boolean {
|
|
return isAbortError(e);
|
|
}
|
|
|
|
export async function fetchTemplates(
|
|
options?: FetchTemplatesOptions,
|
|
): Promise<RuleTemplateDto[] | { error: string }> {
|
|
const got = await getTemplatesJson("", options?.signal);
|
|
if (got.ok === false) {
|
|
if (got.error === "aborted") {
|
|
throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
return { error: "Could not load templates" };
|
|
}
|
|
const { data, statusOk } = got;
|
|
if (!statusOk) {
|
|
return {
|
|
error:
|
|
typeof data.error === "string"
|
|
? data.error
|
|
: "Could not load templates",
|
|
};
|
|
}
|
|
return Array.isArray(data.templates) ? data.templates : [];
|
|
}
|
|
|
|
/**
|
|
* Facet-ranked list + per-template scores (CR-88). Query must be non-empty
|
|
* `facet.size=…&…` from {@link buildFacetQueryString}.
|
|
*/
|
|
export async function fetchRankedTemplatesByFacets(options: {
|
|
facetQuery: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<RankedTemplatesFetchResult | { error: string }> {
|
|
if (options.facetQuery.length === 0) {
|
|
return { error: "Could not load templates" };
|
|
}
|
|
const got = await getTemplatesJson(options.facetQuery, options.signal);
|
|
if (got.ok === false) {
|
|
if (got.error === "aborted") {
|
|
throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
return { error: "Could not load templates" };
|
|
}
|
|
const { data, statusOk } = got;
|
|
if (!statusOk) {
|
|
return {
|
|
error:
|
|
typeof data.error === "string"
|
|
? data.error
|
|
: "Could not load templates",
|
|
};
|
|
}
|
|
const templates = Array.isArray(data.templates) ? data.templates : [];
|
|
return { templates, scores: parseScoresPayload(data.scores) };
|
|
}
|
|
|
|
export type TemplateDetailDto = {
|
|
template: RuleTemplateDto;
|
|
methods: Array<{ section: string; slug: string }>;
|
|
};
|
|
|
|
export async function fetchTemplateDetailBySlug(
|
|
slug: string,
|
|
options?: FetchTemplatesOptions,
|
|
): Promise<TemplateDetailDto | null | { error: string }> {
|
|
const encoded = encodeURIComponent(slug);
|
|
try {
|
|
const res = await fetch(`/api/templates/${encoded}`, {
|
|
credentials: "include",
|
|
signal: options?.signal,
|
|
});
|
|
const data = (await res.json()) as TemplateDetailDto & {
|
|
error?: string | { message?: string };
|
|
};
|
|
if (!res.ok) {
|
|
if (res.status === 404) return null;
|
|
const err = data.error;
|
|
const message =
|
|
typeof err === "string"
|
|
? err
|
|
: err && typeof err === "object" && typeof err.message === "string"
|
|
? err.message
|
|
: "Could not load template";
|
|
return { error: message };
|
|
}
|
|
if (!data.template || typeof data.template.slug !== "string") {
|
|
return { error: "Could not load template" };
|
|
}
|
|
return {
|
|
template: data.template,
|
|
methods: Array.isArray(data.methods) ? data.methods : [],
|
|
};
|
|
} catch (e) {
|
|
if (isAbortError(e)) {
|
|
throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
return { error: "Could not load template" };
|
|
}
|
|
}
|
|
|
|
export async function fetchTemplateBySlug(
|
|
slug: string,
|
|
options?: FetchTemplatesOptions,
|
|
): Promise<RuleTemplateDto | null | { error: string }> {
|
|
const result = await fetchTemplateDetailBySlug(slug, options);
|
|
if ("error" in result) {
|
|
return result;
|
|
}
|
|
if (result === null) {
|
|
return null;
|
|
}
|
|
return result.template;
|
|
}
|