Load rule templates from API

This commit is contained in:
adilallo
2026-04-12 21:56:34 -06:00
parent cae4df261e
commit a39b4aa04b
17 changed files with 698 additions and 429 deletions
+30 -6
View File
@@ -9,16 +9,36 @@ export type RuleTemplateDto = {
category: string | null;
description: string | null;
body: unknown;
sortOrder: number;
featured: boolean;
};
type TemplatesResponse = { templates?: RuleTemplateDto[] };
export async function fetchTemplates(): Promise<
RuleTemplateDto[] | { error: string }
> {
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 }> {
try {
const res = await fetch("/api/templates", { credentials: "include" });
const res = await fetch("/api/templates", {
credentials: "include",
signal: options?.signal,
});
const data = (await res.json()) as TemplatesResponse & { error?: string };
if (!res.ok) {
return {
@@ -29,15 +49,19 @@ export async function fetchTemplates(): Promise<
};
}
return Array.isArray(data.templates) ? data.templates : [];
} catch {
} catch (e) {
if (isAbortError(e)) {
throw e;
}
return { error: "Could not load templates" };
}
}
export async function fetchTemplateBySlug(
slug: string,
options?: FetchTemplatesOptions,
): Promise<RuleTemplateDto | null | { error: string }> {
const result = await fetchTemplates();
const result = await fetchTemplates(options);
if ("error" in result) {
return result;
}
+30
View File
@@ -0,0 +1,30 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
/**
* Curated templates for public list UIs (same query as GET /api/templates).
* Returns [] when the database is not configured or on query failure.
*/
export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
if (!isDatabaseConfigured()) {
return [];
}
try {
return await prisma.ruleTemplate.findMany({
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
select: {
id: true,
slug: true,
title: true,
category: true,
description: true,
body: true,
sortOrder: true,
featured: true,
},
});
} catch {
return [];
}
}
+103
View File
@@ -0,0 +1,103 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { templateSummaryFromBody } from "../create/templateReviewMapping";
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
import {
GOVERNANCE_TEMPLATE_CATALOG,
getGovernanceTemplateCatalogEntry,
governanceTemplateIconPath,
} from "./governanceTemplateCatalog";
/** Matches TemplateReviewCard when slug is absent from the Figma catalog. */
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
iconPath: governanceTemplateIconPath("consensus"),
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
} as const;
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
function presentationForSlug(slug: string): Pick<
GovernanceTemplateCatalogEntry,
"iconPath" | "backgroundColor"
> {
const catalog = getGovernanceTemplateCatalogEntry(slug);
return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
}
/**
* One grid card: API copy + Figma icon/surface from catalog (or fallback).
*/
export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry {
const pres = presentationForSlug(template.slug);
const description = templateSummaryFromBody(template.description, template.body);
return {
slug: template.slug,
title: template.title,
description,
iconPath: pres.iconPath,
backgroundColor: pres.backgroundColor,
};
}
const bySlug = (templates: RuleTemplateDto[]) =>
new Map(templates.map((t) => [t.slug, t] as const));
/**
* Ordered subset for home: follow `slugOrder`; skip missing slugs.
*/
export function gridEntriesForSlugOrder(
templates: RuleTemplateDto[],
slugOrder: readonly string[],
): TemplateGridCardEntry[] {
const map = bySlug(templates);
const out: TemplateGridCardEntry[] = [];
for (const slug of slugOrder) {
const t = map.get(slug);
if (t) out.push(ruleTemplateToGridEntry(t));
}
return out;
}
/**
* Home row: prefer API row per slug; if missing, use static Figma catalog entry.
*/
export function gridEntriesForSlugOrderWithCatalogFallback(
templates: RuleTemplateDto[],
slugOrder: readonly string[],
): TemplateGridCardEntry[] {
const map = bySlug(templates);
const out: TemplateGridCardEntry[] = [];
for (const slug of slugOrder) {
const t = map.get(slug);
if (t) {
out.push(ruleTemplateToGridEntry(t));
continue;
}
const cat = getGovernanceTemplateCatalogEntry(slug);
if (cat) out.push(cat);
}
return out;
}
/**
* Full templates index: `featured` first, then `sortOrder`, then title.
*/
export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] {
const withSort = [...templates].sort((a, b) => {
if (a.featured !== b.featured) return a.featured ? -1 : 1;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
return a.title.localeCompare(b.title);
});
return withSort.map(ruleTemplateToGridEntry);
}
/**
* Marketing `/templates`: use API order when rows exist; otherwise static catalog.
*/
export function gridEntriesForFullCatalogWithFallback(
templates: RuleTemplateDto[],
): TemplateGridCardEntry[] {
if (templates.length === 0) {
return [...GOVERNANCE_TEMPLATE_CATALOG];
}
return gridEntriesForFullCatalog(templates);
}