diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index a326c4e..4f98f3e 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -15,7 +15,16 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import CreateFlowFooter from "../../components/navigation/CreateFlowFooter"; import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav"; -import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps"; +import { + getNextStep, + getStepIndex, + parseReviewReturnSearchParam, + CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, + TEMPLATES_FACET_RECOMMEND_QUERY, + TEMPLATES_FACET_RECOMMEND_VALUE, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, +} from "./utils/flowSteps"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { createFlowStepUsesCenteredTextLayout, @@ -600,7 +609,9 @@ function CreateFlowLayoutContent({ // detour. Direct entries to `/templates` (no marker) and // home "Popular templates" clicks always start fresh by // wiping anonymous draft storage at click time. - router.push("/templates?fromFlow=1"); + router.push( + `/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`, + ); }} > {footer.createFromTemplate} diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts index 7da4f3a..950f6c7 100644 --- a/app/(app)/create/hooks/useFacetRecommendations.ts +++ b/app/(app)/create/hooks/useFacetRecommendations.ts @@ -1,10 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import facetGroups from "../../../../data/create/customRule/_facetGroups.json"; -import { - type CreateFlowState, -} from "../types"; +import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; import { useCreateFlow } from "../context/CreateFlowContext"; /** @@ -16,60 +13,6 @@ export type RecommendationSection = | "decisionApproaches" | "conflictManagement"; -const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const; -type FacetGroupId = (typeof FACET_GROUPS)[number]; - -/** Reverse map chipId → canonical facet value id, per group. */ -const CHIP_TO_VALUE_BY_GROUP: Record> = (() => { - const out: Record> = { - size: {}, - orgType: {}, - scale: {}, - maturity: {}, - }; - for (const group of FACET_GROUPS) { - const block = (facetGroups as Record)[group]; - if (block && typeof block === "object" && "values" in block) { - const values = (block as { values: Record }) - .values; - for (const [valueId, entry] of Object.entries(values)) { - out[group][entry.chipId] = valueId; - } - } - } - return out; -})(); - -/** Chip-id state accessors per group. */ -const STATE_KEY_BY_GROUP: Record = { - size: "selectedCommunitySizeIds", - orgType: "selectedOrganizationTypeIds", - scale: "selectedScaleIds", - maturity: "selectedMaturityIds", -}; - -function readChipIds( - state: CreateFlowState, - group: FacetGroupId, -): string[] { - const value = state[STATE_KEY_BY_GROUP[group]]; - return Array.isArray(value) ? (value as string[]) : []; -} - -function buildFacetQuery(state: CreateFlowState): string { - const params = new URLSearchParams(); - for (const group of FACET_GROUPS) { - const valuesById = CHIP_TO_VALUE_BY_GROUP[group]; - for (const chipId of readChipIds(state, group)) { - const valueId = valuesById[chipId]; - if (valueId) { - params.append(`facet.${group}`, valueId); - } - } - } - return params.toString(); -} - export type FacetRecommendationsResult = { /** `true` once the network call completes (or short-circuits with no facets). */ isReady: boolean; @@ -99,7 +42,10 @@ export function useFacetRecommendations( section: RecommendationSection, ): FacetRecommendationsResult { const { state } = useCreateFlow(); - const queryString = useMemo(() => buildFacetQuery(state), [state]); + const queryString = useMemo( + () => buildFacetQueryString(state), + [state], + ); const hasAnyFacets = queryString.length > 0; const [result, setResult] = useState({ diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index 341d7fc..f68fa6d 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -153,6 +153,14 @@ export function parseCreateFlowScreenFromPathname( export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; +/** + * Only set from `/create/review` “Create from template” with `fromFlow=1`. + * Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit + * on profile and marketing so stale localStorage facets never show badges. + */ +export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const; +export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const; + /** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx index 0d292d0..5030775 100644 --- a/app/(marketing)/templates/TemplatesPageClient.tsx +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -6,8 +6,13 @@ import HeaderLockup from "../../components/type/HeaderLockup"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry"; -import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps"; +import { + buildTemplateReviewHref, + TEMPLATES_FACET_RECOMMEND_QUERY, + TEMPLATES_FACET_RECOMMEND_VALUE, +} from "../../(app)/create/utils/flowSteps"; import { useTranslation } from "../../contexts/MessagesContext"; +import { useTemplatesFacetGridEntries } from "./useTemplatesFacetGridEntries"; export interface TemplatesPageClientProps { initialGridEntries: TemplateGridCardEntry[]; @@ -44,9 +49,16 @@ export default function TemplatesPageClient({ {/* Suspense boundary required by `useSearchParams` below (Next.js 15+ static-generation contract). */} } + fallback={ + + } > - + @@ -55,18 +67,25 @@ export default function TemplatesPageClient({ } /** - * Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when - * the user arrived from `/create/review`'s "Create from template" button. - * That button pushes `/templates?fromFlow=1` so their in-progress community - * stage is preserved when they pick a template here. + * - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click + * (draft preserved). Used by review “Create from template” and profile. + * - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED” + * from `GET /api/templates?facet.*` using the persisted community draft. */ function TemplatesGridWithSearchParams({ - entries, + initialGridEntries, }: { - entries: TemplateGridCardEntry[]; + initialGridEntries: TemplateGridCardEntry[]; }) { const searchParams = useSearchParams(); const fromFlow = searchParams.get("fromFlow") === "1"; + const enableFacetRecommendations = + searchParams.get(TEMPLATES_FACET_RECOMMEND_QUERY) === + TEMPLATES_FACET_RECOMMEND_VALUE; + const entries = useTemplatesFacetGridEntries({ + initialGridEntries, + enableFacetRecommendations, + }); return ; } diff --git a/app/(marketing)/templates/useTemplatesFacetGridEntries.ts b/app/(marketing)/templates/useTemplatesFacetGridEntries.ts new file mode 100644 index 0000000..27cadd0 --- /dev/null +++ b/app/(marketing)/templates/useTemplatesFacetGridEntries.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { readAnonymousCreateFlowState } from "../../(app)/create/utils/anonymousDraftStorage"; +import { buildFacetQueryString } from "../../../lib/create/buildFacetQueryString"; +import { + fetchRankedTemplatesByFacets, + isTemplatesFetchAborted, +} from "../../../lib/create/fetchTemplates"; +import { + gridEntriesWithFacetScores, + type TemplateGridCardEntry, +} from "../../../lib/templates/templateGridPresentation"; + +type UseTemplatesFacetGridEntriesArgs = { + initialGridEntries: TemplateGridCardEntry[]; + enableFacetRecommendations: boolean; +}; + +/** + * When `enableFacetRecommendations` (review → “Create from template” only), + * re-fetch ranked templates from `GET /api/templates?facet.*` using the + * persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR. + */ +export function useTemplatesFacetGridEntries({ + initialGridEntries, + enableFacetRecommendations, +}: UseTemplatesFacetGridEntriesArgs): TemplateGridCardEntry[] { + const [entries, setEntries] = useState(initialGridEntries); + + useEffect(() => { + if (!enableFacetRecommendations) { + setEntries(initialGridEntries); + return; + } + const state = readAnonymousCreateFlowState(); + const facetQuery = buildFacetQueryString(state); + if (facetQuery.length === 0) { + setEntries(initialGridEntries); + return; + } + + const ac = new AbortController(); + void (async () => { + try { + const result = await fetchRankedTemplatesByFacets({ + signal: ac.signal, + facetQuery, + }); + if (ac.signal.aborted) return; + if ("error" in result) { + setEntries(initialGridEntries); + return; + } + setEntries( + gridEntriesWithFacetScores(result.templates, result.scores), + ); + } catch (e) { + if (isTemplatesFetchAborted(e)) return; + setEntries(initialGridEntries); + } + })(); + + return () => { + ac.abort(); + }; + }, [enableFacetRecommendations, initialGridEntries]); + + return entries; +} diff --git a/data/templates/templateFacet.json b/data/templates/templateFacet.json new file mode 100644 index 0000000..bd211c1 --- /dev/null +++ b/data/templates/templateFacet.json @@ -0,0 +1,91 @@ +{ + "consensus": { + "size": ["oneMember", "twoToFive", "sixToTwelve"], + "orgType": ["dao", "openSource", "mutualAid", "workersCoop"], + "scale": ["national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "consensus-clusters": { + "size": ["sixToTwelve", "thirteenToOneHundred", "oneHundredToOneHundredK"], + "orgType": ["dao", "openSource", "mutualAid"], + "scale": ["global", "national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "solidarity-network": { + "size": ["oneMember"], + "orgType": ["dao", "openSource", "workersCoop"], + "scale": ["national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established"] + }, + "sortition-jury": { + "size": [], + "orgType": [], + "scale": ["national", "regional", "local"], + "maturity": ["growthStage", "established", "enterprise"] + }, + "liquid-democracy": { + "size": ["thirteenToOneHundred", "oneHundredToOneHundredK"], + "orgType": ["dao", "openSource"], + "scale": ["national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "do-ocracy": { + "size": ["oneMember"], + "orgType": ["dao", "openSource", "workersCoop"], + "scale": ["national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established"] + }, + "quadratic-governance": { + "size": [ + "twoToFive", + "sixToTwelve", + "thirteenToOneHundred", + "oneHundredToOneHundredK" + ], + "orgType": ["dao", "openSource", "workersCoop"], + "scale": ["global", "national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "federated-clusters": { + "size": ["sixToTwelve", "thirteenToOneHundred"], + "orgType": ["dao", "openSource", "mutualAid", "workersCoop"], + "scale": ["national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "devolution": { + "size": ["oneMember"], + "orgType": ["forProfit", "nonprofit"], + "scale": ["global", "national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "benevolent-dictator": { + "size": ["oneMember"], + "orgType": ["forProfit", "nonprofit", "openSource"], + "scale": ["global", "national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established"] + }, + "petition": { + "size": ["thirteenToOneHundred", "oneHundredToOneHundredK"], + "orgType": ["dao", "openSource", "workersCoop"], + "scale": ["global", "national", "regional"], + "maturity": ["growthStage", "established", "enterprise"] + }, + "self-appointed-board": { + "size": [ + "oneMember", + "twoToFive", + "sixToTwelve", + "thirteenToOneHundred", + "oneHundredToOneHundredK" + ], + "orgType": ["dao", "forProfit", "nonprofit", "openSource"], + "scale": ["global", "national", "regional", "local"], + "maturity": ["earlyStage", "growthStage", "established", "enterprise"] + }, + "elected-board": { + "size": ["oneHundredToOneHundredK"], + "orgType": ["dao", "forProfit", "nonprofit"], + "scale": ["national", "regional", "local"], + "maturity": ["growthStage", "established", "enterprise"] + } +} diff --git a/lib/create/buildFacetQueryString.ts b/lib/create/buildFacetQueryString.ts new file mode 100644 index 0000000..cafee44 --- /dev/null +++ b/lib/create/buildFacetQueryString.ts @@ -0,0 +1,60 @@ +import facetGroups from "../../data/create/customRule/_facetGroups.json"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const; +type FacetGroupId = (typeof FACET_GROUPS)[number]; + +const CHIP_TO_VALUE_BY_GROUP: Record> = + (() => { + const out: Record> = { + size: {}, + orgType: {}, + scale: {}, + maturity: {}, + }; + for (const group of FACET_GROUPS) { + const block = (facetGroups as Record)[group]; + if (block && typeof block === "object" && "values" in block) { + const values = (block as { values: Record }) + .values; + for (const [valueId, entry] of Object.entries(values)) { + out[group][entry.chipId] = valueId; + } + } + } + return out; + })(); + +const STATE_KEY_BY_GROUP: Record = { + size: "selectedCommunitySizeIds", + orgType: "selectedOrganizationTypeIds", + scale: "selectedScaleIds", + maturity: "selectedMaturityIds", +}; + +function readChipIds( + state: CreateFlowState, + group: FacetGroupId, +): string[] { + const value = state[STATE_KEY_BY_GROUP[group]]; + return Array.isArray(value) ? (value as string[]) : []; +} + +/** + * Build `facet.size=…&facet.orgType=…` query string from Create Community + * chip selections. Shared by `/api/create-flow/methods` and + * `GET /api/templates` ranking (CR-88). + */ +export function buildFacetQueryString(state: CreateFlowState): string { + const params = new URLSearchParams(); + for (const group of FACET_GROUPS) { + const valuesById = CHIP_TO_VALUE_BY_GROUP[group]; + for (const chipId of readChipIds(state, group)) { + const valueId = valuesById[chipId]; + if (valueId) { + params.append(`facet.${group}`, valueId); + } + } + } + return params.toString(); +} diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts index 3d2787e..9d6ef1e 100644 --- a/lib/create/fetchTemplates.ts +++ b/lib/create/fetchTemplates.ts @@ -13,7 +13,21 @@ export type RuleTemplateDto = { featured: boolean; }; -type TemplatesResponse = { templates?: RuleTemplateDto[] }; +type TemplatesResponse = { + templates?: RuleTemplateDto[]; + scores?: Record; +}; + +/** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */ +export type TemplateFacetScoreDto = { + score: number; + matchedFacets: string[]; +}; + +export type RankedTemplatesFetchResult = { + templates: RuleTemplateDto[]; + scores: Record; +}; export type FetchTemplatesOptions = { signal?: AbortSignal; @@ -57,6 +71,46 @@ export async function fetchTemplates( } } +/** + * 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 { + if (options.facetQuery.length === 0) { + return { error: "Could not load templates" }; + } + try { + const res = await fetch(`/api/templates?${options.facetQuery}`, { + credentials: "include", + signal: options.signal, + }); + const data = (await res.json()) as TemplatesResponse & { error?: string }; + if (!res.ok) { + return { + error: + typeof data.error === "string" + ? data.error + : "Could not load templates", + }; + } + const templates = Array.isArray(data.templates) ? data.templates : []; + const raw = data.scores; + const scores: Record = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + return { templates, scores }; + } catch (e) { + if (isAbortError(e)) { + throw e; + } + return { error: "Could not load templates" }; + } +} + export async function fetchTemplateBySlug( slug: string, options?: FetchTemplatesOptions, diff --git a/lib/server/methodRecommendations.ts b/lib/server/methodRecommendations.ts index d4b91c9..50e73c5 100644 --- a/lib/server/methodRecommendations.ts +++ b/lib/server/methodRecommendations.ts @@ -169,3 +169,72 @@ export async function scoreTemplatesByFacets(args: { return null; } } + +/** + * Slugs that have at least one `TemplateFacet` row (Template Composition + * matrix, cols G–Y) — use {@link scoreTemplatesByTemplateFacets} for these; + * others use {@link scoreTemplatesByFacets}. + */ +export async function getTemplateFacetSlugSet(): Promise | null> { + if (!isDatabaseConfigured()) return null; + try { + const rows = await prisma.templateFacet.findMany({ + where: { matches: true }, + distinct: ["templateSlug"], + select: { templateSlug: true }, + }); + return new Set(rows.map((r) => r.templateSlug)); + } catch { + return null; + } +} + +/** + * Per-template score from the `TemplateFacet` table: one point per + * user-requested `(group, value)` that exists for that `templateSlug`. + * Same counting semantics as the pre-DB matrix JSON path. + */ +export async function scoreTemplatesByTemplateFacets(args: { + templateSlugs: ReadonlyArray; + facets: RequestedFacets; +}): Promise { + if (!isDatabaseConfigured()) return null; + const requested = flattenRequestedFacets(args.facets); + if (requested.length === 0) { + return args.templateSlugs.map((templateSlug) => ({ + templateSlug, + score: 0, + matchedFacets: [] as string[], + })); + } + if (args.templateSlugs.length === 0) { + return []; + } + + try { + const rows = await prisma.templateFacet.findMany({ + where: { + matches: true, + templateSlug: { in: [...args.templateSlugs] }, + OR: requested.map(({ group, value }) => ({ group, value })), + }, + select: { templateSlug: true, group: true, value: true }, + }); + + const pairSet = new Set( + rows.map((r) => `${r.templateSlug}\0${r.group}\0${r.value}` as const), + ); + + return args.templateSlugs.map((templateSlug) => { + const matched: string[] = []; + for (const { group, value } of requested) { + if (pairSet.has(`${templateSlug}\0${group}\0${value}`)) { + matched.push(`${group}:${value}`); + } + } + return { templateSlug, score: matched.length, matchedFacets: matched }; + }); + } catch { + return null; + } +} diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts index 3d74c76..1997b9d 100644 --- a/lib/server/ruleTemplates.ts +++ b/lib/server/ruleTemplates.ts @@ -1,7 +1,11 @@ import type { RuleTemplateDto } from "../create/fetchTemplates"; import { prisma } from "./db"; import { isDatabaseConfigured } from "./env"; -import { scoreTemplatesByFacets } from "./methodRecommendations"; +import { + getTemplateFacetSlugSet, + scoreTemplatesByFacets, + scoreTemplatesByTemplateFacets, +} from "./methodRecommendations"; import { templateMethodsFromBody } from "./templateMethods"; import type { RequestedFacets } from "./validation/methodFacetsSchemas"; import { flattenRequestedFacets } from "./validation/methodFacetsSchemas"; @@ -53,8 +57,11 @@ export type RankedTemplatesResult = { }; /** - * Curated templates ranked by how many of `facets` each composed method - * matches (§9.1). When `facets` is empty, returns the curated ordering with + * Curated templates ranked by facet match. Templates with a row in + * `TemplateFacet` (seeded from `data/templates/templateFacet.json`, Template + * Composition-2, cols G–Y) use that matrix; others fall back to + * composed-method × `MethodFacet` + * scoring (§9.1). When `facets` is empty, returns the curated ordering with * an empty `scores` map (caller can omit it from the API response). * * Ties (and zero-score templates) fall back to the curated @@ -83,22 +90,51 @@ export async function listRankedRuleTemplatesFromDb( return { templates: [], scores: {} }; } + const slugs = templates.map((t) => t.slug); const templateMethods = templates.map((t) => ({ templateSlug: t.slug, methods: templateMethodsFromBody(t.body), })); - const ranked = await scoreTemplatesByFacets({ templateMethods, facets }); - if (!ranked) { + const [matrixRanked, facetSlugSet, methodRanked] = await Promise.all([ + scoreTemplatesByTemplateFacets({ templateSlugs: slugs, facets }), + getTemplateFacetSlugSet(), + scoreTemplatesByFacets({ templateMethods, facets }), + ]); + + if (!methodRanked) { return { templates, scores: {} }; } + const matrixBySlug = + matrixRanked == null + ? new Map() + : new Map(matrixRanked.map((r) => [r.templateSlug, r] as const)); + const methodBySlug = new Map( + methodRanked.map((r) => [r.templateSlug, r] as const), + ); + const scores: Record = {}; - for (const r of ranked) { - scores[r.templateSlug] = { - score: r.score, - matchedFacets: r.matchedFacets, - }; + for (const t of templates) { + const useMatrix = + matrixRanked != null && (facetSlugSet?.has(t.slug) ?? false); + if (useMatrix) { + const m = matrixBySlug.get(t.slug); + if (m) { + scores[t.slug] = { + score: m.score, + matchedFacets: m.matchedFacets, + }; + } + } else { + const m = methodBySlug.get(t.slug); + if (m) { + scores[t.slug] = { + score: m.score, + matchedFacets: m.matchedFacets, + }; + } + } } // Stable sort: scoreDesc, then preserve curated index order. diff --git a/lib/server/validation/templateFacetSchema.ts b/lib/server/validation/templateFacetSchema.ts new file mode 100644 index 0000000..2d1ea33 --- /dev/null +++ b/lib/server/validation/templateFacetSchema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { + MATURITY_VALUE_IDS, + ORG_TYPE_VALUE_IDS, + SCALE_VALUE_IDS, + SIZE_VALUE_IDS, +} from "./methodFacetsSchemas"; + +const sizeValueIdSchema = z.enum(SIZE_VALUE_IDS); +const orgTypeValueIdSchema = z.enum(ORG_TYPE_VALUE_IDS); +const scaleValueIdSchema = z.enum(SCALE_VALUE_IDS); +const maturityValueIdSchema = z.enum(MATURITY_VALUE_IDS); + +/** + * Per-template row for Template Composition-2 (spreadsheet cols G–Y). Each + * array lists canonical facet `value` ids that are a fit (✓) for that + * community dimension. + */ +export const templateFacetRowSchema = z + .object({ + size: z.array(sizeValueIdSchema), + orgType: z.array(orgTypeValueIdSchema), + scale: z.array(scaleValueIdSchema), + maturity: z.array(maturityValueIdSchema), + }) + .strict(); + +export const templateFacetFileSchema = z.record( + z.string().min(1), + templateFacetRowSchema, +); + +export type TemplateFacetFile = z.infer; diff --git a/lib/templates/templateGridPresentation.ts b/lib/templates/templateGridPresentation.ts index 5b18182..e29ce84 100644 --- a/lib/templates/templateGridPresentation.ts +++ b/lib/templates/templateGridPresentation.ts @@ -1,4 +1,4 @@ -import type { RuleTemplateDto } from "../create/fetchTemplates"; +import type { RuleTemplateDto, TemplateFacetScoreDto } from "../create/fetchTemplates"; import { templateSummaryFromBody } from "../create/templateReviewMapping"; import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog"; import { @@ -47,6 +47,70 @@ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGrid }; } +/** + * Max templates that show the “RECOMMENDED” tag when facet-ranked. Within the + * **top score tier** only: we do not pad with lower-scoring templates (e.g. two + * at score 4 and three at 3 → recommend the two 4s only), but if the top tier + * exceeds this cap we still take the first `limit` in API order. + */ +export const TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT = 5; + +/** + * Among templates in **API rank order** (score desc) with `score > 0`, mark + * only those in the **maximum-score tier** (no lower tiers), at most `limit` + * (API order is the tie-break when many tie for first place). + */ +export function deriveRecommendedTemplateSlugs( + templatesInRankOrder: ReadonlyArray<{ slug: string }>, + scores: Record, + limit: number, +): Set { + if (limit <= 0) return new Set(); + const matched = templatesInRankOrder.filter( + (t) => (scores[t.slug]?.score ?? 0) > 0, + ); + if (matched.length === 0) return new Set(); + let maxScore = 0; + for (const t of matched) { + const s = scores[t.slug]?.score ?? 0; + if (s > maxScore) maxScore = s; + } + const topTier = matched.filter( + (t) => (scores[t.slug]?.score ?? 0) === maxScore, + ); + return new Set(topTier.slice(0, limit).map((t) => t.slug)); +} + +export type GridEntriesWithFacetScoresOptions = { + /** Default {@link TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT}. */ + compactRecommendedLimit?: number; +}; + +/** + * After `GET /api/templates?facet.*` with `scores`, mark `recommended` only + * for the top facet matches (see {@link deriveRecommendedTemplateSlugs}). + */ +export function gridEntriesWithFacetScores( + templates: RuleTemplateDto[], + scores: Record, + options?: GridEntriesWithFacetScoresOptions, +): TemplateGridCardEntry[] { + const cap = + options?.compactRecommendedLimit ?? TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT; + const recommendedSlugs = deriveRecommendedTemplateSlugs( + templates, + scores, + cap, + ); + return templates.map((t) => { + const base = ruleTemplateToGridEntry(t); + return { + ...base, + recommended: recommendedSlugs.has(t.slug), + }; + }); +} + const bySlug = (templates: RuleTemplateDto[]) => new Map(templates.map((t) => [t.slug, t] as const)); diff --git a/prisma/migrations/20260429000000_add_template_facet/migration.sql b/prisma/migrations/20260429000000_add_template_facet/migration.sql new file mode 100644 index 0000000..7b8a2c7 --- /dev/null +++ b/prisma/migrations/20260429000000_add_template_facet/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "TemplateFacet" ( + "id" TEXT NOT NULL, + "templateSlug" TEXT NOT NULL, + "group" TEXT NOT NULL, + "value" TEXT NOT NULL, + "matches" BOOLEAN NOT NULL, + + CONSTRAINT "TemplateFacet_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateFacet_templateSlug_group_value_key" ON "TemplateFacet"("templateSlug", "group", "value"); + +-- CreateIndex +CREATE INDEX "TemplateFacet_templateSlug_idx" ON "TemplateFacet"("templateSlug"); + +-- CreateIndex +CREATE INDEX "TemplateFacet_group_value_matches_idx" ON "TemplateFacet"("group", "value", "matches"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78f790f..1ef543a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -111,3 +111,24 @@ model MethodFacet { @@index([section]) @@index([group, value, matches]) } + +/// Template-level recommendation matrix (Template Composition, cols G–Y). Canonical +/// JSON in `data/templates/templateFacet.json`; rebuilt at seed like +/// `MethodFacet`. One row per `(templateSlug, group, value)` where the matrix +/// marks a fit (✓). `GET /api/templates?facet.*` joins these rows to user facets. +/// See `docs/guides/template-recommendation-matrix.md` (parallel to `MethodFacet` §7). +model TemplateFacet { + id String @id @default(cuid()) + /// `RuleTemplate.slug` (e.g. `consensus`, `do-ocracy`). + templateSlug String + /// `size` | `orgType` | `scale` | `maturity` — same as `MethodFacet.group`. + group String + /// Canonical facet value id, e.g. `workersCoop`, `local`. + value String + /// `true` iff the JSON marks a fit; seed only writes `true` rows. + matches Boolean + + @@unique([templateSlug, group, value]) + @@index([templateSlug]) + @@index([group, value, matches]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 06f7f00..cee12e2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,6 @@ import { PrismaClient, type Prisma } from "@prisma/client"; import { seedMethodFacets } from "./seed/methodFacets"; +import { seedTemplateFacets } from "./seed/templateFacets"; /** * Curated rule templates for GET /api/templates. @@ -393,6 +394,12 @@ async function main() { .map(([section, count]) => `${section}=${count}`) .join(", ")}`, ); + + const templateFacetSeed = await seedTemplateFacets(prisma); + // eslint-disable-next-line no-console -- seed CLI feedback + console.log( + `Seeded TemplateFacet rows: ${templateFacetSeed.rowCount}`, + ); } main() diff --git a/prisma/seed/templateFacets.ts b/prisma/seed/templateFacets.ts new file mode 100644 index 0000000..3d96296 --- /dev/null +++ b/prisma/seed/templateFacets.ts @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { PrismaClient } from "@prisma/client"; +import { FACET_GROUP_IDS } from "../../lib/server/validation/methodFacetsSchemas"; +import { templateFacetFileSchema } from "../../lib/server/validation/templateFacetSchema"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const TEMPLATE_FACET_FILE = path.join( + REPO_ROOT, + "data", + "templates", + "templateFacet.json", +); + +type TemplateFacetRow = { + templateSlug: string; + group: string; + value: string; + matches: boolean; +}; + +async function loadTemplateFacets() { + const raw = await readFile(TEMPLATE_FACET_FILE, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const result = templateFacetFileSchema.safeParse(parsed); + if (!result.success) { + throw new Error( + `Invalid template facet file ${TEMPLATE_FACET_FILE}: ${JSON.stringify( + result.error.flatten(), + null, + 2, + )}`, + ); + } + return result.data; +} + +/** + * One row per `(templateSlug, group, value)` where the matrix lists a fit (✓). + * Sparse: omitted cells are not stored (unlike `MethodFacet`, which materializes + * all cells for constant table density). + */ +function flattenTemplateFacets( + data: Awaited>, +): TemplateFacetRow[] { + const rows: TemplateFacetRow[] = []; + for (const [templateSlug, row] of Object.entries(data)) { + for (const group of FACET_GROUP_IDS) { + for (const value of row[group]) { + rows.push({ templateSlug, group, value, matches: true }); + } + } + } + return rows; +} + +/** + * Validates and re-seeds the `TemplateFacet` table from + * `data/templates/templateFacet.json` (Template Composition-2, cols G–Y). + */ +export async function seedTemplateFacets( + prisma: PrismaClient, +): Promise<{ rowCount: number }> { + const data = await loadTemplateFacets(); + const rows = flattenTemplateFacets(data); + await prisma.$transaction([ + prisma.templateFacet.deleteMany(), + prisma.templateFacet.createMany({ data: rows }), + ]); + return { rowCount: rows.length }; +} diff --git a/tests/unit/buildFacetQueryString.test.ts b/tests/unit/buildFacetQueryString.test.ts new file mode 100644 index 0000000..4b9b56a --- /dev/null +++ b/tests/unit/buildFacetQueryString.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { buildFacetQueryString } from "../../lib/create/buildFacetQueryString"; + +describe("buildFacetQueryString", () => { + it("maps community chip ids to facet.* query params", () => { + const qs = buildFacetQueryString({ + selectedCommunitySizeIds: ["2"], + selectedOrganizationTypeIds: ["3"], + selectedScaleIds: ["1"], + selectedMaturityIds: ["1"], + }); + const params = new URLSearchParams(qs); + expect(params.get("facet.size")).toBe("twoToFive"); + expect(params.get("facet.orgType")).toBe("openSource"); + expect(params.get("facet.scale")).toBe("local"); + expect(params.get("facet.maturity")).toBe("earlyStage"); + }); + + it("returns empty string when no selections", () => { + expect(buildFacetQueryString({})).toBe(""); + }); +}); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index fc59f34..7a8b294 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -8,6 +8,10 @@ import { getStepIndex, parseReviewReturnSearchParam, resolveCreateFlowBackTarget, + TEMPLATES_FACET_RECOMMEND_QUERY, + TEMPLATES_FACET_RECOMMEND_VALUE, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, + TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, } from "../../app/(app)/create/utils/flowSteps"; describe("flowSteps", () => { @@ -106,6 +110,12 @@ describe("flowSteps", () => { ); }); + it("review Create from template uses fromFlow and recommendTemplates together", () => { + expect( + `/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`, + ).toBe("/templates?fromFlow=1&recommendTemplates=1"); + }); + it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => { expect( parseReviewReturnSearchParam( diff --git a/tests/unit/templateFacetRecommendations.test.ts b/tests/unit/templateFacetRecommendations.test.ts new file mode 100644 index 0000000..10af6f6 --- /dev/null +++ b/tests/unit/templateFacetRecommendations.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const templateFindManyMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + templateFacet: { + findMany: (...args: unknown[]) => templateFindManyMock(...args), + }, + }, +})); + +import { + getTemplateFacetSlugSet, + scoreTemplatesByTemplateFacets, +} from "../../lib/server/methodRecommendations"; + +beforeEach(() => { + templateFindManyMock.mockReset(); +}); + +describe("scoreTemplatesByTemplateFacets", () => { + it("counts matches against TemplateFacet rows", async () => { + templateFindManyMock.mockResolvedValueOnce([ + { templateSlug: "consensus", group: "size", value: "oneMember" }, + { templateSlug: "consensus", group: "orgType", value: "dao" }, + ]); + + const out = await scoreTemplatesByTemplateFacets({ + templateSlugs: ["consensus", "unknown-slug"], + facets: { + size: ["oneMember"], + orgType: ["dao"], + scale: [], + maturity: [], + }, + }); + + const consensus = out?.find((r) => r.templateSlug === "consensus"); + const unknown = out?.find((r) => r.templateSlug === "unknown-slug"); + expect(consensus?.score).toBe(2); + expect(consensus?.matchedFacets).toEqual([ + "size:oneMember", + "orgType:dao", + ]); + expect(unknown?.score).toBe(0); + }); + + it("returns zero when no facets requested", async () => { + const out = await scoreTemplatesByTemplateFacets({ + templateSlugs: ["consensus"], + facets: {}, + }); + expect(out?.[0]?.score).toBe(0); + expect(templateFindManyMock).not.toHaveBeenCalled(); + }); +}); + +describe("getTemplateFacetSlugSet", () => { + it("returns distinct template slugs", async () => { + templateFindManyMock.mockResolvedValueOnce([ + { templateSlug: "consensus" }, + { templateSlug: "do-ocracy" }, + ]); + + const set = await getTemplateFacetSlugSet(); + expect(set?.has("consensus")).toBe(true); + expect(set?.has("do-ocracy")).toBe(true); + }); +}); diff --git a/tests/unit/templateGridFacetScores.test.ts b/tests/unit/templateGridFacetScores.test.ts new file mode 100644 index 0000000..aa367eb --- /dev/null +++ b/tests/unit/templateGridFacetScores.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + deriveRecommendedTemplateSlugs, + gridEntriesWithFacetScores, +} from "../../lib/templates/templateGridPresentation"; +import { fetchRankedTemplatesByFacets } from "../../lib/create/fetchTemplates"; +import type { RuleTemplateDto } from "../../lib/create/fetchTemplates"; + +const minimalTemplate = (slug: string, title: string): RuleTemplateDto => ({ + id: "x", + slug, + title, + category: null, + description: null, + body: null, + sortOrder: 0, + featured: false, +}); + +describe("deriveRecommendedTemplateSlugs", () => { + it("returns at most limit slugs in the top score tier (API order for ties)", () => { + const templates = ["a", "b", "c", "d", "e", "f"].map((s) => ({ slug: s })); + const scores = Object.fromEntries( + ["a", "b", "c", "d", "e", "f"].map((s) => [ + s, + { score: 1, matchedFacets: [] as string[] }, + ]), + ); + const set = deriveRecommendedTemplateSlugs(templates, scores, 5); + expect(set.size).toBe(5); + expect([...set]).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("only recommends the highest score group, not lower tiers to fill the cap", () => { + const templates = ["a", "b", "c", "d", "e"].map((s) => ({ slug: s })); + const scores = { + a: { score: 4, matchedFacets: [] as string[] }, + b: { score: 4, matchedFacets: [] as string[] }, + c: { score: 3, matchedFacets: [] as string[] }, + d: { score: 3, matchedFacets: [] as string[] }, + e: { score: 3, matchedFacets: [] as string[] }, + }; + const set = deriveRecommendedTemplateSlugs(templates, scores, 5); + expect([...set]).toEqual(["a", "b"]); + }); +}); + +describe("gridEntriesWithFacetScores", () => { + it("sets recommended true only for top compact matches (like card decks)", () => { + const t = minimalTemplate("do-ocracy", "Do-ocracy"); + const [row] = gridEntriesWithFacetScores([t], { + "do-ocracy": { score: 3, matchedFacets: ["a"] }, + }); + expect(row.recommended).toBe(true); + }); + + it("does not mark lower-scoring templates recommended when a higher tier exists", () => { + const high = [ + minimalTemplate("a", "A"), + minimalTemplate("b", "B"), + ]; + const low = minimalTemplate("c", "C"); + const rows = gridEntriesWithFacetScores([...high, low], { + a: { score: 4, matchedFacets: [] }, + b: { score: 4, matchedFacets: [] }, + c: { score: 3, matchedFacets: [] }, + }); + const rec = rows.filter((r) => r.recommended).map((r) => r.slug); + expect(rec).toEqual(["a", "b"]); + }); + + it("caps top-tier recommended badges to compactRecommendedLimit", () => { + const slugs = ["a", "b", "c", "d", "e", "f"]; + const templates = slugs.map((s) => minimalTemplate(s, s)); + const scores = Object.fromEntries( + slugs.map((s) => [s, { score: 1, matchedFacets: [] as string[] }]), + ); + const rows = gridEntriesWithFacetScores(templates, scores, { + compactRecommendedLimit: 5, + }); + expect(rows.filter((r) => r.recommended).map((r) => r.slug)).toEqual([ + "a", + "b", + "c", + "d", + "e", + ]); + }); + + it("sets recommended false when score is zero or missing", () => { + const t = minimalTemplate("consensus", "Consensus"); + const [a] = gridEntriesWithFacetScores([t], { + consensus: { score: 0, matchedFacets: [] }, + }); + const [b] = gridEntriesWithFacetScores([t], {}); + expect(a.recommended).toBe(false); + expect(b.recommended).toBe(false); + }); +}); + +describe("fetchRankedTemplatesByFacets", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses ok JSON with templates and scores", async () => { + const template = minimalTemplate("s", "T"); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + templates: [template], + scores: { s: { score: 1, matchedFacets: ["size:oneMember"] } }, + }), + } as Response); + const r = await fetchRankedTemplatesByFacets({ + facetQuery: "facet.size=oneMember", + }); + expect("error" in r).toBe(false); + if (!("error" in r)) { + expect(r.templates).toEqual([template]); + expect(r.scores.s?.score).toBe(1); + } + }); + + it("returns error when facetQuery is empty", async () => { + const r = await fetchRankedTemplatesByFacets({ facetQuery: "" }); + expect("error" in r).toBe(true); + }); +});