Files
community-rule/lib/create/fetchTemplates.ts
T
2026-05-22 14:32:15 -06:00

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;
}