843 lines
38 KiB
Markdown
843 lines
38 KiB
Markdown
# Recommendation Matrix — Implementation Context (CR-88)
|
||
|
||
**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.
|
||
|
||
This doc documents the **method facet matrix** that powers ranking from shared facet data:
|
||
|
||
1. **Create-flow card ranking (shipped)** — the four card-deck wizard steps
|
||
(`communication-methods`, `membership-methods`, `decision-approaches`,
|
||
`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)**.
|
||
|
||
> **Scope note:** Card / modal copy lives in
|
||
> `messages/en/create/customRule/*.json` as flat `methods` arrays (one
|
||
> entry per method, with `id`, `label`, `supportText`, `recommended`,
|
||
> and a `sections` map). The matrix layer adds **facet data** — which
|
||
> methods match which user facets — as committed JSON in
|
||
> `data/create/customRule/*.json`, validated by Zod and seeded into
|
||
> `MethodFacet`. There is no spreadsheet importer, no `xlsx` runtime
|
||
> dependency, and the spreadsheets in `~/Downloads/` were a **one-time
|
||
> authoring artifact** — they are not part of the runtime contract.
|
||
|
||
---
|
||
|
||
## 1. Where things live (post-reorg)
|
||
|
||
### 1a. Card / modal copy — `messages/en/create/customRule/<section>.json`
|
||
|
||
Source of truth for all displayed text. Each file holds the page chrome
|
||
(`page`, `confirmModal`, `addPlatform`/`addApproach`, `sectionHeadings`,
|
||
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.
|
||
|
||
### 1b. Facet data — `data/create/customRule/<section>.json`
|
||
|
||
Mirrors the messages path one-to-one (same filename, different root) so a
|
||
content reviewer can read the two side-by-side. Each file is a typed JSON
|
||
object mapping each method `id` → its facet matches across the four
|
||
canonical groups (`size`, `orgType`, `scale`, `maturity`). Validated by
|
||
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)
|
||
|
||
Today every `messages/en/create/*.json` file sits flat in one folder.
|
||
Plan: regroup into the three Figma stages from
|
||
[`docs/create-flow.md`](../create-flow.md) §"Product stages":
|
||
|
||
| New 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.
|
||
|
||
---
|
||
|
||
## 2. Facet groups (the canonical 19-column matrix)
|
||
|
||
Four facet groups, 19 values total, identical across all four sections.
|
||
These are the only dimensions the recommendation engine scores against.
|
||
|
||
| Group | Value `id`s (canonical lowercase keys) | Wizard chip labels | `CreateFlowState` field |
|
||
| --- | --- | --- | --- |
|
||
| `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `1 member`, `2-5 members`, `6-12 members`, `13-100 members`, `100-100,000 members` | `selectedCommunitySizeIds` |
|
||
| `orgType` | `dao`, `forProfit`, `nonprofit`, `openSource`, `mutualAid`, `workersCoop` | `DAO`, `For profit business`, `Nonprofit`, `Open source project`, `Mutual aid`, `Worker's coop` | `selectedOrganizationTypeIds` |
|
||
| `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` |
|
||
| `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` |
|
||
|
||
**Wizard chip ids vs facet value keys.** Wizard chips today use positional
|
||
1..N ids inside their messages files (see `chipRowsFromLabels` in
|
||
`app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` and
|
||
`CommunitySizeSelectScreen.tsx`). The recommendation layer needs a
|
||
**stable lookup** from those positional chip ids to the canonical facet
|
||
value keys above. Live that lookup table in
|
||
`data/create/customRule/_facetGroups.json` so every consumer sees the
|
||
same mapping.
|
||
|
||
Shape: a single object keyed by group, then by the canonical value id,
|
||
with the wizard chip's positional id and source messages path. The
|
||
chip's display label is **not** duplicated here — it stays in the
|
||
messages file (the lookup is just position → canonical id). Order in
|
||
each group's `values` follows §2's canonical key order, **not** the
|
||
chip's positional order in the messages file:
|
||
|
||
```json
|
||
{
|
||
"size": {
|
||
"source": "messages/en/create/community/communitySize.json#/communitySizes",
|
||
"values": {
|
||
"oneMember": { "chipId": "1" },
|
||
"twoToFive": { "chipId": "2" },
|
||
"sixToTwelve": { "chipId": "3" },
|
||
"thirteenToOneHundred": { "chipId": "4" },
|
||
"oneHundredToOneHundredK": { "chipId": "5" }
|
||
}
|
||
},
|
||
"orgType": {
|
||
"source": "messages/en/create/community/communityStructure.json#/organizationTypes",
|
||
"values": {
|
||
"workersCoop": { "chipId": "1" },
|
||
"mutualAid": { "chipId": "2" },
|
||
"openSource": { "chipId": "3" },
|
||
"nonprofit": { "chipId": "4" },
|
||
"forProfit": { "chipId": "5" },
|
||
"dao": { "chipId": "6" }
|
||
}
|
||
},
|
||
"scale": {
|
||
"source": "messages/en/create/community/communityStructure.json#/scaleOptions",
|
||
"values": {
|
||
"local": { "chipId": "1" },
|
||
"regional": { "chipId": "2" },
|
||
"national": { "chipId": "3" },
|
||
"global": { "chipId": "4" }
|
||
}
|
||
},
|
||
"maturity": {
|
||
"source": "messages/en/create/community/communityStructure.json#/maturityOptions",
|
||
"values": {
|
||
"earlyStage": { "chipId": "1" },
|
||
"growthStage": { "chipId": "2" },
|
||
"established": { "chipId": "3" },
|
||
"enterprise": { "chipId": "4" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Validated by the same Zod schema module as the facet files
|
||
(`lib/server/validation/methodFacetsSchemas.ts`). The parity test in §12
|
||
asserts every `chipId` matches an actual position in the referenced
|
||
messages file (off-by-one fails loudly). Adding a chip in messages
|
||
without updating this file → schema error.
|
||
|
||
---
|
||
|
||
## 3. Method inventory per section
|
||
|
||
Every method's slug must exist as a `methods[].id` in the corresponding
|
||
messages file *and* as a key in the corresponding facet file. Parity is
|
||
enforced at two points (no app-boot hook):
|
||
|
||
- **`prisma db seed`** — `loadSectionFacets()` runs the Zod schema
|
||
against each `data/create/customRule/<section>.json` file before
|
||
upserting; any orphan slug or unknown facet value fails the seed.
|
||
- **`tests/unit/methodFacets.test.ts`** — runs in CI on every PR;
|
||
re-asserts the same parity statically (no DB needed) so authoring
|
||
errors are caught before the seed ever runs.
|
||
|
||
There is intentionally **no Next.js app-boot validator** — schema
|
||
failures should surface in CI/seed, not at request time.
|
||
|
||
| Section | Screen | Messages file | Facet file | Method count |
|
||
| --- | --- | --- | --- | --- |
|
||
| `communication` | `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` | `messages/en/create/customRule/communication.json` | `data/create/customRule/communication.json` | 11 |
|
||
| `membership` | `app/(app)/create/screens/card/MembershipMethodsScreen.tsx` | `messages/en/create/customRule/membership.json` | `data/create/customRule/membership.json` | 19 |
|
||
| `decisionApproaches` | `app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx` | `messages/en/create/customRule/decisionApproaches.json` | `data/create/customRule/decisionApproaches.json` | 32 |
|
||
| `conflictManagement` | `app/(app)/create/screens/card/ConflictManagementScreen.tsx` | `messages/en/create/customRule/conflictManagement.json` | `data/create/customRule/conflictManagement.json` | 19 |
|
||
|
||
`section` keys above are the canonical lowercase camelCase tokens used in
|
||
the DB, the API query string, and the Zod schemas. The four xlsx
|
||
authoring artifacts in `~/Downloads/` (see "Source workbooks" appendix
|
||
below) are the human reference for which methods match which facets;
|
||
they do not ship.
|
||
|
||
---
|
||
|
||
## 4. Existing data model & wizard surface area
|
||
|
||
### 4.1 `RuleTemplate` (today, unchanged)
|
||
|
||
```64:73:prisma/schema.prisma
|
||
model RuleTemplate {
|
||
id String @id @default(cuid())
|
||
slug String @unique
|
||
title String
|
||
category String?
|
||
description String?
|
||
body Json
|
||
sortOrder Int @default(0)
|
||
featured Boolean @default(false)
|
||
}
|
||
```
|
||
|
||
`body` JSON is the rendered rule document
|
||
(`{ sections: [{ categoryName, entries: [{ title, body }] }, ...] }`),
|
||
authored today by `bodyFromXlsxComposition()` in `prisma/seed.ts` from
|
||
the hand-typed `COMPOSITION_BY_SLUG` map. **Stays hand-curated** — the
|
||
matrix doesn't change how templates are authored, only how they are
|
||
ranked. Section ordering (Values → Communication → Membership →
|
||
Decision-making → Conflict management) is set by `governancePatternBody`
|
||
and is canonical.
|
||
|
||
### 4.2 Wizard facets captured today (`CreateFlowState`)
|
||
|
||
```ts
|
||
selectedCommunitySizeIds?: string[];
|
||
selectedOrganizationTypeIds?: string[];
|
||
selectedScaleIds?: string[];
|
||
selectedMaturityIds?: string[];
|
||
selectedCoreValueIds?: string[];
|
||
selectedCommunicationMethodIds?: string[];
|
||
selectedMembershipMethodIds?: string[];
|
||
selectedDecisionApproachIds?: string[];
|
||
selectedConflictManagementIds?: string[];
|
||
```
|
||
|
||
The first four are exactly the four facet groups in §2. The last four
|
||
are the user's chosen methods per section (used to pre-rank or to feed
|
||
recommendations on later steps). `createFlowStateSchema` in
|
||
`lib/server/validation/createFlowSchemas.ts` is the canonical Zod schema
|
||
— recommendation-side schemas should import the facet-id arrays from
|
||
there rather than redefine them.
|
||
|
||
### 4.3 Wizard step order
|
||
|
||
Source of truth: `app/(app)/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`).
|
||
Relevant slice for the matrix:
|
||
|
||
```
|
||
review → core-values → communication-methods → membership-methods →
|
||
decision-approaches → conflict-management → confirm-stakeholders → final-review
|
||
```
|
||
|
||
### 4.4 Where templates surface in the UI (in scope for ranking)
|
||
|
||
| Surface | File |
|
||
| --- | --- |
|
||
| Marketing home "Popular templates" | `app/(marketing)/_components/MarketingRuleStackSection.tsx` |
|
||
| 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) |
|
||
|
||
**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**
|
||
now prefills the custom-rule flow via
|
||
[`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts)
|
||
(applied in `CreateFlowLayoutClient.tsx`) and routes to `core-values`
|
||
when Community already has input, else to `informational`. Template **Use
|
||
without changes** writes `template.body.sections` into `state.sections`
|
||
and routes to `confirm-stakeholders`, so the user exits via the normal
|
||
`final-review → handleFinalize → publishRule` path and picks up the
|
||
server-enforced 401 sign-in gate for free.
|
||
|
||
When the user picks a template **before** completing the community
|
||
stage, both handlers still apply their side effects eagerly (prefill or
|
||
`sections`/`summary`) and pin a
|
||
`pendingTemplateAction: { slug, mode: "customize" | "useWithoutChanges" }`
|
||
on `CreateFlowState`, then route to `informational`. Once the user
|
||
reaches `/create/review`, `CommunityReviewScreen` consumes the pin and
|
||
`router.replace`s past itself — to `core-values` for `customize`, to
|
||
`confirm-stakeholders` for `useWithoutChanges`. The community-review
|
||
screen is therefore only shown when the user came from "Create Custom"
|
||
(no template), matching the intent already expressed at the
|
||
template-review step.
|
||
|
||
---
|
||
|
||
## 5. Repo conventions to follow (don't reinvent)
|
||
|
||
References point at the canonical example for each.
|
||
|
||
### 5.1 API routes (`app/api/**/route.ts`)
|
||
|
||
`app/api/drafts/me/route.ts` is the reference. Every new route in this
|
||
feature must match:
|
||
|
||
1. `if (!isDatabaseConfigured()) return dbUnavailable();` — always first
|
||
(`lib/server/env.ts`, `lib/server/responses.ts`).
|
||
2. For auth'd routes: `const user = await getSessionUser();` then
|
||
`NextResponse.json({ error: "Unauthorized" }, { status: 401 })` if
|
||
missing. Recommendation read endpoints stay unauthenticated.
|
||
3. Request bodies: `readLimitedJson(request)` →
|
||
`<schema>.safeParse(parsed.value)` → `jsonFromZodError(validated.error)`
|
||
on failure (`lib/server/validation/requestBody.ts`,
|
||
`lib/server/validation/zodHttp.ts`).
|
||
4. Success: `NextResponse.json({ <key>: data })` — flat object, no
|
||
`success: true` envelope.
|
||
5. Errors: structured `{ error: { code, message } }` (Zod path) or simple
|
||
`{ error: "..." }` (auth path).
|
||
6. Server-side query helpers swallow Prisma failures and return
|
||
`[]`/`null` (see `listRuleTemplatesFromDb` in
|
||
`lib/server/ruleTemplates.ts`). Routes do **not** wrap helper calls
|
||
in `try/catch`.
|
||
|
||
### 5.2 Zod schemas live in `lib/server/validation/`
|
||
|
||
- One file per feature area
|
||
(`lib/server/validation/methodFacetsSchemas.ts` for this feature).
|
||
- Export the schema **and** the inferred type
|
||
(`export type X = z.infer<typeof xSchema>`).
|
||
- Reuse facet-id arrays from `createFlowStateSchema` rather than
|
||
redeclaring them.
|
||
|
||
### 5.3 Prisma access
|
||
|
||
- Singleton: `import { prisma } from "lib/server/db";` — never
|
||
`new PrismaClient()` from app code.
|
||
- Server-only fetch/list helpers live under `lib/server/<feature>.ts`,
|
||
return DTOs (not raw Prisma rows), and degrade gracefully
|
||
(`isDatabaseConfigured()` short-circuit, `try/catch` → empty result).
|
||
- Use `prisma.$transaction` for the seed step that swaps facet rows
|
||
(delete-then-create per `(section, slug)` pair atomically).
|
||
|
||
### 5.4 DTO style
|
||
|
||
Hand-written `type` aliases that mirror a Prisma `select` clause,
|
||
co-located with the consumer (see `RuleTemplateDto` in
|
||
`lib/create/fetchTemplates.ts`).
|
||
|
||
### 5.5 Tests
|
||
|
||
- Vitest under `tests/unit/*.test.ts` for schemas and pure functions
|
||
(see `tests/unit/createFlowValidation.test.ts`).
|
||
- Test the facet JSON files themselves with a parity test
|
||
(`tests/unit/methodFacets.test.ts`) that loads each `data/` file
|
||
alongside its messages file and asserts every `methods[].id` has a
|
||
facet entry and vice-versa.
|
||
- API routes are not unit-tested today; cover behavior indirectly via
|
||
schema tests.
|
||
|
||
### 5.6 Logging
|
||
|
||
Use `logger` from `lib/logger.ts` for any server-side info/warn/error in
|
||
scripts and route helpers. No `apiError` helper exists; do not introduce
|
||
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
|
||
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 —
|
||
so Storybook stories, MSW handlers, and Vitest specs keep importing the
|
||
message JSON statically and don't need an API mock for content.
|
||
|
||
### 5.8 Component-prop normalization rule
|
||
|
||
The repo's lowercase prop normalization rule
|
||
(`.cursor/rules/component-props.mdc`) applies to **React component props
|
||
only**. It does **not** apply to API query params, DB columns, JSON
|
||
keys, or messages keys. Conventions used by this feature: facet group
|
||
ids and facet values are camelCase (`orgType`, `workersCoop`); messages
|
||
keys are camelCase (`stepByStepInstructions`, `restorationFallbacks`);
|
||
method slugs are kebab-case (`peer-mediation`, `lazy-consensus`).
|
||
|
||
---
|
||
|
||
## 6. Authoring contract (the JSON shape)
|
||
|
||
Each `data/create/customRule/<section>.json` file is a single object
|
||
keyed by method slug. Each entry has the four facet groups, each holding
|
||
booleans (or numeric weights, optional) per canonical value id.
|
||
|
||
```ts
|
||
type FacetMatch = boolean | { match: boolean; weight?: number };
|
||
|
||
type SectionFacets = Record<
|
||
string, // method slug, e.g. "peer-mediation"
|
||
{
|
||
size: Record<"oneMember" | "twoToFive" | "sixToTwelve" | "thirteenToOneHundred" | "oneHundredToOneHundredK", FacetMatch>;
|
||
orgType: Record<"dao" | "forProfit" | "nonprofit" | "openSource" | "mutualAid" | "workersCoop", FacetMatch>;
|
||
scale: Record<"global" | "national" | "regional" | "local", FacetMatch>;
|
||
maturity: Record<"earlyStage" | "growthStage" | "established" | "enterprise", FacetMatch>;
|
||
}
|
||
>;
|
||
```
|
||
|
||
Example (illustrative, not a real entry):
|
||
|
||
```json
|
||
{
|
||
"peer-mediation": {
|
||
"size": { "oneMember": false, "twoToFive": true, "sixToTwelve": true, "thirteenToOneHundred": true, "oneHundredToOneHundredK": false },
|
||
"orgType": { "dao": false, "forProfit": false, "nonprofit": true, "openSource": true, "mutualAid": true, "workersCoop": true },
|
||
"scale": { "global": false, "national": false, "regional": true, "local": true },
|
||
"maturity": { "earlyStage": true, "growthStage": true, "established": true, "enterprise": false }
|
||
}
|
||
}
|
||
```
|
||
|
||
Bulk shorthand: a field that's omitted defaults to `false`, so files
|
||
only need to enumerate matching facets when authors prefer the compact
|
||
form. The Zod schema (run at seed time and in the parity test, per §3)
|
||
fills defaults and enforces:
|
||
|
||
- Every key in the file matches a `methods[].id` in the corresponding
|
||
messages file (no orphans either way — fail loudly).
|
||
- Every facet group is present for every method (or fully omitted, in
|
||
which case it defaults to "all false").
|
||
- Facet values are exactly the canonical ids in §2 (typos fail).
|
||
|
||
### One-time transcription from the workbooks
|
||
|
||
The four `.xlsx` files in `~/Downloads/` (see appendix) carry the facet
|
||
matches as the trailing 19 columns after the descriptive copy columns
|
||
(slug, label, supportText, applicableScope, consensusLevel, etc.). The
|
||
implementing agent should:
|
||
|
||
1. Inspect one workbook with `openpyxl` to confirm the column order
|
||
matches §2 (`size` × 5, `orgType` × 6, `scale` × 4, `maturity` × 4 =
|
||
19 facet columns). The copy ingest already confirmed the leading
|
||
columns; the trailing 19 are the only new bit.
|
||
2. Write a one-shot Python script (mirror of the throwaway
|
||
`/tmp/ingest_methods.py` used for the messages ingest — **do not
|
||
commit**) that:
|
||
- Reads each workbook's `Current` sheet,
|
||
- Slugifies the first column the same way the messages ingest did
|
||
(kebab-case, ASCII-folded, lowercase) so keys match
|
||
`methods[].id` exactly,
|
||
- Treats `✓` (and `1`, `true`, `yes` — case-insensitive) as `true` and
|
||
everything else (`x`, blank, `-`, `0`, `false`, `no`, `✗`) as `false`.
|
||
The workbooks intentionally use `✓` to mark a match and `x` to mark a
|
||
non-match (cross-out) — both are filled cells but only `✓` counts as
|
||
a positive recommendation,
|
||
- Emits one `data/create/customRule/<section>.json` per workbook in
|
||
the §6 shape, sorted by method slug,
|
||
- Emits `data/create/customRule/_facetGroups.json` from the column
|
||
header → canonical-id mapping above (§2).
|
||
3. Hand-review the diff against the workbook for the first ~3 methods
|
||
per section, then commit the JSON files.
|
||
4. Run `tests/unit/methodFacets.test.ts` (§12) to confirm parity with
|
||
the messages files, and `prisma db seed` to confirm the schema +
|
||
transaction work.
|
||
|
||
The script is throwaway. Future facet edits happen by hand in the JSON
|
||
files. The workbooks stay at `~/Downloads/` and are **not** committed —
|
||
the post-ingest JSON files (both `messages/` and `data/`) are the
|
||
historical record.
|
||
|
||
---
|
||
|
||
## 7. Storage
|
||
|
||
Facet data lives in the JSON files above (the source of truth) and is
|
||
**hydrated into the DB at seed time** so the API can do efficient joins
|
||
when ranking templates by composed-method match. The JSON is canonical —
|
||
the DB is a derived index.
|
||
|
||
```prisma
|
||
model MethodFacet {
|
||
id String @id @default(cuid())
|
||
section String // communication | membership | decisionApproaches | conflictManagement
|
||
slug String // matches the `id` of an entry in the messages file's `methods` array
|
||
group String // size | orgType | scale | maturity
|
||
value String // e.g. "workersCoop"
|
||
matches Boolean // ✓ → true, blank/explicit-false → false
|
||
weight Float? // optional numeric override for future scoring tweaks
|
||
@@unique([section, slug, group, value])
|
||
@@index([section])
|
||
@@index([group, value, matches])
|
||
}
|
||
```
|
||
|
||
Why this shape:
|
||
|
||
- **JSON is the source of truth** — diffs are reviewable in PRs, no
|
||
binary artifacts, no importer to maintain. `prisma db seed` (or the
|
||
facet-only equivalent script) reads the JSON and upserts rows.
|
||
- **DB enables joins** — template ranking joins `MethodFacet` against
|
||
the methods listed in `RuleTemplate.body` to compute per-template
|
||
match scores. In-memory ranking would also work but the join is
|
||
cleaner and matches existing patterns.
|
||
- **Slug is the contract** — every JSON key matches a `methods[].id` in
|
||
messages; boot-time validation enforces this both ways.
|
||
- **No runtime spreadsheet dependency** — `xlsx` / SheetJS is **not**
|
||
added to `package.json`. The original `~/Downloads/*.xlsx` files
|
||
authored both the messages content (already ingested) and the facet
|
||
matches (one-time transcription into the JSON files described in §6).
|
||
Future facet edits happen directly in the JSON.
|
||
|
||
---
|
||
|
||
## 8. Seed / sync
|
||
|
||
`prisma/seed.ts` (or a small co-located helper at
|
||
`prisma/seed/methodFacets.ts`) does:
|
||
|
||
```ts
|
||
async function seedMethodFacets() {
|
||
const sections: Section[] = [
|
||
"communication",
|
||
"membership",
|
||
"decisionApproaches",
|
||
"conflictManagement",
|
||
];
|
||
|
||
for (const section of sections) {
|
||
const facets = await loadSectionFacets(section); // reads + Zod-validates the JSON
|
||
await prisma.$transaction([
|
||
prisma.methodFacet.deleteMany({ where: { section } }),
|
||
prisma.methodFacet.createMany({
|
||
data: flattenSectionFacets(section, facets),
|
||
}),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- **Validation runs first.** `loadSectionFacets` reads the JSON and runs
|
||
the Zod schema against it; any orphan slug, missing facet group, or
|
||
unknown facet value fails the seed.
|
||
- **Per-section atomic swap.** Delete-then-create inside one
|
||
transaction so the DB is never partially populated.
|
||
- **No flags, no `--dry-run`, no fixture workbooks.** The seed is
|
||
idempotent and cheap (~80 methods × 19 facets = ~1500 rows total);
|
||
re-running on every prisma seed is fine.
|
||
- **No app-boot validator.** Authoring errors surface in CI (parity
|
||
test, §3) or at `prisma db seed` time — never at request time.
|
||
|
||
---
|
||
|
||
## 9. APIs
|
||
|
||
Both endpoints follow §5.1 conventions. **Neither returns copy** — copy
|
||
lives in messages and is read client-side via `useMessages()`.
|
||
|
||
### 9.1 `GET /api/templates` (rewrite)
|
||
|
||
Optional facet query params:
|
||
|
||
- `facet.size=<valueId>` (repeatable)
|
||
- `facet.orgType=<valueId>`
|
||
- `facet.scale=<valueId>`
|
||
- `facet.maturity=<valueId>`
|
||
|
||
Behavior:
|
||
|
||
- No params → existing curated ordering (`featured`, `sortOrder`,
|
||
`title`), no scoring.
|
||
- With facets → score each template by joining its `body`-referenced
|
||
method slugs against `MethodFacet` and **summing matches**, then
|
||
sort highest-first.
|
||
|
||
**Scoring algorithm (simple count, v1).**
|
||
|
||
For each template, build the set of `(section, slug)` pairs from
|
||
`RuleTemplate.body` (the existing curated composition). For each
|
||
`(section, slug)` pair, count how many of the requested
|
||
`facet.<group>=<value>` query params match an existing
|
||
`MethodFacet { matches: true }` row. The template's score is the
|
||
**sum across all its methods**:
|
||
|
||
```
|
||
score(template)
|
||
= Σ over (section, slug) in template.body:
|
||
Σ over (group, value) in requested facets:
|
||
1 if MethodFacet exists with matches=true for (section, slug, group, value)
|
||
else 0
|
||
```
|
||
|
||
Notes:
|
||
- A template with five matching methods scores ~5× a template with one
|
||
matching method, which is the desired bias toward whole-stack fit.
|
||
- `weight` in `MethodFacet` is **ignored** by v1 — it's only there so
|
||
authors can store nuance for a future weighted-rank pass without a
|
||
migration.
|
||
- Ties broken by today's curated ordering (`featured` desc → `sortOrder`
|
||
asc → `title` asc) so no-facets and zero-match cases produce identical
|
||
output to the existing endpoint.
|
||
- Templates with `score = 0` are still returned, ranked last by the
|
||
curated tie-break (recommendations rank, never hide; see §10).
|
||
|
||
Response:
|
||
|
||
```ts
|
||
{
|
||
templates: RuleTemplateDto[],
|
||
scores?: Record<string, { score: number; matchedFacets: string[] }>
|
||
}
|
||
```
|
||
|
||
`scores[slug].matchedFacets` is the deduped list of
|
||
`"<section>:<slug>:<group>:<value>"` keys that contributed, useful for
|
||
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).
|
||
|
||
Response:
|
||
|
||
```ts
|
||
{
|
||
section: "communication" | "membership" | "decisionApproaches" | "conflictManagement",
|
||
methods: Array<{
|
||
slug: string;
|
||
matches: { score: number; matchedFacets: string[] };
|
||
}>
|
||
}
|
||
```
|
||
|
||
**Scoring algorithm.** Same simple count as §9.1, scoped to a single
|
||
method:
|
||
|
||
```
|
||
score(method)
|
||
= Σ over (group, value) in requested facets:
|
||
1 if MethodFacet exists with matches=true for (section, method.slug, group, value)
|
||
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.
|
||
|
||
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.
|
||
|
||
### 9.3 `POST /api/templates/recommend` (follow-up, 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.
|
||
|
||
**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:
|
||
|
||
- `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.
|
||
- 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**
|
||
prefills the create flow's `selected*MethodIds` and core-values chip
|
||
snapshot from the template's composition — see
|
||
[`buildTemplateCustomizePrefill`](../../lib/create/applyTemplatePrefill.ts)
|
||
and the `handleCustomizeTemplate` handler in
|
||
`CreateFlowLayoutClient.tsx`. Shipped outside CR-88.
|
||
- Recommendations **never hide** options — ranking only. Authors expect
|
||
to see "all 32 decision-making patterns" with the matching ones
|
||
surfaced first.
|
||
|
||
---
|
||
|
||
## 11. Resolved decisions (no open questions)
|
||
|
||
- ~~Where card copy lives~~ → `messages/en/create/customRule/*.json`,
|
||
flat `methods` array per file. Done.
|
||
- ~~Card / modal split in messages files~~ → collapsed into a single
|
||
`methods` array; modal title/description derive from each entry's
|
||
`label`/`supportText`. Done.
|
||
- ~~`rightRail.json` rename~~ → file is now
|
||
`messages/en/create/decisionApproaches.json`; namespace is
|
||
`m.create.decisionApproaches` (and will become
|
||
`m.create.customRule.decisionApproaches` after the §1c folder reorg).
|
||
Done.
|
||
- ~~Facet authoring format~~ → typed JSON files committed under
|
||
`data/create/customRule/`, validated by Zod. No spreadsheets at
|
||
runtime, no `xlsx` dep, no importer.
|
||
- ~~Where facet data lives at runtime~~ → JSON is canonical; DB is
|
||
hydrated at seed time for join-friendly queries.
|
||
- ~~Decision-making `Consensus Level` scale~~ → integer 0-100 in
|
||
messages; the original spreadsheet's 0.0-1.0 floats were converted
|
||
during the one-time content ingest.
|
||
- ~~Membership section key naming~~ → `eligibility` /
|
||
`joiningProcess` / `expectations` (matches wizard).
|
||
- ~~Scoring vs filtering~~ → ranking only; never hide rows (§10).
|
||
- ~~`RuleTemplate` rows~~ → stay hand-curated in `prisma/seed.ts`
|
||
`COMPOSITION_BY_SLUG`. The matrix just adds ranking; it doesn't
|
||
regenerate templates.
|
||
- ~~Values matrix~~ → out of scope. Values are baked into each
|
||
curated template (and authored in `coreValues.json` for the
|
||
open-ended wizard step). No facet matrix needed; if a template is
|
||
recommended, its values come along statically.
|
||
- ~~Ranking algorithm~~ → simple count (sum of `MethodFacet { matches:
|
||
true }` rows touched by the requested facets); per-method for §9.2
|
||
and per-template (sum across composed methods) for §9.1. `weight` is
|
||
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.
|
||
- ~~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`
|
||
and `data/create/customRule/*.json` files are the historical record.
|
||
|
||
---
|
||
|
||
## 12. Test plan (acceptance for CR-88)
|
||
|
||
- [ ] `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
|
||
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
|
||
(rejects unknown facet values, unknown groups, unknown sections,
|
||
malformed booleans).
|
||
- [ ] `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`
|
||
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`
|
||
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
|
||
(no regression for the existing marketing/templates surfaces).
|
||
- [ ] 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
|
||
re-running `prisma db seed` changes the rank order returned by
|
||
both endpoints without any code change.
|
||
|
||
---
|
||
|
||
## 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).
|
||
- `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
|
||
for the JSON facet files and the API request shapes.
|
||
- `lib/server/validation/createFlowSchemas.ts` — reuse facet-id arrays
|
||
rather than redeclaring them.
|
||
- `lib/server/validation/requestBody.ts` — `readLimitedJson`.
|
||
- `lib/server/validation/zodHttp.ts` — `jsonFromZodError`.
|
||
- `lib/logger.ts` — server-side `logger`.
|
||
- `app/(app)/create/types.ts` — `CreateFlowState` and facet fields.
|
||
- `app/(app)/create/utils/flowSteps.ts` — canonical step order.
|
||
- `app/(app)/create/utils/createFlowScreenRegistry.ts` — screen
|
||
metadata.
|
||
- `app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx`
|
||
— chip-id derivation pattern (positional `String(i+1)`).
|
||
- `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` (and
|
||
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;
|
||
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.
|
||
- `data/create/customRule/{communication,membership,decisionApproaches,conflictManagement}.json`
|
||
— **new**; facet matches per method.
|
||
- `data/create/customRule/_facetGroups.json` — **new**; canonical facet
|
||
group/value ids and the wizard-chip-id ↔ facet-value-id mapping.
|
||
- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for new
|
||
schema/parity tests.
|
||
- Roadmap: `docs/guides/backend-roadmap.md` §4, §13.
|
||
- Spec: `docs/guides/backend-linear-tickets.md` Ticket 16.
|
||
|
||
---
|
||
|
||
## Appendix — Source workbooks (one-time authoring artifact)
|
||
|
||
These four spreadsheets are **handed to the implementing agent
|
||
alongside this doc**. They were used once to seed the messages content
|
||
(already done) and will be used once more to transcribe the facet
|
||
matches into `data/create/customRule/*.json` per §6's "One-time
|
||
transcription" steps. They are **not** committed to the repo, **not**
|
||
part of the runtime contract, and **not** referenced by any code path.
|
||
|
||
| File (in `~/Downloads/`) | Sheet | Rows | Section |
|
||
| --- | --- | --- | --- |
|
||
| `Communication Methods.xlsx` | `Current` | 11 | `communication` |
|
||
| `Group_Membership_Methods.xlsx` | `Current` | 19 | `membership` |
|
||
| `Decision-making.xlsx` | `Current` | 32 | `decisionApproaches` |
|
||
| `Conflict Management Methods.xlsx` | `Current` | 19 | `conflictManagement` |
|
||
|
||
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
|
||
historical reference only, and the committed JSON (in both `messages/`
|
||
and `data/`) is the canonical record.
|