From 9e11063a11285770d0b738bc41c0f216ae4a266c Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 22 May 2026 14:32:15 -0600 Subject: [PATCH] Add public API for methods and values --- CONTRIBUTING.md | 3 +- app/api/create-flow/methods/route.ts | 107 ++++++-- app/api/templates/[slug]/route.ts | 39 +++ docs/guides/backend-linear-tickets.md | 27 ++ docs/guides/template-recommendation-matrix.md | 235 ++++++++++-------- lib/create/customRuleFacets.ts | 8 + lib/create/fetchTemplates.ts | 51 +++- lib/server/governanceCatalog.ts | 144 +++++++++++ lib/server/ruleTemplates.ts | 17 ++ lib/server/validation/methodFacetsSchemas.ts | 9 +- tests/unit/createFlowMethodsRoute.test.ts | 81 +++++- tests/unit/governanceCatalog.test.ts | 61 +++++ tests/unit/methodFacets.test.ts | 4 + tests/unit/templatesBySlugRoute.test.ts | 75 ++++++ 14 files changed, 727 insertions(+), 134 deletions(-) create mode 100644 app/api/templates/[slug]/route.ts create mode 100644 lib/server/governanceCatalog.ts create mode 100644 tests/unit/governanceCatalog.test.ts create mode 100644 tests/unit/templatesBySlugRoute.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a7986b..0c0f27c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,8 @@ deployment-pipeline work. | GET | `/api/uploads/[id]` | Stream a previously uploaded file by opaque id (public read). | | GET / POST | `/api/rules` | List or publish rules. | | GET | `/api/templates` | List curated templates. Optional repeatable `facet.=` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. | -| GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. | +| GET | `/api/templates/[slug]` | Single curated template plus normalized `{ section, slug }` composition from `body`. Public read; 404 when unknown. §9.4 (CR-115). | +| GET | `/api/create-flow/methods` | Public catalog for built-in governance methods and core values. Required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement` \| `coreValues`; alias `values` → `coreValues`). Returns the **full deck** with `label`, `description`, and `sections` (methods) or `id`, `label`, `meaning`, `signals` (core values). Optional `facet.*` adds `matches` and re-ranks method rows (ignored for `coreValues`). Core value `id` is a 1-based position string (`"1"`, `"2"`, …). English v1 only. §9.2 / §9.5 (CR-115). | | POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. | | GET | `/api/rules/me` | Authenticated list of own published rules. | | GET / PATCH / DELETE | `/api/rules/[id]` | Public read; owner update/delete. | diff --git a/app/api/create-flow/methods/route.ts b/app/api/create-flow/methods/route.ts index 0e78edf..e0f2b64 100644 --- a/app/api/create-flow/methods/route.ts +++ b/app/api/create-flow/methods/route.ts @@ -1,24 +1,63 @@ import { NextResponse, type NextRequest } from "next/server"; import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { + listCatalogCoreValues, + listCatalogMethods, + type CatalogCoreValueDto, + type CatalogMethodDto, +} from "../../../../lib/server/governanceCatalog"; import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations"; import { apiRoute } from "../../../../lib/server/apiRoute"; import { dbUnavailable, errorJson } from "../../../../lib/server/responses"; +import { CATALOG_SECTION_IDS } from "../../../../lib/create/customRuleFacets"; import { - SECTION_IDS, + type CatalogSectionId, type SectionId, + flattenRequestedFacets, parseRequestedFacetsFromSearchParams, } from "../../../../lib/server/validation/methodFacetsSchemas"; -const SECTION_SET = new Set(SECTION_IDS); +const CATALOG_SECTION_SET = new Set(CATALOG_SECTION_IDS); +const CATALOG_CACHE_CONTROL = "public, max-age=3600"; + +/** Route query alias → canonical `coreValues`. */ +function normalizeSectionParam(raw: string): string { + return raw === "values" ? "coreValues" : raw; +} + +type MethodMatch = { score: number; matchedFacets: string[] }; + +function rankCatalogMethods( + catalog: CatalogMethodDto[], + matchesBySlug: Record | null, + includeMatches: boolean, +): Array { + if (!matchesBySlug || !includeMatches) { + return catalog; + } + + const indexBySlug = new Map(catalog.map((m, i) => [m.slug, i])); + const sorted = [...catalog].sort((a, b) => { + const sa = matchesBySlug[a.slug]?.score ?? 0; + const sb = matchesBySlug[b.slug]?.score ?? 0; + if (sa !== sb) return sb - sa; + return (indexBySlug.get(a.slug) ?? 0) - (indexBySlug.get(b.slug) ?? 0); + }); + + return sorted.map((m) => ({ + ...m, + matches: matchesBySlug[m.slug] ?? { score: 0, matchedFacets: [] }, + })); +} /** * GET /api/create-flow/methods?section=
[&facet.*=...] * - * Returns slugs + per-method match scores for one of the four card-deck - * sections; the wizard renders by looking up the slug in the section's - * messages file (`useMessages().create.customRule.
.methods`). + * Returns the full built-in catalog for one facet: four method decks + * (with optional facet ranking) or all preset core values. Copy is + * included in v1 (English only). * - * See `docs/guides/template-recommendation-matrix.md` §9.2 / §10. + * See `docs/guides/template-recommendation-matrix.md` §9.2 / §9.5 (CR-115). */ export const GET = apiRoute( "createFlow.methods.get", @@ -28,29 +67,59 @@ export const GET = apiRoute( } const sectionParam = request.nextUrl.searchParams.get("section"); - if (!sectionParam || !SECTION_SET.has(sectionParam)) { + if (!sectionParam) { return errorJson( "validation_error", - `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`, + `Unknown section. Expected one of: ${CATALOG_SECTION_IDS.join(", ")} (alias: values → coreValues)`, 400, ); } - const section = sectionParam as SectionId; + + const normalized = normalizeSectionParam(sectionParam); + if (!CATALOG_SECTION_SET.has(normalized)) { + return errorJson( + "validation_error", + `Unknown section. Expected one of: ${CATALOG_SECTION_IDS.join(", ")} (alias: values → coreValues)`, + 400, + ); + } + const section = normalized as CatalogSectionId; + + if (section === "coreValues") { + const methods: CatalogCoreValueDto[] = listCatalogCoreValues(); + return NextResponse.json( + { section: "coreValues" as const, methods }, + { headers: { "Cache-Control": CATALOG_CACHE_CONTROL } }, + ); + } const facets = parseRequestedFacetsFromSearchParams( request.nextUrl.searchParams, ); - const result = await listMethodRecommendations({ section, facets }); - if (!result) { - // DB query failed; return empty so the wizard falls back to its messages - // deck in authoring order (§10). - return NextResponse.json({ section, methods: [] }); + const catalog = listCatalogMethods(section as SectionId); + const facetSection = section as SectionId; + + const ranking = await listMethodRecommendations({ + section: facetSection, + facets, + }); + + const includeMatches = flattenRequestedFacets(facets).length > 0; + + let matchesBySlug: Record | null = null; + if (includeMatches && ranking) { + matchesBySlug = ranking.matchesBySlug; } - const methods = result.rankedSlugs.map((slug) => ({ - slug, - matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] }, - })); - return NextResponse.json({ section, methods }); + const methods = rankCatalogMethods( + catalog, + matchesBySlug, + includeMatches, + ); + + return NextResponse.json( + { section, methods }, + { headers: { "Cache-Control": CATALOG_CACHE_CONTROL } }, + ); }, ); diff --git a/app/api/templates/[slug]/route.ts b/app/api/templates/[slug]/route.ts new file mode 100644 index 0000000..64f6b5b --- /dev/null +++ b/app/api/templates/[slug]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { getRuleTemplateBySlug } from "../../../../lib/server/ruleTemplates"; +import { templateMethodsFromBody } from "../../../../lib/server/templateMethods"; +import { apiRoute } from "../../../../lib/server/apiRoute"; +import { dbUnavailable, notFound } from "../../../../lib/server/responses"; + +type RouteContext = { params: Promise<{ slug: string }> }; + +const CATALOG_CACHE_CONTROL = "public, max-age=3600"; + +/** + * GET /api/templates/[slug] + * + * Single seeded template plus normalized `(section, slug)` composition + * derived from `body`. Public read; 404 when unknown. + * + * See `docs/guides/template-recommendation-matrix.md` §9.4 (CR-115). + */ +export const GET = apiRoute( + "templates.bySlug", + async (_request: NextRequest, context) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const { slug } = await context.params; + const template = await getRuleTemplateBySlug(slug); + if (!template) { + return notFound(); + } + + const methods = templateMethodsFromBody(template.body); + return NextResponse.json( + { template, methods }, + { headers: { "Cache-Control": CATALOG_CACHE_CONTROL } }, + ); + }, +); diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index 3e701da..4bdc534 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -319,6 +319,33 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi --- +## Ticket 22 — Public catalog API (templates + methods + core values; CR-115) + +**Depends on:** Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) (facet ranking). + +**Goal:** Machine-readable HTTP catalog so external clients and tools can discover curated templates and all built-in governance methods (including core values) **without** importing `messages/en/create/customRule/*.json`. + +**Implementation (shipped):** + +1. **`lib/server/governanceCatalog.ts`** — DTOs from messages JSON (English v1). +2. **`GET /api/templates/[slug]`** — template + `templateMethodsFromBody` composition. +3. **`GET /api/create-flow/methods`** — extended: full copy, full deck, `coreValues` (+ `values` alias), facet merge unchanged for method sections. +4. **`lib/create/fetchTemplates.ts`** — `fetchTemplateDetailBySlug`; `fetchTemplateBySlug` uses detail route. +5. Tests: `governanceCatalog.test.ts`, `templatesBySlugRoute.test.ts`, extended `createFlowMethodsRoute.test.ts`. + +**Acceptance criteria:** + +- [x] `GET /api/templates/[slug]` returns one template or 404. +- [x] `GET /api/create-flow/methods?section=coreValues` returns all presets with stable ids. +- [x] Method sections return full metadata; facet ranking preserved. +- [x] Documented in CONTRIBUTING.md and template-recommendation-matrix.md §9. + +**Files:** [lib/server/governanceCatalog.ts](../../lib/server/governanceCatalog.ts), [app/api/templates/[slug]/route.ts](../../app/api/templates/[slug]/route.ts), [app/api/create-flow/methods/route.ts](../../app/api/create-flow/methods/route.ts), [lib/server/ruleTemplates.ts](../../lib/server/ruleTemplates.ts), [lib/create/fetchTemplates.ts](../../lib/create/fetchTemplates.ts), [lib/create/customRuleFacets.ts](../../lib/create/customRuleFacets.ts). + +**Status:** [CR-115](https://linear.app/community-rule/issue/CR-115/backend-public-catalog-api-templates-built-in-governance-methods-all) **Done** (in-repo). + +--- + ## Ticket 17 — Canon custom create-rule wizard (routes, resume, progress) + docs **Depends on:** none for documentation; soft optional **CR-73**, **CR-76**, **CR-77** for payload/resume/publish alignment. diff --git a/docs/guides/template-recommendation-matrix.md b/docs/guides/template-recommendation-matrix.md index 1958e85..e3c336c 100644 --- a/docs/guides/template-recommendation-matrix.md +++ b/docs/guides/template-recommendation-matrix.md @@ -1,10 +1,10 @@ -# Recommendation Matrix — Implementation Context (CR-88) +# Recommendation Matrix — Implementation Context -**Status:** Implemented (CR-88). This doc remains the spec — keep code in sync with it. -**Linear:** [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) (**Done** — no `.xlsx` import; authoring = committed JSON + seed). -**Follow-up (UI):** [CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates) — pass facets into marketing template fetches so grids rank like the wizard; template ranking **tests** ship with CR-93. -**Roadmap:** [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §4 (`RuleTemplate`) and §13. -**Spec ticket:** [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md) Ticket 16. +**Status:** Implemented. This doc is the canonical spec — keep code in sync with it. + +**Authoring:** Committed JSON under `data/create/customRule/` plus `npx prisma db seed` — no runtime `.xlsx` import. + +**Related:** [`docs/create-flow.md`](../create-flow.md) (wizard URLs and stages), [`CONTRIBUTING.md`](../../CONTRIBUTING.md) (API route table). This doc documents the **method facet matrix** that powers ranking from shared facet data: @@ -13,7 +13,7 @@ This doc documents the **method facet matrix** that powers ranking from shared f `conflict-management`) reorder their `methods[]` array based on which methods match the user's selected community facets. 2. **Template list API (shipped)** — `GET /api/templates?facet.*` returns scored/ranked `RuleTemplate` rows when query params are present. -3. **Template marketing grids (CR-93)** — home `MarketingRuleStackSection.tsx` and `/templates` still call `GET /api/templates` **without** facets; wiring + tests are **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)**. +3. **Template marketing grids (optional follow-up)** — home `MarketingRuleStackSection.tsx` and `/templates` may call `GET /api/templates` **without** facets; product can pass `facet.*` when those surfaces should rank like the wizard. > **Scope note:** Card / modal copy lives in > `messages/en/create/customRule/*.json` as flat `methods` arrays (one @@ -27,7 +27,7 @@ This doc documents the **method facet matrix** that powers ranking from shared f --- -## 1. Where things live (post-reorg) +## 1. Where things live ### 1a. Card / modal copy — `messages/en/create/customRule/
.json` @@ -36,7 +36,9 @@ Source of truth for all displayed text. Each file holds the page chrome plus decision-approaches' `sidebar` / `messageBox` / `cardStack` / `scopeAddButtonLabel`) plus a flat `methods` array. Read via `useMessages().create.customRule.
.methods`. Never duplicated -anywhere else; the recommendation API never returns copy. +elsewhere for in-app UI. `GET /api/create-flow/methods` exposes the same +copy for external consumers (§9.2); the wizard may still read messages +directly. ### 1b. Facet data — `data/create/customRule/
.json` @@ -48,43 +50,22 @@ the Zod schema at seed time and in CI (see §3 — no app-boot validator). Lives **outside** `messages//` because facets are not localized — they describe the methods, not the UI text. -### 1c. Messages folder reorg (prerequisite) +### 1c. Messages folder layout -Today every `messages/en/create/*.json` file sits flat in one folder. -Plan: regroup into the three Figma stages from +Create-flow messages are grouped into three stages from [`docs/create-flow.md`](../create-flow.md) §"Product stages": -| New folder | Files | +| Folder | Files | | --- | --- | | `messages/en/create/community/` | `informational.json`, `communityName.json`, `communityStructure.json`, `communityContext.json`, `communitySize.json`, `communityUpload.json`, `communitySave.json`, `review.json` | | `messages/en/create/customRule/` | `coreValues.json`, `communication.json`, `membership.json`, `decisionApproaches.json`, `conflictManagement.json` | | `messages/en/create/reviewAndComplete/` | `confirmStakeholders.json`, `finalReview.json`, `completed.json`, `publish.json` | | `messages/en/create/` (root, cross-cutting) | `footer.json`, `topNav.json`, `draftHydration.json`, `templateReview.json`, plus layout-shell strings (`select.json`, `text.json`, `upload.json`) | -Touchpoints: every file move, the imports in -[`messages/en/index.ts`](../../messages/en/index.ts), the namespace shape -exposed via `useMessages()`, and every screen that reads -`m.create.
` becomes `m.create..
`. This is a -mechanical refactor — no behavior change. - -**Sequencing (explicit).** This reorg is **its own ticket** and **must -land before** any of §6–§9 below. CR-88's facet JSON paths -(`data/create/customRule/
.json`) and `useMessages()` namespaces -(`m.create.customRule.
`) assume the reorg is already in place. -Concretely, ship in this order: - -1. **Reorg PR (separate ticket).** Move every `messages/en/create/*.json` - into the table above, update `messages/en/index.ts`, update every - `useMessages().create.
` callsite to - `useMessages().create..
`, run `npx tsc --noEmit` and - `npx vitest run` green. No new behavior. -2. **CR-88 PR (this doc).** Adds `data/create/customRule/`, - `MethodFacet`, the seed step, and the two API endpoints — all reading - the post-reorg paths. - -If the reorg slips, do **not** start CR-88 against the flat paths and -plan to migrate later — the path mirroring between `messages/` and -`data/` is the whole point of §1a/§1b and is fragile to retrofit. +[`messages/en/index.ts`](../../messages/en/index.ts) exposes +`useMessages().create..
`. Facet JSON paths +(`data/create/customRule/
.json`) mirror the `customRule/` +filenames — that pairing is intentional (§1a/§1b). --- @@ -264,9 +245,12 @@ decision-approaches → conflict-management → confirm-stakeholders → final-r | Templates index | `app/(marketing)/templates/page.tsx` | | Template preview (by slug) | `app/(app)/create/review-template/[slug]/page.tsx` | | "Use without changes" → publish | `app/(app)/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` | -| API list | `app/api/templates/route.ts` (`GET`; optional `facet.*` params — see §9.1) | +| API list | `app/api/templates/route.ts` (`GET`; optional `facet.*` — §9.1) | +| API detail | `app/api/templates/[slug]/route.ts` (`GET` — §9.4) | +| API catalog | `app/api/create-flow/methods/route.ts` (`GET` — §9.2); `lib/server/governanceCatalog.ts` | -**Marketing UIs** do not pass `facet.*` yet (**[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)**). The no-facets path keeps curated ordering; with facets, templates are ranked and `scores` may be returned. Template **Customize** +**Marketing UIs** may omit `facet.*` (curated ordering only). When facets +are passed, templates are ranked and `scores` may be returned. Template **Customize** now prefills the custom-rule flow via [`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts) (applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values` @@ -362,7 +346,7 @@ one. ### 5.7 i18n stays the source of truth for copy Card decks and modal copy live in -`messages/en/create/customRule/
.json` (post-reorg) and are read +`messages/en/create/customRule/
.json` and are read via `useMessages().create.customRule.
` (`messages/en/index.ts`, `app/contexts/MessagesContext.tsx`). The matrix never puts copy in the DB. The recommendation API returns slugs and scores only — never copy — @@ -547,10 +531,14 @@ async function seedMethodFacets() { ## 9. APIs -Both endpoints follow §5.1 conventions. **Neither returns copy** — copy -lives in messages and is read client-side via `useMessages()`. +Endpoints follow §5.1 conventions. List and method routes support +optional `facet.*` ranking. `GET /api/create-flow/methods` returns the +full catalog (copy + optional scores); `GET /api/templates/[slug]` returns +template detail and composition. The in-app wizard may still read messages +via `useMessages()`; external clients should use the HTTP catalog (English +v1 only today). -### 9.1 `GET /api/templates` (rewrite) +### 9.1 `GET /api/templates` Optional facet query params: @@ -611,25 +599,46 @@ debugging and for an eventual "Why this template?" UI tooltip. ### 9.2 `GET /api/create-flow/methods?section=
[&facet.*=...]` -Powers the four card-deck wizard steps. Returns slugs + per-method match -scores only — wizard renders by looking up entries in -`useMessages().create.customRule.
.methods` (via the -`methodById` map each screen builds). +Public catalog for one facet. Powers wizard re-ranking (scores) and +external method browsers (full copy). Assembler: +`lib/server/governanceCatalog.ts`. -Response: +**`section` values:** `communication`, `membership`, +`decisionApproaches`, `conflictManagement`, `coreValues` (query alias +`values` → `coreValues`). Core values have **no** `MethodFacet` rows; +`facet.*` is ignored for `coreValues`. + +**Method sections** — full deck, messages authoring order: ```ts { section: "communication" | "membership" | "decisionApproaches" | "conflictManagement", methods: Array<{ slug: string; - matches: { score: number; matchedFacets: string[] }; + label: string; + description: string; // messages supportText + sections: Record; + matches?: { score: number; matchedFacets: string[] }; // present when facet.* sent }> } ``` -**Scoring algorithm.** Same simple count as §9.1, scoped to a single -method: +**Core values** — preset list; `id` is `"1"` … `"n"` (position in +`coreValues.json`, not a kebab slug): + +```ts +{ + section: "coreValues", + methods: Array<{ + id: string; + label: string; + meaning: string; + signals: string; + }> +} +``` + +**Scoring algorithm** (method sections only). Same simple count as §9.1: ``` score(method) @@ -638,41 +647,59 @@ score(method) else 0 ``` -Methods are returned **ranked by `matches.score` desc**, then by the -on-disk order from the messages file (so the deck stays stable when no -facets are passed and zero-match methods preserve authoring order). The -wizard never **hides** rows — see §10. +With `facet.*` present, methods are **ranked by `matches.score` desc**, +then authoring order; **all** methods are returned (zero-score rows stay +in the deck). Without facets, `matches` is omitted and order is +authoring order. `Cache-Control: public, max-age=3600`. -Server helper: `listMethodRecommendations({ section, facets })` in -`lib/server/methodRecommendations.ts`. Same swallow-and-return-`[]` -failure mode as `listRuleTemplatesFromDb`. When the DB is unavailable -(or facets are empty), the wizard falls back to the messages deck in -its on-disk order. +Server: `listMethodRecommendations` + `listCatalogMethods` / +`listCatalogCoreValues`. DB failure with facets still returns the full +catalog without `matches` (wizard treats scores as 0). -### 9.3 `POST /api/templates/recommend` (follow-up, optional) +### 9.4 `GET /api/templates/[slug]` + +Single seeded template. Public read; `404` when unknown. + +```ts +{ + template: RuleTemplateDto, + methods: Array<{ section: SectionId; slug: string }> // templateMethodsFromBody(body) +} +``` + +`Cache-Control: public, max-age=3600`. List behavior unchanged in §9.1. + +### 9.5 External catalog consumers + +1. Cache section responses (`GET /api/create-flow/methods?section=…`). +2. Render cards from `label` + `description`; modal bodies from `sections`. +3. Template preview: `GET /api/templates/:slug` → join each + `{ section, slug }` to the cached section catalog. +4. Do not import Next.js `messages/` from outside the app — treat these + GET routes as the stable contract until copy is served from a CMS or + database instead of committed JSON. + +### 9.3 `POST /api/templates/recommend` (optional) If product wants to send the full `CreateFlowState` instead of just -facet ids, body schema reuses `createFlowStateSchema`. Skip until §9.1 -+ §9.2 ship. +facet ids, body schema can reuse `createFlowStateSchema`. Not +implemented today. **Empty / partial facets:** never error. Fall back to today's ordering and return all rows. --- -## 10. Wizard wiring (UI follow-on) - -Once the API exists: +## 10. Wizard wiring - `communication-methods` / `membership-methods` / `decision-approaches` / `conflict-management` screens each call - `GET /api/create-flow/methods?section=...&facet.*=...` to get the - ranking. Card label, description, and modal copy continue to come - from `useMessages().create.customRule.
.methods` (a flat - array — each screen already builds a `methodById` lookup map and - iterates the array; no per-section `_CARD_ORDER` constants exist). - The screen reorders the array by the API's ranked slug list before - rendering. + `GET /api/create-flow/methods?section=...&facet.*=...` for scores + (`matches.score` per slug). Card label, description, and modal copy + still come from `useMessages().create.customRule.
.methods` + in-app; external clients use the API copy fields (§9.5). Each screen + reorders the messages array via `rankMethodsByScore` (full deck + returned; zero-score slugs sort to authoring order). - API failure or empty facets → render the messages deck in its on-disk order. No regression from today. - Selecting a template on the template-review page via **Customize** @@ -680,7 +707,7 @@ Once the API exists: snapshot from the template's composition — see [`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts) and the `handleCustomizeTemplate` handler in - `CreateFlowLayoutClient.tsx`. Shipped outside CR-88. + `CreateFlowLayoutClient.tsx`. - Recommendations **never hide** options — ranking only. Authors expect to see "all 32 decision-making patterns" with the matching ones surfaced first. @@ -723,8 +750,8 @@ Once the API exists: reserved for a future v2; ignored by v1. - ~~Boot-time validation~~ → none. Parity is enforced by the seed step (§8) and the parity test in CI (§3, §12). No `next dev` startup hook. -- ~~Messages folder reorg sequencing~~ → ships as **its own ticket - before** CR-88 (§1c). CR-88 assumes the post-reorg paths. +- ~~Messages folder layout~~ → three-stage folders under + `messages/en/create/` (§1c). Facet paths assume that layout. - ~~Spreadsheet handoff~~ → the four `~/Downloads/*.xlsx` files are passed to the implementing agent alongside this doc. They are **not** committed; the post-ingest `messages/en/create/customRule/*.json` @@ -732,39 +759,39 @@ Once the API exists: --- -## 12. Test plan (acceptance for CR-88) +## 12. Test plan -- [ ] `prisma db seed` populates `MethodFacet` from the four +- [x] `prisma db seed` populates `MethodFacet` from the four `data/create/customRule/
.json` files with no errors, producing the expected row count (`(11 + 19 + 32 + 19) × 19 = 1539` rows max, fewer if authors use the omit-default shorthand). -- [ ] `tests/unit/methodFacets.test.ts` asserts every method slug in +- [x] `tests/unit/methodFacets.test.ts` asserts every method slug in each facet file matches a `methods[].id` in the corresponding messages file (and vice-versa) — no orphans either way. Also asserts every `chipId` in `_facetGroups.json` resolves to a real position in the referenced messages file (off-by-one fails). -- [ ] `tests/unit/methodFacetsSchemas.test.ts` exercises the Zod schema +- [x] `tests/unit/methodFacetsSchemas.test.ts` exercises the Zod schema (rejects unknown facet values, unknown groups, unknown sections, malformed booleans). -- [ ] `tests/unit/methodRecommendations.test.ts` exercises the scoring +- [x] `tests/unit/methodRecommendations.test.ts` exercises the scoring function directly with a fixture set: a method matching 2 of 3 requested facets scores `2`; a template composing two methods that each match `2` and `3` requested facets scores `5`; ties fall back to curated `(featured, sortOrder, title)` order. -- [ ] `GET /api/create-flow/methods?section=conflictManagement&facet.orgType=nonprofit` +- [x] `GET /api/create-flow/methods?section=conflictManagement&facet.orgType=nonprofit` returns all 19 methods, ranked, with the `nonprofit`-matching methods scoring higher than non-matching ones; zero-match methods preserve their on-disk authoring order. -- [ ] `GET /api/templates?facet.orgType=nonprofit&facet.size=sixToTwelve` +- [x] `GET /api/templates?facet.orgType=nonprofit&facet.size=sixToTwelve` returns templates re-ordered by composed-method match count, with score-0 templates still present at the end in curated order. -- [ ] No-facets `GET /api/templates` matches today's curated ordering +- [x] No-facets `GET /api/templates` matches today's curated ordering (no regression for the existing marketing/templates surfaces). -- [ ] DB-down smoke: with `DATABASE_URL` unset, the four wizard +- [x] DB-down smoke: with `DATABASE_URL` unset, the four wizard card-deck steps still render the full deck from messages (no 5xx, no broken cards). -- [ ] Editing a `data/create/customRule/
.json` entry and +- [x] Editing a `data/create/customRule/
.json` entry and re-running `prisma db seed` changes the rank order returned by both endpoints without any code change. @@ -772,19 +799,18 @@ Once the API exists: ## 13. Source files referenced -- `prisma/schema.prisma` — `RuleTemplate` model (unchanged); add - `MethodFacet` model (§7). -- `prisma/seed.ts` — current curated composition; add `seedMethodFacets` - helper (§8). -- `app/api/templates/route.ts` — existing GET endpoint (rewrite with - optional facet params). +- `prisma/schema.prisma` — `RuleTemplate`, `MethodFacet`, `TemplateFacet`. +- `prisma/seed.ts` — curated templates + `seedMethodFacets` (§8). +- `app/api/templates/route.ts` — list + optional facet params (§9.1). +- `app/api/templates/[slug]/route.ts` — detail + composition (§9.4). +- `lib/server/governanceCatalog.ts` — catalog DTOs from messages (§9.2). - `app/api/drafts/me/route.ts` — reference route shape. - `lib/server/db.ts` — Prisma singleton. - `lib/server/responses.ts` — `dbUnavailable()`. - `lib/server/ruleTemplates.ts` — `listRuleTemplatesFromDb` (extend with facet param + scoring helper). -- `lib/server/methodRecommendations.ts` — **new**; helper for §9.2. -- `lib/server/validation/methodFacetsSchemas.ts` — **new**; Zod schema +- `lib/server/methodRecommendations.ts` — facet scoring for §9.1–9.2. +- `lib/server/validation/methodFacetsSchemas.ts` — Zod schema for the JSON facet files and the API request shapes. - `lib/server/validation/createFlowSchemas.ts` — reuse facet-id arrays rather than redeclaring them. @@ -801,19 +827,20 @@ Once the API exists: the three sibling screens) — already iterate `methods[]` via `methodById`; the API ranking layer plugs in here. - `messages/en/create/customRule/{communication,membership,decisionApproaches,conflictManagement}.json` - — flat `methods` arrays (post-reorg paths). Source of truth for copy; + — flat `methods` arrays. Source of truth for in-app copy; the matrix never edits these. - `messages/en/create/{community,reviewAndComplete}/*.json` — the other - two stages (post-reorg); not consumed by the matrix but listed for - context on the §1c reorg. + two other create stages; not consumed by the matrix but listed for + context alongside §1c. - `data/create/customRule/{communication,membership,decisionApproaches,conflictManagement}.json` - — **new**; facet matches per method. -- `data/create/customRule/_facetGroups.json` — **new**; canonical facet + — facet matches per method. +- `data/create/customRule/_facetGroups.json` — canonical facet group/value ids and the wizard-chip-id ↔ facet-value-id mapping. -- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for new +- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for schema/parity tests. -- Roadmap: `docs/guides/backend-roadmap.md` §4, §13. -- Spec: `docs/guides/backend-linear-tickets.md` Ticket 16. +- `tests/unit/governanceCatalog.test.ts` — catalog ↔ messages parity. +- `tests/unit/createFlowMethodsRoute.test.ts` — methods API routes. +- `tests/unit/templatesBySlugRoute.test.ts` — template detail route. --- @@ -836,7 +863,7 @@ part of the runtime contract, and **not** referenced by any code path. Each workbook's leading columns hold the descriptive copy already ingested into `messages/en/create/customRule/
.json`; the trailing 19 columns hold the facet matches that need to land in -`data/create/customRule/
.json`. After CR-88 lands, future -facet edits happen directly in the JSON files — the workbooks are +`data/create/customRule/
.json`. Ongoing facet edits happen +directly in those JSON files — the workbooks are historical reference only, and the committed JSON (in both `messages/` and `data/`) is the canonical record. diff --git a/lib/create/customRuleFacets.ts b/lib/create/customRuleFacets.ts index 02021c3..609b91e 100644 --- a/lib/create/customRuleFacets.ts +++ b/lib/create/customRuleFacets.ts @@ -66,6 +66,14 @@ export const METHOD_FACET_API_SECTION_IDS = [ export type MethodFacetApiSectionId = (typeof METHOD_FACET_API_SECTION_IDS)[number]; +/** `GET /api/create-flow/methods?section=` — four method decks + core values (CR-115). */ +export const CATALOG_SECTION_IDS = [ + ...METHOD_FACET_API_SECTION_IDS, + "coreValues", +] as const; + +export type CatalogSectionId = (typeof CATALOG_SECTION_IDS)[number]; + export type CustomRuleFacetKind = "coreValues" | "method"; export type CustomRuleFacetRow = { diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts index 85817a8..59fbd5f 100644 --- a/lib/create/fetchTemplates.ts +++ b/lib/create/fetchTemplates.ts @@ -131,13 +131,60 @@ export async function fetchRankedTemplatesByFacets(options: { 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 { + 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 { - const result = await fetchTemplates(options); + const result = await fetchTemplateDetailBySlug(slug, options); if ("error" in result) { return result; } - return result.find((t) => t.slug === slug) ?? null; + if (result === null) { + return null; + } + return result.template; } diff --git a/lib/server/governanceCatalog.ts b/lib/server/governanceCatalog.ts new file mode 100644 index 0000000..2e596af --- /dev/null +++ b/lib/server/governanceCatalog.ts @@ -0,0 +1,144 @@ +/** + * Public catalog DTOs for built-in governance methods and core values (CR-115). + * Source of truth: `messages/en/create/customRule/*.json` (English v1). + */ + +import communicationMessages from "../../messages/en/create/customRule/communication.json"; +import conflictManagementMessages from "../../messages/en/create/customRule/conflictManagement.json"; +import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json"; +import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json"; +import membershipMessages from "../../messages/en/create/customRule/membership.json"; +import type { MethodFacetApiSectionId } from "../create/customRuleFacets"; + +export type CatalogMethodDto = { + /** Stable id — same as `methods[].id` in messages and `MethodFacet.slug`. */ + slug: string; + label: string; + /** Card copy from messages `supportText`. */ + description: string; + /** Section-specific modal blocks from messages `sections`. */ + sections: Record; +}; + +export type CatalogCoreValueDto = { + /** 1-based position in `coreValues.json` (`"1"`, `"2"`, …). */ + id: string; + label: string; + meaning: string; + signals: string; +}; + +export type CatalogSectionId = MethodFacetApiSectionId | "coreValues"; + +type MethodMessagesSource = { + methods?: unknown; +}; + +const METHOD_MESSAGES_BY_SECTION: Record = { + communication: communicationMessages, + membership: membershipMessages, + decisionApproaches: decisionApproachesMessages, + conflictManagement: conflictManagementMessages, +}; + +function readMethodRows(source: unknown): Array<{ + id: string; + label: string; + supportText?: string; + sections?: Record; +}> { + if (!source || typeof source !== "object") return []; + const methods = (source as MethodMessagesSource).methods; + if (!Array.isArray(methods)) return []; + const out: Array<{ + id: string; + label: string; + supportText?: string; + sections?: Record; + }> = []; + for (const raw of methods) { + if (!raw || typeof raw !== "object") continue; + const o = raw as Record; + if (typeof o.id !== "string" || typeof o.label !== "string") continue; + out.push({ + id: o.id, + label: o.label, + supportText: + typeof o.supportText === "string" ? o.supportText : undefined, + sections: + o.sections && typeof o.sections === "object" + ? (o.sections as Record) + : undefined, + }); + } + return out; +} + +function rowToCatalogMethod(row: { + id: string; + label: string; + supportText?: string; + sections?: Record; +}): CatalogMethodDto { + return { + slug: row.id, + label: row.label, + description: row.supportText ?? "", + sections: row.sections ?? {}, + }; +} + +/** All built-in methods for a card-deck section, in messages authoring order. */ +export function listCatalogMethods( + section: MethodFacetApiSectionId, +): CatalogMethodDto[] { + return readMethodRows(METHOD_MESSAGES_BY_SECTION[section]).map( + rowToCatalogMethod, + ); +} + +export function getCatalogMethod( + section: MethodFacetApiSectionId, + slug: string, +): CatalogMethodDto | null { + const row = readMethodRows(METHOD_MESSAGES_BY_SECTION[section]).find( + (r) => r.id === slug, + ); + return row ? rowToCatalogMethod(row) : null; +} + +/** All preset core values, in `coreValues.json` order (ids `"1"` … `"n"`). */ +export function listCatalogCoreValues(): CatalogCoreValueDto[] { + const values = (coreValuesMessages as { values?: unknown }).values; + if (!Array.isArray(values)) return []; + const out: CatalogCoreValueDto[] = []; + for (let i = 0; i < values.length; i++) { + const row = values[i]; + const id = String(i + 1); + if (typeof row === "string") { + out.push({ id, label: row, meaning: "", signals: "" }); + continue; + } + if (!row || typeof row !== "object") continue; + const o = row as Record; + if (typeof o.label !== "string") continue; + out.push({ + id, + label: o.label, + meaning: typeof o.meaning === "string" ? o.meaning : "", + signals: typeof o.signals === "string" ? o.signals : "", + }); + } + return out; +} + +export function getCatalogCoreValue(id: string): CatalogCoreValueDto | null { + return listCatalogCoreValues().find((v) => v.id === id) ?? null; +} + +/** Slugs for parity tests — same set as messages `methods[].id`. */ +export function catalogMethodSlugsForSection( + section: MethodFacetApiSectionId, +): string[] { + return listCatalogMethods(section).map((m) => m.slug); +} diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts index 1997b9d..55b26d3 100644 --- a/lib/server/ruleTemplates.ts +++ b/lib/server/ruleTemplates.ts @@ -45,6 +45,23 @@ export async function listRuleTemplatesFromDb(): Promise { } } +/** Single curated template by slug; `null` when missing or DB unavailable. */ +export async function getRuleTemplateBySlug( + slug: string, +): Promise { + if (!isDatabaseConfigured()) { + return null; + } + try { + return await prisma.ruleTemplate.findUnique({ + where: { slug }, + select: TEMPLATE_SELECT, + }); + } catch { + return null; + } +} + export type TemplateScore = { score: number; matchedFacets: string[]; diff --git a/lib/server/validation/methodFacetsSchemas.ts b/lib/server/validation/methodFacetsSchemas.ts index c720a23..fd5eec3 100644 --- a/lib/server/validation/methodFacetsSchemas.ts +++ b/lib/server/validation/methodFacetsSchemas.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import { METHOD_FACET_API_SECTION_IDS } from "../../create/customRuleFacets"; +import { + CATALOG_SECTION_IDS, + METHOD_FACET_API_SECTION_IDS, +} from "../../create/customRuleFacets"; /** * Zod schemas for the recommendation matrix (CR-88). @@ -20,6 +23,10 @@ export const SECTION_IDS = METHOD_FACET_API_SECTION_IDS; export type SectionId = (typeof SECTION_IDS)[number]; export const sectionIdSchema = z.enum(SECTION_IDS); +/** `GET /api/create-flow/methods?section=` including core values (CR-115). */ +export type CatalogSectionId = (typeof CATALOG_SECTION_IDS)[number]; +export const catalogSectionIdSchema = z.enum(CATALOG_SECTION_IDS); + export const FACET_GROUP_IDS = [ "size", "orgType", diff --git a/tests/unit/createFlowMethodsRoute.test.ts b/tests/unit/createFlowMethodsRoute.test.ts index 082f9c9..8ab8964 100644 --- a/tests/unit/createFlowMethodsRoute.test.ts +++ b/tests/unit/createFlowMethodsRoute.test.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listCatalogMethods } from "../../lib/server/governanceCatalog"; const findManyMock = vi.fn(); @@ -21,6 +22,8 @@ function makeReq(url: string) { return new NextRequest(url); } +const communicationCatalog = listCatalogMethods("communication"); + beforeEach(() => { findManyMock.mockReset(); }); @@ -50,11 +53,35 @@ describe("GET /api/create-flow/methods", () => { expect(body2.error.code).toBe("validation_error"); }); - it("returns ranked methods from the facet query", async () => { + it("returns full catalog with copy when no facets are passed", async () => { + const res = await GET( + makeReq( + "https://x.test/api/create-flow/methods?section=communication", + ), + undefined, + ); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toContain("max-age=3600"); + const json = (await res.json()) as { + section: string; + methods: Array<{ + slug: string; + label: string; + description: string; + matches?: unknown; + }>; + }; + expect(json.section).toBe("communication"); + expect(json.methods.length).toBe(communicationCatalog.length); + expect(json.methods[0].label).toBeTruthy(); + expect(json.methods[0].matches).toBeUndefined(); + }); + + it("returns ranked full deck with matches from the facet query", async () => { findManyMock.mockResolvedValueOnce([ { slug: "loomio", group: "size", value: "twoToFive" }, { slug: "loomio", group: "orgType", value: "workersCoop" }, - { slug: "in-person", group: "size", value: "twoToFive" }, + { slug: "in-person-meetings", group: "size", value: "twoToFive" }, ]); const res = await GET( makeReq( @@ -65,14 +92,22 @@ describe("GET /api/create-flow/methods", () => { expect(res.status).toBe(200); const json = (await res.json()) as { section: string; - methods: { slug: string; matches: { score: number } }[]; + methods: Array<{ + slug: string; + label: string; + matches: { score: number }; + }>; }; expect(json.section).toBe("communication"); - expect(json.methods.map((m) => m.slug)).toEqual(["loomio", "in-person"]); + expect(json.methods.length).toBe(communicationCatalog.length); + expect(json.methods[0].slug).toBe("loomio"); expect(json.methods[0].matches.score).toBe(2); + expect(json.methods[1].slug).toBe("in-person-meetings"); + expect(json.methods[1].matches.score).toBe(1); + expect(json.methods[0].label).toBeTruthy(); }); - it("returns empty methods when the DB query throws (caller falls back)", async () => { + it("returns full catalog without matches when the DB query throws", async () => { findManyMock.mockRejectedValueOnce(new Error("db down")); const res = await GET( makeReq( @@ -81,7 +116,39 @@ describe("GET /api/create-flow/methods", () => { undefined, ); expect(res.status).toBe(200); - const json = (await res.json()) as { methods: unknown[] }; - expect(json.methods).toEqual([]); + const json = (await res.json()) as { + methods: Array<{ slug: string; matches?: unknown }>; + }; + expect(json.methods.length).toBe(communicationCatalog.length); + expect(json.methods[0].matches).toBeUndefined(); + }); + + it("returns all core values for section=coreValues", async () => { + const res = await GET( + makeReq( + "https://x.test/api/create-flow/methods?section=coreValues", + ), + undefined, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + section: string; + methods: Array<{ id: string; label: string; meaning: string }>; + }; + expect(json.section).toBe("coreValues"); + expect(json.methods.length).toBeGreaterThan(50); + expect(json.methods[0].id).toBe("1"); + expect(json.methods[0].label).toBeTruthy(); + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it("accepts values as an alias for coreValues", async () => { + const res = await GET( + makeReq("https://x.test/api/create-flow/methods?section=values"), + undefined, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { section: string }; + expect(json.section).toBe("coreValues"); }); }); diff --git a/tests/unit/governanceCatalog.test.ts b/tests/unit/governanceCatalog.test.ts new file mode 100644 index 0000000..d4167fc --- /dev/null +++ b/tests/unit/governanceCatalog.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + catalogMethodSlugsForSection, + getCatalogCoreValue, + getCatalogMethod, + listCatalogCoreValues, + listCatalogMethods, +} from "../../lib/server/governanceCatalog"; +import { SECTION_IDS, type SectionId } from "../../lib/server/validation/methodFacetsSchemas"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +const SECTION_TO_MESSAGES_FILE: Record = { + communication: "messages/en/create/customRule/communication.json", + membership: "messages/en/create/customRule/membership.json", + decisionApproaches: "messages/en/create/customRule/decisionApproaches.json", + conflictManagement: "messages/en/create/customRule/conflictManagement.json", +}; + +function readJson(rel: string): T { + return JSON.parse(readFileSync(path.join(REPO_ROOT, rel), "utf8")) as T; +} + +describe("governanceCatalog (CR-115)", () => { + for (const section of SECTION_IDS) { + it(`${section}: catalog slugs match messages methods one-to-one`, () => { + const messages = readJson<{ methods: { id: string }[] }>( + SECTION_TO_MESSAGES_FILE[section], + ); + const messageSlugs = messages.methods.map((m) => m.id); + const catalogSlugs = catalogMethodSlugsForSection(section); + expect(catalogSlugs).toEqual(messageSlugs); + }); + + it(`${section}: each method has label, description, and sections`, () => { + const methods = listCatalogMethods(section); + expect(methods.length).toBeGreaterThan(0); + const first = methods[0]; + expect(first.slug).toBeTruthy(); + expect(first.label).toBeTruthy(); + expect(typeof first.description).toBe("string"); + expect(first.sections).toBeDefined(); + expect(getCatalogMethod(section, first.slug)).toEqual(first); + }); + } + + it("core values: ids are 1-based positions with copy fields", () => { + const values = listCatalogCoreValues(); + const messages = readJson<{ + values: Array; + }>("messages/en/create/customRule/coreValues.json"); + expect(values.length).toBe(messages.values.length); + expect(values[0].id).toBe("1"); + expect(values[0].label).toBeTruthy(); + expect(typeof values[0].meaning).toBe("string"); + expect(typeof values[0].signals).toBe("string"); + expect(getCatalogCoreValue("1")).toEqual(values[0]); + }); +}); diff --git a/tests/unit/methodFacets.test.ts b/tests/unit/methodFacets.test.ts index 81ef801..44b6b63 100644 --- a/tests/unit/methodFacets.test.ts +++ b/tests/unit/methodFacets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { catalogMethodSlugsForSection } from "../../lib/server/governanceCatalog"; import { FACET_GROUP_IDS, FACET_VALUE_IDS_BY_GROUP, @@ -42,6 +43,9 @@ describe("data/create/customRule parity (CR-88)", () => { expect(onlyInMessages, `${section} slugs missing from data/`).toEqual([]); expect(onlyInData, `${section} slugs missing from messages/`).toEqual([]); + + const catalogSlugs = catalogMethodSlugsForSection(section); + expect(catalogSlugs).toEqual([...messageSlugs]); }); } }); diff --git a/tests/unit/templatesBySlugRoute.test.ts b/tests/unit/templatesBySlugRoute.test.ts new file mode 100644 index 0000000..eee6606 --- /dev/null +++ b/tests/unit/templatesBySlugRoute.test.ts @@ -0,0 +1,75 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findUniqueMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => true, +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + ruleTemplate: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + }, + }, +})); + +import { GET } from "../../app/api/templates/[slug]/route"; + +function makeReq(url: string) { + return new NextRequest(url); +} + +const consensusTemplate = { + id: "tpl-1", + slug: "consensus", + title: "Consensus", + category: null, + description: "Desc", + body: { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Loomio", body: "" }], + }, + ], + }, + sortOrder: 0, + featured: true, +}; + +beforeEach(() => { + findUniqueMock.mockReset(); +}); + +describe("GET /api/templates/[slug]", () => { + it("404s when slug is unknown", async () => { + findUniqueMock.mockResolvedValueOnce(null); + const res = await GET( + makeReq("https://x.test/api/templates/unknown"), + { params: Promise.resolve({ slug: "unknown" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns template and composed methods for a known slug", async () => { + findUniqueMock.mockResolvedValueOnce(consensusTemplate); + const res = await GET( + makeReq("https://x.test/api/templates/consensus"), + { params: Promise.resolve({ slug: "consensus" }) }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toContain("max-age=3600"); + const json = (await res.json()) as { + template: { slug: string }; + methods: Array<{ section: string; slug: string }>; + }; + expect(json.template.slug).toBe("consensus"); + expect(json.methods.length).toBeGreaterThan(0); + expect(json.methods[0]).toMatchObject({ + section: "communication", + slug: "loomio", + }); + }); +}); -- 2.43.0