Add public API for methods and values

This commit is contained in:
adilallo
2026-05-22 14:32:15 -06:00
parent cef7c98205
commit 9e11063a11
14 changed files with 727 additions and 134 deletions
+2 -1
View File
@@ -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.<group>=<value>` 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. |
+88 -19
View File
@@ -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<string>(SECTION_IDS);
const CATALOG_SECTION_SET = new Set<string>(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<string, MethodMatch> | null,
includeMatches: boolean,
): Array<CatalogMethodDto & { matches?: MethodMatch }> {
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=<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.<section>.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<string, MethodMatch> | 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 } },
);
},
);
+39
View File
@@ -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<RouteContext>(
"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 } },
);
},
);
+27
View File
@@ -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.
+131 -104
View File
@@ -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/<section>.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.<section>.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/<section>.json`
@@ -48,43 +50,22 @@ the Zod schema at seed time and in CI (see §3 — no app-boot validator).
Lives **outside** `messages/<locale>/` 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.<section>` becomes `m.create.<stage>.<section>`. 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/<section>.json`) and `useMessages()` namespaces
(`m.create.customRule.<section>`) 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.<section>` callsite to
`useMessages().create.<stage>.<section>`, 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.<stage>.<section>`. Facet JSON paths
(`data/create/customRule/<section>.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/<section>.json` (post-reorg) and are read
`messages/en/create/customRule/<section>.json` and are read
via `useMessages().create.customRule.<section>` (`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=<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.<section>.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<string, unknown>;
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.<section>.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.<section>.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/<section>.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/<section>.json` entry and
- [x] Editing a `data/create/customRule/<section>.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` model7).
- `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.19.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/<section>.json`; the
trailing 19 columns hold the facet matches that need to land in
`data/create/customRule/<section>.json`. After CR-88 lands, future
facet edits happen directly in the JSON files — the workbooks are
`data/create/customRule/<section>.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.
+8
View File
@@ -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 = {
+49 -2
View File
@@ -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<TemplateDetailDto | null | { error: string }> {
const encoded = encodeURIComponent(slug);
try {
const res = await fetch(`/api/templates/${encoded}`, {
credentials: "include",
signal: options?.signal,
});
const data = (await res.json()) as TemplateDetailDto & {
error?: string | { message?: string };
};
if (!res.ok) {
if (res.status === 404) return null;
const err = data.error;
const message =
typeof err === "string"
? err
: err && typeof err === "object" && typeof err.message === "string"
? err.message
: "Could not load template";
return { error: message };
}
if (!data.template || typeof data.template.slug !== "string") {
return { error: "Could not load template" };
}
return {
template: data.template,
methods: Array.isArray(data.methods) ? data.methods : [],
};
} catch (e) {
if (isAbortError(e)) {
throw new DOMException("Aborted", "AbortError");
}
return { error: "Could not load template" };
}
}
export async function fetchTemplateBySlug(
slug: string,
options?: FetchTemplatesOptions,
): Promise<RuleTemplateDto | null | { error: string }> {
const result = await 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;
}
+144
View File
@@ -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<string, unknown>;
};
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<MethodFacetApiSectionId, unknown> = {
communication: communicationMessages,
membership: membershipMessages,
decisionApproaches: decisionApproachesMessages,
conflictManagement: conflictManagementMessages,
};
function readMethodRows(source: unknown): Array<{
id: string;
label: string;
supportText?: string;
sections?: Record<string, unknown>;
}> {
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<string, unknown>;
}> = [];
for (const raw of methods) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
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<string, unknown>)
: undefined,
});
}
return out;
}
function rowToCatalogMethod(row: {
id: string;
label: string;
supportText?: string;
sections?: Record<string, unknown>;
}): 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<string, unknown>;
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);
}
+17
View File
@@ -45,6 +45,23 @@ export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
}
}
/** Single curated template by slug; `null` when missing or DB unavailable. */
export async function getRuleTemplateBySlug(
slug: string,
): Promise<RuleTemplateDto | null> {
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[];
+8 -1
View File
@@ -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",
+74 -7
View File
@@ -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");
});
});
+61
View File
@@ -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<SectionId, string> = {
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<T>(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<string | { label: string }>;
}>("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]);
});
});
+4
View File
@@ -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]);
});
}
});
+75
View File
@@ -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",
});
});
});