Merge pull request 'Public catalog API: templates, methods, and core values' (#54) from adilallo/PublicAPI into main
Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
+2
-1
@@ -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. |
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 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/<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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user