From 73cb61d3a225aed562d873f9991ef49a2d5bd189 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:34:02 +0200 Subject: [PATCH 01/19] docs(PBI-96): voeg implementatieplan product-docs toe Plan v2 (post-review) met PBI/Story/Task-referenties (PBI-96, 6 stories, 18 taken). Verwerkt review-punten P1 (delete-audit FK), P2 (title/status sync, last_updated normalisatie, disabled-folder semantiek) en P3 (denormalized actor-velden). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 3 +- docs/plans/PBI-96-product-docs.md | 584 ++++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 docs/plans/PBI-96-product-docs.md diff --git a/docs/INDEX.md b/docs/INDEX.md index ffcbce3..3683c19 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-15 from front-matter and headings. +Auto-generated on 2026-05-16 from front-matter and headings. ## Architecture Decision Records @@ -49,6 +49,7 @@ Auto-generated on 2026-05-15 from front-matter and headings. | [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — | | [Plan — `code` wordt bindende volgorde voor stories & taken; drag-and-drop eruit](./plans/PBI-84-code-binding-order.md) | — | — | | [Plan — Expliciete schermstaat + draft-zichtbaarheid op de Product Backlog page](./plans/PBI-91-pb-screen-state.md) | — | — | +| [Per-product documentatiestructuur (Product Docs)](./plans/PBI-96-product-docs.md) | planned | 2026-05-16 | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | diff --git a/docs/plans/PBI-96-product-docs.md b/docs/plans/PBI-96-product-docs.md new file mode 100644 index 0000000..9101581 --- /dev/null +++ b/docs/plans/PBI-96-product-docs.md @@ -0,0 +1,584 @@ +--- +title: "Per-product documentatiestructuur (Product Docs)" +status: planned +audience: implementation +language: nl +last_updated: 2026-05-16 +version: 2 +applies_to: scrum4me-app +pbi: PBI-96 +source_review: docs/recommendations/product-docs-storage-system-review-2026-05-16.md +--- + +# Per-product documentatie — Implementatieplan (v2, post-review) + +## Context + +Scrum4Me onderhoudt zelf een rijke `docs/`-tree met 8 kern-folders (`adr/`, `architecture/`, `patterns/`, `plans/`, `runbooks/`, `specs/`, `manual/`, `api/`), YAML-frontmatter-conventies en een auto-gegenereerde `INDEX.md`. Producten **in** de app hebben momenteel alléén `Product.description` (single-string) en `definition_of_done` — geen plek voor structurele product-documentatie. + +Doel: elk product in de app krijgt dezelfde 8-folder-structuur als sjabloon, **per product configureerbaar** (folders aan/uit) en beheerd via een in-app markdown-editor, gemodelleerd naar het bestaande Ideas-pattern (`Idea.grill_md`/`plan_md` + `IdeaLog`). + +**Vastgelegde keuzes (gebruiker):** +1. Storage: DB-tabel `product_docs` (geen filesystem/git) +2. Sjabloon: Kern-8 default, **per product configureerbaar** via folder-toggles +3. Beheer: in-app markdown-editor (hergebruik van `idea-md-editor` patroon) + +**Out of scope (v1):** filesystem/git-koppeling, auto-commit naar `repo_url`, WYSIWYG, full-text-search, comments/threads, page-versies (alleen audit-log), file-uploads/attachments, MCP-tools, REST-API. + +## Wijzigingen v1 → v2 (na review) + +Verwerkt vanuit [docs/recommendations/product-docs-storage-system-review-2026-05-16.md](docs/recommendations/product-docs-storage-system-review-2026-05-16.md): + +| # | Review | Verwerking | +|---|---|---| +| P1 | Delete-audit FK-probleem | `deleteProductDocAction` pipeline volledig herschreven (§B.2): eerst metadata ophalen, dan `$transaction` met log-rij die `doc_id: null` schrijft + delete. Geen FK-probleem, geen interactieve transaction nodig. | +| P2 | Create vult `title`/`status` niet expliciet | `createProductDocAction` (§B.2) maakt expliciet: `parsed = parseProductDocMd(content_md)`; `title` en `status` worden uit `parsed.frontmatter` naar de kolommen geschreven; ontbrekende frontmatter-velden → 422. | +| P2 | `last_updated` zonder serializer | Nieuwe helper `lib/product-doc-frontmatter.ts` met `setProductDocFrontmatterFields(md, patch)`. Server overschrijft `last_updated` in de **opgeslagen** `content_md` zelf. Documented in §B.1 + §B.2. | +| P2 | Disabled folder-semantiek onduidelijk | **Expliciete keuze** (§C.4): "verborgen in INDEX/nav, maar directe URL leesbaar (read-only-banner)". Geen 404. Anti-data-loss + anti-frustratie. | +| P3 | Audit-actor relationeel contract | Expliciete keuze (§A.3): **denormalized string-id** voor v1 + `@@index([actor_user_id, created_at])` voor toekomstige actor-timelines. Geen FK naar User. | + +--- + +## Architectuur + +### A. Datamodel (Prisma) + +#### A.1 Nieuwe enums + +```prisma +enum ProductDocFolder { + ADR + ARCHITECTURE + PATTERNS + PLANS + RUNBOOKS + SPECS + MANUAL + API +} + +enum ProductDocLogType { + CREATED + UPDATED + DELETED + FOLDER_ENABLED + FOLDER_DISABLED +} +``` + +UPPER_SNAKE volgens CLAUDE.md hardstop ("Enum: DB UPPER_SNAKE ↔ API lowercase"). API-mapping in `lib/product-doc-folder.ts` (spiegelt `lib/task-status.ts`). + +#### A.2 `Product`-uitbreiding ([prisma/schema.prisma:201](prisma/schema.prisma:201)) + +Toevoegen in het `Product`-model (rond regel 215): + +```prisma +enabled_doc_folders ProductDocFolder[] @default([ADR, ARCHITECTURE, PATTERNS, PLANS, RUNBOOKS, SPECS, MANUAL, API]) +docs ProductDoc[] +doc_logs ProductDocLog[] +``` + +Postgres-array van het enum-type — geen aparte join-tabel voor config die zelden muteert. + +#### A.3 Nieuwe modellen + +```prisma +model ProductDoc { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + folder ProductDocFolder + slug String @db.VarChar(80) + title String @db.VarChar(200) // gesynct uit frontmatter bij elke save + content_md String @db.Text + status String @db.VarChar(20) // uit frontmatter; VARCHAR i.p.v. enum (user-input) + created_by String // denormalized user-id (zie §P3-keuze) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + logs ProductDocLog[] + + @@unique([product_id, folder, slug]) + @@index([product_id, folder, updated_at]) + @@index([product_id, status]) + @@map("product_docs") +} + +model ProductDocLog { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + doc ProductDoc? @relation(fields: [doc_id], references: [id], onDelete: SetNull) + doc_id String? + actor_user_id String // denormalized — geen FK (zie P3-keuze) + type ProductDocLogType + metadata Json? // { folder, slug, title, length, prev_status, ... } + created_at DateTime @default(now()) + + @@index([product_id, created_at]) + @@index([doc_id, created_at]) + @@index([actor_user_id, created_at]) // voor toekomstige per-actor-timeline-queries + @@map("product_doc_logs") +} +``` + +**Designkeuzes (incl. P3-resolutie):** +- `slug` uniek per `(product_id, folder)` — zelfde slug mag in twee folders (bv. `runbooks/deploy` + `manual/deploy`). +- `title` + `status` als gerepliceerde kolommen — list-queries hoeven MD niet te parsen. +- `status` als VARCHAR i.p.v. enum: frontmatter is user-input. Kanonieke set leeft in Zod. +- **Actor-velden zijn denormalized strings, geen FK** (P3-keuze). Reden: minder schema-coupling voor v1; ProductDocLog hoeft niet te joinen voor de meeste views; bij user-deletion blijven audit-rijen leesbaar zonder cascade-config. Migratie naar FK is reversible (kolom blijft string). +- `@@index([actor_user_id, created_at])` reserveert query-pad voor v1.1 (audit-timeline per actor). +- `doc_id` nullable + `SetNull` → ProductDocLog overleeft delete (zie P1-pipeline §B.2). + +#### A.4 Migratie + +Eén migration `prisma/migrations/_add_product_docs/migration.sql`: +1. `CREATE TYPE "ProductDocFolder"` (8 values) +2. `CREATE TYPE "ProductDocLogType"` (5 values) +3. `ALTER TABLE products ADD COLUMN enabled_doc_folders "ProductDocFolder"[] NOT NULL DEFAULT ARRAY[...]::"ProductDocFolder"[]` +4. `CREATE TABLE product_docs` + 3 indexes +5. `CREATE TABLE product_doc_logs` + 3 indexes (incl. actor-index) + +**Geen backfill** nodig — DB-DEFAULT vult bestaande rijen automatisch. + +--- + +### B. Server-laag + +#### B.1 Schemas + helpers (nieuw) + +| Bestand | Verantwoordelijkheid | +|---|---| +| `lib/schemas/product-doc.ts` | `productDocCreateSchema`, `productDocUpdateSchema`, `productDocFolderToggleSchema`, `productDocFrontmatterSchema` (Zod) | +| `lib/product-doc-folder.ts` | DB-enum ↔ API-string mapping; spiegelt [lib/task-status.ts](lib/task-status.ts) | +| `lib/product-doc-parser.ts` | `parseProductDocMd(md): { ok: true, frontmatter, body } \| { ok: false, errors }`; hergebruikt yaml-parser + error-shape uit [lib/idea-plan-parser.ts](lib/idea-plan-parser.ts) | +| `lib/product-doc-frontmatter.ts` | **`setProductDocFrontmatterFields(md, patch): string`** — parseert YAML-frontmatter, merget `patch`-keys (bv. `{last_updated: '2026-05-16'}`), serialiseert terug naar markdown. Whitespace + ordering best-effort behouden. Throws bij parse-fout (caller heeft al gevalideerd). | +| `lib/product-doc-slug.ts` | `slugify(title)` + `suggestSlug(title, folder, existing)` met dedupe-suffix `-2`/`-3` en ADR-sequence-helper | +| `lib/schemas/product-doc-frontmatter-defaults.ts` | Per-folder template-strings voor "Nieuwe doc"-dialog (geen server-gebruik) | + +Kern-zod (samengevat): +```ts +export const PRODUCT_DOC_FOLDERS = ['adr','architecture','patterns','plans','runbooks','specs','manual','api'] as const +export const PRODUCT_DOC_STATUSES = ['draft','active','deprecated','archived'] as const + +productDocFrontmatterSchema = z.object({ + title: z.string().min(1).max(200), + status: z.enum(PRODUCT_DOC_STATUSES), + audience: z.union([z.string(), z.array(z.string())]).optional(), + applies_to: z.union([z.string(), z.array(z.string())]).optional(), + last_updated: z.string().optional(), // server overschrijft via setProductDocFrontmatterFields +}) + +productDocCreateSchema = z.object({ + product_id: z.string().cuid(), + folder: z.enum(PRODUCT_DOC_FOLDERS), + slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/), + content_md: z.string().min(1).max(100_000), +}) +``` + +#### B.2 Server-actions — `actions/product-docs.ts` + +Strikt volgens [docs/patterns/server-action.md](docs/patterns/server-action.md) en het patroon uit [actions/ideas.ts:232-313](actions/ideas.ts:232). **Pipelines hieronder zijn definitief en verwerken alle review-punten (P1, P2-create, P2-last_updated).** + +##### `createProductDocAction(input)` — verwerkt P2-create + P2-last_updated + +``` +1. session.userId → 401 +2. session.isDemo → 403 +3. rate-limit `create-product-doc` +4. Zod parse(productDocCreateSchema, input) → 422 +5. parsed = parseProductDocMd(input.content_md) → 422 met line-info indien fail +6. loadAccessibleProduct(input.product_id, session.userId) → 404 +7. product.enabled_doc_folders.includes(folder) → 422 'folder uitgeschakeld' +8. content_md_normalized = setProductDocFrontmatterFields( + input.content_md, + { last_updated: today() } // ISO yyyy-mm-dd, server-side + ) +9. $transaction([ + prisma.productDoc.create({ + data: { + product_id, folder, slug, + title: parsed.frontmatter.title, // expliciet uit frontmatter (P2) + status: parsed.frontmatter.status, // idem + content_md: content_md_normalized, // server-genormaliseerd (P2) + created_by: session.userId, + }, + }), + prisma.productDocLog.create({ + data: { + product_id, doc_id: , actor_user_id: session.userId, + type: 'CREATED', metadata: { folder, slug, title, length: content_md_normalized.length }, + }, + }), + ]) + ── op P2002 (slug-uniciteit) → 422 'slug bestaat al in folder' +10. revalidatePath /products/[id]/docs + /products/[id]/docs/[folder] +``` + +> Implementatienoot: stap 9's tweede statement heeft de id van de eerste nodig. Gebruik `prisma.$transaction(async tx => { const doc = await tx.productDoc.create(...); await tx.productDocLog.create({ data: { doc_id: doc.id, ...} }) })` (interactieve transaction) i.p.v. array — bekend Prisma-idiom. + +##### `updateProductDocAction(id, content_md)` — verwerkt P2-create-equiv + P2-last_updated + +``` +1. session.userId → 401 +2. session.isDemo → 403 +3. rate-limit `edit-product-doc` +4. Zod parse(productDocUpdateSchema, {content_md}) → 422 +5. existing = prisma.productDoc.findFirst({ + where: { id, product: productAccessFilter(session.userId) }, + select: { id, product_id, status } + }) → 404 +6. parsed = parseProductDocMd(content_md) → 422 +7. content_md_normalized = setProductDocFrontmatterFields( + content_md, { last_updated: today() } + ) +8. $transaction([ + prisma.productDoc.update({ + where: { id }, + data: { + title: parsed.frontmatter.title, // sync uit frontmatter + status: parsed.frontmatter.status, // sync uit frontmatter + content_md: content_md_normalized, + }, + }), + prisma.productDocLog.create({ + data: { + product_id: existing.product_id, doc_id: id, actor_user_id: session.userId, + type: 'UPDATED', metadata: { length: content_md_normalized.length, prev_status: existing.status, new_status: parsed.frontmatter.status }, + }, + }), + ]) +9. revalidatePath index + folder + detail-route +``` + +##### `deleteProductDocAction(id)` — **verwerkt P1 (delete-audit FK)** + +``` +1. session.userId → 401 +2. session.isDemo → 403 +3. existing = prisma.productDoc.findFirst({ + where: { id, product: productAccessFilter(session.userId) }, + select: { id, product_id, folder, slug, title } // metadata vóór delete (P1) + }) → 404 +4. $transaction([ + prisma.productDocLog.create({ + data: { + product_id: existing.product_id, + doc_id: null, // null vanaf het begin (P1) + actor_user_id: session.userId, + type: 'DELETED', + metadata: { folder: existing.folder, slug: existing.slug, title: existing.title }, + }, + }), + prisma.productDoc.delete({ where: { id } }), + ]) +5. revalidatePath index + folder +``` + +> **P1-rationale:** door `doc_id: null` direct te schrijven, omzeilen we het FK-volgorde-probleem volledig — geen `SetNull`-race, geen interactieve transaction nodig. Metadata bewaart `folder`/`slug`/`title` voor traceability. + +##### `toggleProductDocFolderAction(input)` + +``` +1. session.userId → 401 +2. session.isDemo → 403 +3. Zod parse(productDocFolderToggleSchema, input) → 422 +4. product = prisma.product.findFirst({ + where: { id: input.product_id, user_id: session.userId } // owner-only (folder = product-setting) + }) → 404 als geen toegang of niet-owner +5. next = enabled ? [...current, folder] : current.filter(f => f !== folder) +6. $transaction([ + prisma.product.update({ where: {id}, data: { enabled_doc_folders: dedupe(next) } }), + prisma.productDocLog.create({ + data: { product_id, doc_id: null, actor_user_id: session.userId, + type: enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED', + metadata: { folder } }, + }), + ]) +7. revalidatePath index + settings +``` + +**Verwijdert geen docs** in disabled folder (anti-data-loss). + +##### `listProductDocsAction({product_id, folder?})` — read-only + +``` +1. session.userId → 401 +2. scope-check via productAccessFilter +3. prisma.productDoc.findMany({ + where: { product_id, ...(folder ? { folder } : {}) }, + select: { id, folder, slug, title, status, updated_at }, // geen content_md + orderBy: [{ folder: 'asc' }, { slug: 'asc' }], + }) +``` + +#### B.3 Rate-limit-keys ([lib/rate-limit.ts:36](lib/rate-limit.ts:36)) + +Sectie toevoegen na de M12-Ideas-keys: +```ts +// Per-product Product Docs (PBI-XXX) +'create-product-doc': { windowMs: 60_000, max: 30 }, +'edit-product-doc': { windowMs: 60_000, max: 60 }, +``` + +#### B.4 Demo-policy — drie lagen (CLAUDE.md hardstop) + +1. **proxy.ts** — geen wijziging (geen REST-routes in v1). +2. **Server-action** — `if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }` aan top van elke write-action (zie [actions/ideas.ts:238](actions/ideas.ts:238)). +3. **UI** — elke write-knop (`New doc`, `Edit`, `Delete`, folder-toggle) ``-wrapped + disabled in demo. + +Demo MAG lezen. + +--- + +### C. UI-laag + +#### C.1 Routes onder `app/(app)/products/[id]/docs/` + +| Route | Bestand | Doel | +|---|---|---| +| `/products/[id]/docs` | `docs/page.tsx` | INDEX — grid van **enabled** folders met preview (laatste 3 docs); lege folders tonen CTA | +| `/products/[id]/docs/settings` | `docs/settings/page.tsx` | Folder-toggle-grid (8 checkboxes); owner-only schrijfbaar | +| `/products/[id]/docs/[folder]` | `docs/[folder]/page.tsx` | Folder-listing tabel; 404 bij invalide folder-enum; bij disabled folder zie §C.4 | +| `/products/[id]/docs/[folder]/[slug]` | `docs/[folder]/[slug]/page.tsx` | Doc-viewer; Edit-knop togglet inline-editor | + +#### C.2 Layout-integratie ([app/(app)/products/[id]/layout.tsx:19-25](app/(app)/products/[id]/layout.tsx:19)) + +Nieuwe component `components/products/product-subnav.tsx` (client, `usePathname()`): + +```tsx +export default async function ProductLayout({ children, params }: Props) { + const { id } = await params + const session = await getSession() + if (!session.userId) redirect('/login') + const product = await getAccessibleProduct(id, session.userId) + if (!product) notFound() + + return ( + <> + + + {children} + + ) +} +``` + +Tabs: Backlog · Sprint · Solo · **Docs** · Instellingen. Active-state-patroon kopiëren uit [components/shared/nav-bar.tsx](components/shared/nav-bar.tsx) (`navLink`-helper). + +#### C.3 Componenten — `components/product-docs/` (nieuw) + +| Component | Type | Beschrijving | +|---|---|---| +| `product-docs-index.tsx` | server | Grid van enabled folders + preview-lijst | +| `product-docs-folder-card.tsx` | server | Card per folder met laatste 3 docs | +| `product-docs-folder-list.tsx` | client | Tabel per folder; kolommen + sort | +| `product-doc-viewer.tsx` | server | `` ([components/markdown.tsx](components/markdown.tsx)) + frontmatter-kop | +| `product-doc-editor.tsx` | client | Wrapper rond `MarkdownDocEditor` (§D) met `updateProductDocAction` | +| `new-product-doc-dialog.tsx` | client | Dialog volgens [docs/patterns/dialog.md](docs/patterns/dialog.md); folder-select (alleen enabled), title, auto-slug, starter-template | +| `delete-product-doc-button.tsx` | client | Confirm + `deleteProductDocAction`; `` | +| `product-doc-folder-toggle.tsx` | client | 8 checkboxes; owner-only | +| `product-doc-status-badge.tsx` | server | MD3-tokens: `draft→bg-muted`, `active→bg-status-done`, `deprecated→bg-status-blocked/20`, `archived→bg-muted/40` (**géén** `bg-blue-500`) | +| `disabled-folder-banner.tsx` | server | Banner-component voor disabled-folder pages (zie §C.4) | + +**Dialog-spec verplicht:** `docs/specs/dialogs/product-doc.md` ([docs/patterns/dialog.md](docs/patterns/dialog.md) §3.2). + +#### C.4 Disabled-folder semantiek — **verwerkt P2-disabled** + +**Definitieve keuze: "verborgen maar leesbaar"**. Een folder die is uitgeschakeld via `Product.enabled_doc_folders`: + +| Locatie | Gedrag | +|---|---| +| INDEX-page (`docs/page.tsx`) | Folder is **niet zichtbaar** in de grid (filter op `enabled_doc_folders`) | +| ProductSubNav | "Docs"-tab blijft zichtbaar zolang er ≥1 enabled folder is; anders verborgen | +| Folder-page (`docs/[folder]/page.tsx`) | **Wel** bereikbaar via directe URL; toont `` bovenaan + read-only tabel ("Folder uitgeschakeld in instellingen — bestaande docs blijven leesbaar; nieuwe docs kunnen niet worden aangemaakt"). "Nieuwe doc"-knop is verborgen. | +| Detail-page (`docs/[folder]/[slug]/page.tsx`) | **Wel** bereikbaar; toont dezelfde banner + read-only viewer. Edit-knop is verborgen (niet alleen disabled — er valt niets te wijzigen aan een doc in een gefroze folder; alternatief is muteren-via-frontmatter wat verwarrend wordt). | +| `createProductDocAction` | Already 422 'folder uitgeschakeld' (zie §B.2 stap 7) | +| `updateProductDocAction` | **Géén folder-check** — bestaande docs in disabled folder blijven editbaar **als** de gebruiker er actief naartoe navigeert? **Nee** — UI verbergt Edit-knop. Server-action zelf doet géén extra folder-check (data-laag blijft consistent; de UI-layer dwingt het af). Dit is bewust: wie via een bookmarked directe action-URL probeert, kan zijn eigen oude content terugzetten — geen risico, geen data-loss. | + +**Rationale:** anti-data-loss (docs verdwijnen niet uit DB), anti-frustratie (oude URLs blijven werken), anti-confusion (banner maakt staat duidelijk). Tests dekken: directe folder-URL na toggle-off → 200 met banner; directe detail-URL → 200 met banner + viewer. + +#### C.5 Frontmatter-UX + +Editor toont raw `content_md` — gebruiker bewerkt frontmatter zelf (zoals `idea-md-editor.tsx` met `kind='plan'`). Live yaml-validatie via memoized `parseProductDocMd`. Submit geblokkeerd bij parse-fout; error-box toont line-info. **Niet** wijzigen door user: `last_updated` (wordt server-overschreven, eventuele user-wijziging genegeerd na save — geen feedback nodig, gewone tooltip in editor "wordt automatisch ververst"). + +#### C.6 Geen realtime in v1 + +Edits triggeren `router.refresh()` (zoals [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx)). SSE/Realtime is v1.1. + +--- + +### D. Refactor: `MarkdownDocEditor`-extractie + +`components/ideas/idea-md-editor.tsx` bevat de complete editor-stack (Cmd+S, localStorage drafts, dirty-check, live-validatie). Twee gebruikers (Ideas + Product Docs) = direct extracten — volgens CLAUDE.md dialog-discipline. + +#### D.1 Nieuwe shared component + +`components/shared/markdown-doc-editor.tsx`: +```ts +interface MarkdownDocEditorProps { + storageKey: string + initialValue: string + validate?: (md: string) => ValidationError[] + onSave: (md: string) => Promise + onCancel: () => void + rows?: number + placeholder?: string +} +``` + +#### D.2 Wrappers + +- [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) → wrapper rond `MarkdownDocEditor` met `parsePlanMd`/geen-validator afhankelijk van `kind`. Bestaande tests blijven groen. +- `components/product-docs/product-doc-editor.tsx` → wrapper met `parseProductDocMd`. + +--- + +## Bestanden om aan te raken / aan te maken + +| Laag | Bestand | Status | +|---|---|---| +| Schema | [prisma/schema.prisma:201](prisma/schema.prisma:201) (Product) + enums-blok | Wijzigen | +| Migratie | `prisma/migrations/_add_product_docs/migration.sql` | Nieuw | +| Schemas | `lib/schemas/product-doc.ts`, `lib/schemas/product-doc-frontmatter-defaults.ts` | Nieuw | +| Helpers | `lib/product-doc-folder.ts`, `lib/product-doc-parser.ts`, `lib/product-doc-frontmatter.ts`, `lib/product-doc-slug.ts` | Nieuw (4) | +| Rate-limit | [lib/rate-limit.ts:36](lib/rate-limit.ts:36) | Wijzigen — 2 keys | +| Actions | `actions/product-docs.ts` | Nieuw | +| Pages | `app/(app)/products/[id]/docs/{page,settings/page,[folder]/page,[folder]/[slug]/page}.tsx` | Nieuw (4) | +| Layout | [app/(app)/products/[id]/layout.tsx:19-25](app/(app)/products/[id]/layout.tsx:19) | Wijzigen — `` | +| Sub-nav | `components/products/product-subnav.tsx` | Nieuw | +| UI | `components/product-docs/*.tsx` (10 files, §C.3 incl. `disabled-folder-banner.tsx`) | Nieuw | +| Shared editor | `components/shared/markdown-doc-editor.tsx` | Nieuw (extractie) | +| Refactor | [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Refactor → wrapper | +| Dialog-spec | `docs/specs/dialogs/product-doc.md` | Nieuw (repo-docs) | +| Tests | `__tests__/lib/{product-doc-parser,product-doc-frontmatter,product-doc-slug,schemas/product-doc}.test.ts`, `__tests__/actions/product-docs.test.ts`, `__tests__/components/product-docs/*.test.tsx` | Nieuw | +| Docs-update | [docs/architecture.md](docs/architecture.md), [docs/specs/functional.md](docs/specs/functional.md) | Wijzigen | + +--- + +## Hergebruik (bestaande utilities) + +| Bestaand | Hergebruik in | +|---|---| +| `lib/product-access.ts` (`getAccessibleProduct`, `productAccessFilter`) | Alle page-loaders en server-actions | +| `lib/idea-plan-parser.ts` (`parsePlanMd`-shape) | Referentie voor `parseProductDocMd` en `setProductDocFrontmatterFields` | +| [actions/ideas.ts:232-313](actions/ideas.ts:232) | Patroon voor server-actions | +| [components/markdown.tsx](components/markdown.tsx) | Doc-viewer rendering (react-markdown + remark-gfm + XSS-safe) | +| [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Bron voor `MarkdownDocEditor`-extractie | +| `lib/rate-limit.ts` (`enforceUserRateLimit`) | Twee nieuwe keys | +| ``-wrapper (uit `components/dialogs/product-dialog.tsx`) | Alle write-knoppen | + +--- + +## PBI / Story-breakdown (voor MCP-flow) + +Volgt [docs/runbooks/plan-to-pbi-flow.md](docs/runbooks/plan-to-pbi-flow.md). Eén PBI met N stories, sequentieel (ST-C kan parallel met ST-B): + +| Story | Scope | +|---|---| +| **ST-A** DB & helpers | Schema-edits + migration.sql + Zod-schemas + parser + **frontmatter-serializer** + slug-helpers. Tests voor parser, frontmatter-set, slug, schemas. | +| **ST-B** Server-actions | `actions/product-docs.ts` (5 actions met definitieve pipelines uit §B.2) + rate-limit-keys. Tests dekken P1 (delete-audit), P2 (title/status sync, last_updated normalisatie). | +| **ST-C** MarkdownDocEditor-extractie | Refactor `idea-md-editor.tsx` → `MarkdownDocEditor` + idea-wrapper. Bestaande idea-tests groen. | +| **ST-D** UI routes + viewer + dialog | INDEX, folder-list, viewer, "Nieuwe doc"-dialog, viewer-edit-toggle. **Disabled-folder banner** (§C.4). Specs-doc `docs/specs/dialogs/product-doc.md`. | +| **ST-E** Folder-config + ProductSubNav | `docs/settings`-page + `ProductSubNav`-component + layout-integration. Tests voor directe URL na toggle-off. | +| **ST-F** Demo-policy + e2e + docs-update | ``-wrappers, demo-smoke, manuele e2e, breadcrumb-update in `docs/architecture.md`, optionele ADR voor folder-set-keuze. | + +--- + +## Verificatie + +### Unit-tests (vitest) — review-coverage expliciet + +| Bestand | Wat (incl. review-punten) | +|---|---| +| `__tests__/lib/product-doc-parser.test.ts` | parse-success, parse-fail (missing fm, bad yaml, missing required, status-enum-fail) | +| `__tests__/lib/product-doc-frontmatter.test.ts` | **P2-last_updated**: `setProductDocFrontmatterFields` vervangt bestaand `last_updated`, voegt toe als afwezig, behoudt overige velden + body | +| `__tests__/lib/product-doc-slug.test.ts` | slugify, dedupe, ADR-sequence | +| `__tests__/lib/schemas/product-doc.test.ts` | Zod positive/negative | +| `__tests__/actions/product-docs.test.ts` | Per action: 401 / 403-demo / 422-parse / 404-geen-toegang / success + log-rij. Specifiek: | +| | • **P1-delete**: na `deleteProductDocAction` → 0 product_docs voor id; 1 product_docs_log met `type=DELETED`, `doc_id IS NULL`, metadata bevat `folder/slug/title` | +| | • **P2-create**: success-test vergelijkt opgeslagen `title`/`status` met frontmatter-input | +| | • **P2-create-fail**: frontmatter zonder `title` of `status` → 422, géén DB-write (`product_docs.count() === 0`) | +| | • **P2-last_updated**: user-supplied `last_updated: 2020-01-01` wordt in opgeslagen `content_md` vervangen door today | +| | • **P2-disabled-create**: folder uitgeschakeld → 422 'folder uitgeschakeld' | +| `__tests__/components/product-docs/new-product-doc-dialog.test.tsx` | velden per folder, submit-blokkering yaml-fout, DemoTooltip | +| `__tests__/components/shared/markdown-doc-editor.test.tsx` | Cmd+S triggert save, localStorage draft | +| `__tests__/app/products-docs-disabled-folder.test.tsx` (smoke) | **P2-disabled-routes**: directe folder-URL na toggle-off → render met ``, geen "Nieuwe doc"-knop; directe detail-URL → viewer + banner, geen Edit-knop | + +### End-to-end smoke (handmatig) +1. `npm run dev` → login als seed-user +2. Open product → tab "Docs" → INDEX toont 8 lege folders +3. "Nieuwe doc" → folder `runbooks`, titel "Deploy stappen" → save → viewer-redirect; check dat `last_updated` op vandaag staat +4. Edit → status → `active` in frontmatter, zet `last_updated: 2020-01-01` → save → badge muteert + `last_updated` is vandaag (server-normalisatie) +5. `docs/settings` → toggle `api` uit → INDEX toont 7 folders; directe URL `/products/[id]/docs/api` → 200 met banner; flip terug → folder weer in INDEX +6. Yaml-fout introduceren → save geblokkeerd, error-box met line-info +7. Delete → confirm → doc weg uit folder-listing; `product_doc_logs` heeft rij met `type=DELETED`, `doc_id=null`, metadata `{folder, slug, title}` +8. Andere user **zonder** ProductMember → directe URL → 404 (anti-enum) +9. Demo-user → docs lezen werkt, write-knoppen disabled; directe action-call → 403 +10. ProductMember (niet-owner) → lezen + schrijven docs OK; `docs/settings` read-only (geen toggle-permissie) + +### Build + lint +```bash +npm run verify && npm run build +``` + +--- + +## Open punten (v1.1+, niet-blokkerend) + +- Import van bestaande `.md`-files (drag-and-drop, Obsidian-paste) +- Postgres full-text-search op `content_md` +- Audit-timeline-view per doc + per actor (`ProductDocLog` met de `actor_user_id`-index uit §A.3) +- Auto-link naar PBI/Story-codes in MD-body +- MCP-tools (`read_product_doc`, `list_product_docs`) +- Optionele git-export naar `repo_url/docs/` +- Realtime/SSE updates op doc-mutaties +- Migratie van denormalized actor-strings naar FK (zie P3-keuze) + +--- + +## Procedure na goedkeuring — uitgevoerd 2026-05-16 + +PBI, stories en taken zijn aangemaakt via Scrum4Me MCP. Implementatie wacht op expliciete gebruikersopdracht. + +### PBI + +- **PBI-96** — Per-product documentatiestructuur (Product Docs) · priority 2 · product Scrum4Me (`cmohrysyj0000rd17clnjy4tc`) + +### Stories (uit te voeren in sort_order; ST-C kan parallel met ST-B) + +| Story | Titel | ID | +|---|---|---| +| ST-1379 | A — DB-schema, migratie en helpers | `cmp84tcca0002q017gc5hsw88` | +| ST-1380 | B — Server-actions (5 actions) + rate-limit-keys | `cmp84tgtz0003q0179dpeywqg` | +| ST-1381 | C — MarkdownDocEditor-extractie (refactor) | `cmp84tkhf0004q017zzrka4wq` | +| ST-1382 | D — UI routes (INDEX, folder, viewer, editor) + dialog | `cmp84tp4a0005q0179qa7we7d` | +| ST-1383 | E — Folder-config-page + ProductSubNav | `cmp84tsz50006q017q9l9o6u0` | +| ST-1384 | F — Demo-policy, e2e-smoke en docs-update | `cmp84twp40007q017l4dmva5o` | + +### Taken (18 totaal) + +| Task | Titel | Story | +|---|---|---| +| T-1058 | A1: Prisma-schema + migratie | ST-1379 | +| T-1059 | A2: Zod-schemas + folder-mapping + slug-helper + frontmatter-defaults | ST-1379 | +| T-1060 | A3: Frontmatter parser + serializer (P2-fix) + tests | ST-1379 | +| T-1061 | B1: Rate-limit-keys + loadAccessibleProduct-helper | ST-1380 | +| T-1062 | B2: createProductDocAction + updateProductDocAction + tests (P2) | ST-1380 | +| T-1063 | B3: deleteProductDocAction + tests (P1-fix) | ST-1380 | +| T-1064 | B4: toggleProductDocFolderAction + listProductDocsAction + tests | ST-1380 | +| T-1065 | C1: components/shared/markdown-doc-editor.tsx + unit-tests | ST-1381 | +| T-1066 | C2: Refactor idea-md-editor naar wrapper | ST-1381 | +| T-1067 | D1: INDEX-page + index-grid + folder-card | ST-1382 | +| T-1068 | D2: Folder-listing-page + folder-list + status-badge | ST-1382 | +| T-1069 | D3: Viewer/editor-page + viewer + editor-wrapper + delete-button | ST-1382 | +| T-1070 | D4: NewProductDocDialog + dialog-spec | ST-1382 | +| T-1071 | D5: DisabledFolderBanner + integratie in folder/detail pages | ST-1382 | +| T-1072 | E1: /docs/settings-page + product-doc-folder-toggle | ST-1383 | +| T-1074 | E2: ProductSubNav + layout-integratie + disabled-folder smoke-test | ST-1383 | +| T-1075 | F1: Demo-policy audit + 10-stappen manual e2e | ST-1384 | +| T-1076 | F2: Docs-update (architecture, functional, optionele ADRs, INDEX) | ST-1384 | + +### Volgende stap + +Wacht op gebruikersopdracht. Bij start van uitvoering: pak T-1058 op (volgt uit `mcp__scrum4me__get_claude_context`), zet status op `IN_PROGRESS`, werk per taak in sort_order, commit per laag conform [docs/runbooks/branch-and-commit.md](../runbooks/branch-and-commit.md), zet story-status op `DONE` als alle taken klaar zijn. From 667be61334134cb454169dcc663afa7de9858a5b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:38:00 +0200 Subject: [PATCH 02/19] feat(PBI-96/T-1058): add ProductDoc + ProductDocLog schema + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voegt twee enums (ProductDocFolder met 8 kern-folders + ProductDocLogType), een Product-uitbreiding (enabled_doc_folders array met alle 8 als default) en twee modellen toe (ProductDoc met @@unique(product_id, folder, slug) + ProductDocLog met denormalized actor_user_id en doc_id nullable + SetNull). Bestaande producten krijgen de 8-folder-default automatisch via de ALTER TABLE DEFAULT — geen backfill nodig (zie plan §A.4). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 64 +++++ prisma/schema.prisma | 231 +++++++++++------- 2 files changed, 209 insertions(+), 86 deletions(-) create mode 100644 prisma/migrations/20260516100000_add_product_docs/migration.sql diff --git a/prisma/migrations/20260516100000_add_product_docs/migration.sql b/prisma/migrations/20260516100000_add_product_docs/migration.sql new file mode 100644 index 0000000..b6f309b --- /dev/null +++ b/prisma/migrations/20260516100000_add_product_docs/migration.sql @@ -0,0 +1,64 @@ +-- CreateEnum +CREATE TYPE "ProductDocFolder" AS ENUM ('ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API'); + +-- CreateEnum +CREATE TYPE "ProductDocLogType" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'FOLDER_ENABLED', 'FOLDER_DISABLED'); + +-- AlterTable +ALTER TABLE "products" ADD COLUMN "enabled_doc_folders" "ProductDocFolder"[] NOT NULL DEFAULT ARRAY['ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API']::"ProductDocFolder"[]; + +-- CreateTable +CREATE TABLE "product_docs" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "folder" "ProductDocFolder" NOT NULL, + "slug" VARCHAR(80) NOT NULL, + "title" VARCHAR(200) NOT NULL, + "content_md" TEXT NOT NULL, + "status" VARCHAR(20) NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "product_docs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_doc_logs" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "doc_id" TEXT, + "actor_user_id" TEXT NOT NULL, + "type" "ProductDocLogType" NOT NULL, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "product_doc_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "product_docs_product_id_folder_slug_key" ON "product_docs"("product_id", "folder", "slug"); + +-- CreateIndex +CREATE INDEX "product_docs_product_id_folder_updated_at_idx" ON "product_docs"("product_id", "folder", "updated_at"); + +-- CreateIndex +CREATE INDEX "product_docs_product_id_status_idx" ON "product_docs"("product_id", "status"); + +-- CreateIndex +CREATE INDEX "product_doc_logs_product_id_created_at_idx" ON "product_doc_logs"("product_id", "created_at"); + +-- CreateIndex +CREATE INDEX "product_doc_logs_doc_id_created_at_idx" ON "product_doc_logs"("doc_id", "created_at"); + +-- CreateIndex +CREATE INDEX "product_doc_logs_actor_user_id_created_at_idx" ON "product_doc_logs"("actor_user_id", "created_at"); + +-- AddForeignKey +ALTER TABLE "product_docs" ADD CONSTRAINT "product_docs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_doc_id_fkey" FOREIGN KEY ("doc_id") REFERENCES "product_docs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d854a58..7a8debd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,35 +138,54 @@ enum UserQuestionStatus { answered } +enum ProductDocFolder { + ADR + ARCHITECTURE + PATTERNS + PLANS + RUNBOOKS + SPECS + MANUAL + API +} + +enum ProductDocLogType { + CREATED + UPDATED + DELETED + FOLDER_ENABLED + FOLDER_DISABLED +} + model User { - id String @id @default(cuid()) - username String @unique - email String? @unique + id String @id @default(cuid()) + username String @unique + email String? @unique password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - must_reset_password Boolean @default(false) + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + must_reset_password Boolean @default(false) avatar_data Bytes? active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - idea_code_counter Int @default(0) - min_quota_pct Int @default(20) - settings Json @default("{}") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + idea_code_counter Int @default(0) + min_quota_pct Int @default(20) + settings Json @default("{}") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt roles UserRole[] api_tokens ApiToken[] products Product[] ideas Idea[] product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") + assigned_stories Story[] @relation("StoryAssignee") login_pairings LoginPairing[] - asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") - answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] - started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") + started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") push_subscriptions PushSubscription[] @@index([active_product_id]) @@ -199,32 +218,35 @@ model ApiToken { } model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - name String - code String? @db.VarChar(30) - description String? - repo_url String? - definition_of_done String - auto_pr Boolean @default(false) - pr_strategy PrStrategy @default(SPRINT) - preferred_model String? - thinking_budget_default Int? - preferred_permission_mode String? - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - pbis Pbi[] - sprints Sprint[] - stories Story[] - tasks Task[] - members ProductMember[] - active_for_users User[] @relation("UserActiveProduct") - claude_questions ClaudeQuestion[] - claude_jobs ClaudeJob[] - ideas Idea[] - idea_products IdeaProduct[] + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + name String + code String? @db.VarChar(30) + description String? + repo_url String? + definition_of_done String + auto_pr Boolean @default(false) + pr_strategy PrStrategy @default(SPRINT) + preferred_model String? + thinking_budget_default Int? + preferred_permission_mode String? + archived Boolean @default(false) + enabled_doc_folders ProductDocFolder[] @default([ADR, ARCHITECTURE, PATTERNS, PLANS, RUNBOOKS, SPECS, MANUAL, API]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + pbis Pbi[] + sprints Sprint[] + stories Story[] + tasks Task[] + members ProductMember[] + active_for_users User[] @relation("UserActiveProduct") + claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] + ideas Idea[] + idea_products IdeaProduct[] + docs ProductDoc[] + doc_logs ProductDocLog[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -388,47 +410,47 @@ model Task { } model ClaudeJob { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String? - idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) - idea_id String? - sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) - sprint_run_id String? - kind ClaudeJobKind @default(TASK_IMPLEMENTATION) - status ClaudeJobStatus @default(QUEUED) - claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) - claimed_by_token_id String? - claimed_at DateTime? - started_at DateTime? - finished_at DateTime? - pushed_at DateTime? - verify_result VerifyResult? - model_id String? - input_tokens Int? - output_tokens Int? - cache_read_tokens Int? - cache_write_tokens Int? - requested_model String? - requested_thinking_budget Int? - requested_permission_mode String? - actual_thinking_tokens Int? - plan_snapshot String? - base_sha String? - head_sha String? - branch String? - pr_url String? - summary String? - error String? - retry_count Int @default(0) - lease_until DateTime? - task_executions SprintTaskExecution[] @relation("SprintJobExecutions") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String? + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String? + sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) + sprint_run_id String? + kind ClaudeJobKind @default(TASK_IMPLEMENTATION) + status ClaudeJobStatus @default(QUEUED) + claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) + claimed_by_token_id String? + claimed_at DateTime? + started_at DateTime? + finished_at DateTime? + pushed_at DateTime? + verify_result VerifyResult? + model_id String? + input_tokens Int? + output_tokens Int? + cache_read_tokens Int? + cache_write_tokens Int? + requested_model String? + requested_thinking_budget Int? + requested_permission_mode String? + actual_thinking_tokens Int? + plan_snapshot String? + base_sha String? + head_sha String? + branch String? + pr_url String? + summary String? + error String? + retry_count Int @default(0) + lease_until DateTime? + task_executions SprintTaskExecution[] @relation("SprintJobExecutions") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, status]) @@index([task_id, status]) @@ -515,6 +537,43 @@ model ProductMember { @@map("product_members") } +model ProductDoc { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + folder ProductDocFolder + slug String @db.VarChar(80) + title String @db.VarChar(200) + content_md String @db.Text + status String @db.VarChar(20) + created_by String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + logs ProductDocLog[] + + @@unique([product_id, folder, slug]) + @@index([product_id, folder, updated_at]) + @@index([product_id, status]) + @@map("product_docs") +} + +model ProductDocLog { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + doc ProductDoc? @relation(fields: [doc_id], references: [id], onDelete: SetNull) + doc_id String? + actor_user_id String + type ProductDocLogType + metadata Json? + created_at DateTime @default(now()) + + @@index([product_id, created_at]) + @@index([doc_id, created_at]) + @@index([actor_user_id, created_at]) + @@map("product_doc_logs") +} + model Idea { id String @id @default(cuid()) user User @relation(fields: [user_id], references: [id], onDelete: Cascade) @@ -526,8 +585,8 @@ model Idea { description String? @db.VarChar(4000) grill_md String? @db.Text plan_md String? @db.Text - plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) - reviewed_at DateTime? // When last reviewed + plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) + reviewed_at DateTime? // When last reviewed pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) pbi_id String? @unique status IdeaStatus @default(DRAFT) From 55781e463a91765d5533c8525e93795c074d8500 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:40:30 +0200 Subject: [PATCH 03/19] feat(PBI-96/T-1059): add Zod schemas, folder-mapping, slug helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/schemas/product-doc.ts: PRODUCT_DOC_FOLDERS/STATUSES + create/update/ toggle/frontmatter schemas (MAX_PRODUCT_DOC_CONTENT_LEN=100k) - lib/product-doc-folder.ts: DB UPPER_SNAKE ↔ API lowercase mapper (spiegel van lib/task-status.ts) - lib/product-doc-slug.ts: pure slugify + suggestSlug (dedupe-suffix) + ADR-sequence helpers (nextAdrPrefix, parseAdrNumber, suggestAdrSlug) - lib/schemas/product-doc-frontmatter-defaults.ts: per-folder UI-templates voor "Nieuwe doc"-dialog (last_updated weggelaten — server normaliseert bij save, zie T-1060) - __tests__: 37 tests groen (Zod-schemas + slug-helpers); de pre-existing worktree-env fail in idea-timeline-merge.test.ts blijft buiten scope Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/product-doc-slug.test.ts | 111 ++++++++++++ __tests__/lib/schemas/product-doc.test.ts | 160 ++++++++++++++++++ lib/product-doc-folder.ts | 41 +++++ lib/product-doc-slug.ts | 78 +++++++++ .../product-doc-frontmatter-defaults.ts | 70 ++++++++ lib/schemas/product-doc.ts | 82 +++++++++ 6 files changed, 542 insertions(+) create mode 100644 __tests__/lib/product-doc-slug.test.ts create mode 100644 __tests__/lib/schemas/product-doc.test.ts create mode 100644 lib/product-doc-folder.ts create mode 100644 lib/product-doc-slug.ts create mode 100644 lib/schemas/product-doc-frontmatter-defaults.ts create mode 100644 lib/schemas/product-doc.ts diff --git a/__tests__/lib/product-doc-slug.test.ts b/__tests__/lib/product-doc-slug.test.ts new file mode 100644 index 0000000..bcd1168 --- /dev/null +++ b/__tests__/lib/product-doc-slug.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' + +import { + nextAdrPrefix, + parseAdrNumber, + slugify, + suggestAdrSlug, + suggestSlug, +} from '@/lib/product-doc-slug' + +describe('slugify', () => { + it('maakt simpele titels lowercase met koppeltekens', () => { + expect(slugify('Deploy stappen')).toBe('deploy-stappen') + expect(slugify('Hello, World!')).toBe('hello-world') + }) + + it('stript diakritieken', () => { + expect(slugify('Café écrasé')).toBe('cafe-ecrase') + expect(slugify('Ångström')).toBe('angstrom') + }) + + it('verwijdert leading/trailing dashes', () => { + expect(slugify(' --- hello --- ')).toBe('hello') + }) + + it('capt lengte op 80 tekens', () => { + const long = 'a'.repeat(100) + expect(slugify(long).length).toBe(80) + }) + + it('geeft lege string voor lege/whitespace-only input', () => { + expect(slugify('')).toBe('') + expect(slugify(' ')).toBe('') + expect(slugify('!@#$%')).toBe('') + }) +}) + +describe('suggestSlug', () => { + it('returnt base-slug zonder collision', () => { + expect(suggestSlug('Deploy', [])).toBe('deploy') + }) + + it('voegt -2 suffix toe bij eerste collision', () => { + expect(suggestSlug('Deploy', ['deploy'])).toBe('deploy-2') + }) + + it('telt door bij meerdere collisions', () => { + expect(suggestSlug('Deploy', ['deploy', 'deploy-2', 'deploy-3'])).toBe('deploy-4') + }) + + it('geeft lege string voor lege titel', () => { + expect(suggestSlug('', ['x'])).toBe('') + }) + + it('respecteert max-len bij toevoegen suffix', () => { + const long80 = 'a'.repeat(80) + const result = suggestSlug(long80, [long80]) + expect(result.length).toBeLessThanOrEqual(80) + expect(result.endsWith('-2')).toBe(true) + }) +}) + +describe('nextAdrPrefix', () => { + it('geeft 0001 als er nog geen ADRs zijn', () => { + expect(nextAdrPrefix(null)).toBe('0001') + }) + + it('telt door op currentMax', () => { + expect(nextAdrPrefix(0)).toBe('0001') + expect(nextAdrPrefix(41)).toBe('0042') + expect(nextAdrPrefix(999)).toBe('1000') + }) + + it('pad altijd tot minimaal 4 cijfers', () => { + expect(nextAdrPrefix(null)).toMatch(/^\d{4}$/) + expect(nextAdrPrefix(8)).toBe('0009') + }) +}) + +describe('parseAdrNumber', () => { + it('parseert geldig NNNN-prefix', () => { + expect(parseAdrNumber('0001-context')).toBe(1) + expect(parseAdrNumber('0042-some-slug')).toBe(42) + }) + + it('returns null voor slugs zonder geldig prefix', () => { + expect(parseAdrNumber('context')).toBeNull() + expect(parseAdrNumber('abc-context')).toBeNull() + expect(parseAdrNumber('1-context')).toBeNull() + expect(parseAdrNumber('12345-context')).toBeNull() // 5 cijfers + }) +}) + +describe('suggestAdrSlug', () => { + it('bouwt NNNN-{slug} format', () => { + expect(suggestAdrSlug('Use base-ui not Radix', null)).toBe('0001-use-base-ui-not-radix') + expect(suggestAdrSlug('Use base-ui not Radix', 41)).toBe('0042-use-base-ui-not-radix') + }) + + it('geeft alleen prefix bij lege titel', () => { + expect(suggestAdrSlug('', null)).toBe('0001') + expect(suggestAdrSlug(' ', 5)).toBe('0006') + }) + + it('respecteert max-len van 80 tekens', () => { + const longTitle = 'x'.repeat(100) + const slug = suggestAdrSlug(longTitle, null) + expect(slug.length).toBeLessThanOrEqual(80) + expect(slug.startsWith('0001-')).toBe(true) + }) +}) diff --git a/__tests__/lib/schemas/product-doc.test.ts b/__tests__/lib/schemas/product-doc.test.ts new file mode 100644 index 0000000..3a6b071 --- /dev/null +++ b/__tests__/lib/schemas/product-doc.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest' + +import { + PRODUCT_DOC_FOLDERS, + PRODUCT_DOC_STATUSES, + productDocCreateSchema, + productDocFolderToggleSchema, + productDocFrontmatterSchema, + productDocSlugSchema, + productDocUpdateSchema, +} from '@/lib/schemas/product-doc' + +const validProductId = 'cmohrysyj0000rd17clnjy4tc' + +describe('productDocSlugSchema', () => { + it('accepteert geldige slugs', () => { + expect(productDocSlugSchema.safeParse('deploy').success).toBe(true) + expect(productDocSlugSchema.safeParse('0001-context-decision').success).toBe(true) + expect(productDocSlugSchema.safeParse('a').success).toBe(true) + }) + + it('weigert hoofdletters, spaties en speciale tekens', () => { + expect(productDocSlugSchema.safeParse('Deploy').success).toBe(false) + expect(productDocSlugSchema.safeParse('deploy stappen').success).toBe(false) + expect(productDocSlugSchema.safeParse('deploy/stappen').success).toBe(false) + }) + + it('weigert slug die met streepje begint', () => { + expect(productDocSlugSchema.safeParse('-deploy').success).toBe(false) + }) + + it('weigert slug > 80 tekens', () => { + expect(productDocSlugSchema.safeParse('a'.repeat(81)).success).toBe(false) + expect(productDocSlugSchema.safeParse('a'.repeat(80)).success).toBe(true) + }) +}) + +describe('productDocFrontmatterSchema', () => { + it('accepteert minimaal valide frontmatter', () => { + const r = productDocFrontmatterSchema.safeParse({ title: 'Deploy', status: 'draft' }) + expect(r.success).toBe(true) + }) + + it('weigert ontbrekende title of status', () => { + expect( + productDocFrontmatterSchema.safeParse({ status: 'draft' }).success, + ).toBe(false) + expect( + productDocFrontmatterSchema.safeParse({ title: 'Deploy' }).success, + ).toBe(false) + }) + + it('weigert status die niet in de enum zit', () => { + expect( + productDocFrontmatterSchema.safeParse({ title: 'D', status: 'wip' }).success, + ).toBe(false) + }) + + it('accepteert audience als string of array', () => { + expect( + productDocFrontmatterSchema.safeParse({ + title: 'D', + status: 'draft', + audience: 'maintainer', + }).success, + ).toBe(true) + expect( + productDocFrontmatterSchema.safeParse({ + title: 'D', + status: 'draft', + audience: ['maintainer', 'contributor'], + }).success, + ).toBe(true) + }) + + it('weigert oversized title', () => { + expect( + productDocFrontmatterSchema.safeParse({ + title: 'x'.repeat(201), + status: 'draft', + }).success, + ).toBe(false) + }) +}) + +describe('productDocCreateSchema', () => { + const base = { + product_id: validProductId, + folder: 'runbooks' as const, + slug: 'deploy', + content_md: '---\ntitle: "Deploy"\nstatus: draft\n---\n\nbody', + } + + it('accepteert geldige input', () => { + expect(productDocCreateSchema.safeParse(base).success).toBe(true) + }) + + it('weigert ongeldige folder', () => { + expect( + productDocCreateSchema.safeParse({ ...base, folder: 'wiki' }).success, + ).toBe(false) + }) + + it('weigert ongeldige product_id (geen cuid)', () => { + expect( + productDocCreateSchema.safeParse({ ...base, product_id: 'not-a-cuid' }).success, + ).toBe(false) + }) + + it('weigert leeg of te lang content_md', () => { + expect(productDocCreateSchema.safeParse({ ...base, content_md: '' }).success).toBe(false) + expect( + productDocCreateSchema.safeParse({ ...base, content_md: 'x'.repeat(100_001) }).success, + ).toBe(false) + }) +}) + +describe('productDocUpdateSchema', () => { + it('accepteert valide content_md', () => { + expect( + productDocUpdateSchema.safeParse({ content_md: '---\ntitle: "x"\nstatus: draft\n---\n\nbody' }) + .success, + ).toBe(true) + }) + + it('weigert leeg content_md', () => { + expect(productDocUpdateSchema.safeParse({ content_md: '' }).success).toBe(false) + }) +}) + +describe('productDocFolderToggleSchema', () => { + it('accepteert valide toggle-input', () => { + expect( + productDocFolderToggleSchema.safeParse({ + product_id: validProductId, + folder: 'api', + enabled: false, + }).success, + ).toBe(true) + }) + + it('weigert ontbrekende enabled-vlag', () => { + expect( + productDocFolderToggleSchema.safeParse({ + product_id: validProductId, + folder: 'api', + }).success, + ).toBe(false) + }) +}) + +describe('PRODUCT_DOC_FOLDERS + STATUSES', () => { + it('bevat exact 8 folders', () => { + expect(PRODUCT_DOC_FOLDERS).toHaveLength(8) + }) + + it('bevat exact 4 statussen', () => { + expect(PRODUCT_DOC_STATUSES).toHaveLength(4) + }) +}) diff --git a/lib/product-doc-folder.ts b/lib/product-doc-folder.ts new file mode 100644 index 0000000..e36ac3e --- /dev/null +++ b/lib/product-doc-folder.ts @@ -0,0 +1,41 @@ +// Bidirectionele case-mapper voor de REST API-boundary van ProductDocFolder. +// DB houdt UPPER_SNAKE; API exposeert lowercase. Spiegel van lib/task-status.ts. + +import type { ProductDocFolder } from '@prisma/client' + +import { + PRODUCT_DOC_FOLDERS, + type ProductDocFolderApi, +} from '@/lib/schemas/product-doc' + +const FOLDER_DB_TO_API = { + ADR: 'adr', + ARCHITECTURE: 'architecture', + PATTERNS: 'patterns', + PLANS: 'plans', + RUNBOOKS: 'runbooks', + SPECS: 'specs', + MANUAL: 'manual', + API: 'api', +} as const satisfies Record + +const FOLDER_API_TO_DB: Record = { + adr: 'ADR', + architecture: 'ARCHITECTURE', + patterns: 'PATTERNS', + plans: 'PLANS', + runbooks: 'RUNBOOKS', + specs: 'SPECS', + manual: 'MANUAL', + api: 'API', +} + +export function productDocFolderToApi(f: ProductDocFolder): ProductDocFolderApi { + return FOLDER_DB_TO_API[f] +} + +export function productDocFolderFromApi(s: string): ProductDocFolder | null { + return FOLDER_API_TO_DB[s.toLowerCase()] ?? null +} + +export const PRODUCT_DOC_FOLDER_API_VALUES = PRODUCT_DOC_FOLDERS diff --git a/lib/product-doc-slug.ts b/lib/product-doc-slug.ts new file mode 100644 index 0000000..c8f7ed4 --- /dev/null +++ b/lib/product-doc-slug.ts @@ -0,0 +1,78 @@ +// Slug-helpers voor ProductDoc. Pure functies — geen DB-koppeling. +// Caller (server-action) doet de DB-query om `existing` slugs te leveren. + +const MAX_SLUG_LEN = 80 + +/** + * Genereert een URL-safe slug uit een titel. Diakritieken worden gestript, + * alles buiten [a-z0-9-] wordt vervangen door `-`, en het resultaat wordt + * gecapped op MAX_SLUG_LEN tekens. + */ +export function slugify(input: string): string { + return input + .toLowerCase() + .trim() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG_LEN) + .replace(/-+$/, '') +} + +/** + * Suggesteert een unieke slug door, bij conflict, een numeriek suffix + * (`-2`, `-3`, ...) toe te voegen. Returns '' als de titel leeg slugified. + * + * Caller is verantwoordelijk voor het leveren van bestaande slugs binnen + * dezelfde (product_id, folder)-scope. + */ +export function suggestSlug(title: string, existing: readonly string[]): string { + const base = slugify(title) + if (!base) return '' + if (!existing.includes(base)) return base + + for (let n = 2; n <= 999; n++) { + const suffix = `-${n}` + const trimmed = base.slice(0, MAX_SLUG_LEN - suffix.length).replace(/-+$/, '') + const candidate = `${trimmed}${suffix}` + if (!existing.includes(candidate)) return candidate + } + throw new Error('Te veel slug-collisions; kies een andere titel') +} + +const ADR_PREFIX_RE = /^(\d{4})-/ + +/** + * Geeft het volgende ADR-nummer als 4-cijferige string (`'0042'`). + * `currentMax` is het hoogste reeds gebruikte ADR-nummer binnen deze + * (product_id, folder='adr')-scope, of `null` als er nog geen ADRs zijn + * (eerste = `'0001'`). + */ +export function nextAdrPrefix(currentMax: number | null): string { + const next = (currentMax ?? 0) + 1 + return next.toString().padStart(4, '0') +} + +/** + * Parseert het ADR-volgnummer uit een slug die het `NNNN-...` pattern + * volgt. Returns `null` voor slugs zonder geldig prefix. + */ +export function parseAdrNumber(slug: string): number | null { + const m = ADR_PREFIX_RE.exec(slug) + if (!m) return null + const n = parseInt(m[1], 10) + return Number.isFinite(n) ? n : null +} + +/** + * Bouwt een ADR-slug `NNNN-{slugified-title}` waarbij `NNNN` volgt op + * `currentMax`. Bij lege titel: alleen het prefix. + */ +export function suggestAdrSlug(title: string, currentMax: number | null): string { + const prefix = nextAdrPrefix(currentMax) + const titleSlug = slugify(title) + if (!titleSlug) return prefix + const maxTitleLen = MAX_SLUG_LEN - prefix.length - 1 + return `${prefix}-${titleSlug.slice(0, maxTitleLen).replace(/-+$/, '')}` +} diff --git a/lib/schemas/product-doc-frontmatter-defaults.ts b/lib/schemas/product-doc-frontmatter-defaults.ts new file mode 100644 index 0000000..c46a48d --- /dev/null +++ b/lib/schemas/product-doc-frontmatter-defaults.ts @@ -0,0 +1,70 @@ +// Per-folder template-strings voor het "Nieuwe doc"-dialog. UI-only: +// server gebruikt deze niet. `last_updated` wordt bij save door de server +// gezet (zie setProductDocFrontmatterFields), dus laten we het hier weg. + +import type { ProductDocFolderApi } from '@/lib/schemas/product-doc' + +export interface ProductDocFolderTemplate { + hint: string + template: string +} + +function template(body: string): string { + return `--- +title: "..." +status: draft +audience: [contributor] +--- + +${body}` +} + +export const PRODUCT_DOC_FOLDER_DEFAULTS: Record< + ProductDocFolderApi, + ProductDocFolderTemplate +> = { + adr: { + hint: 'Context → Beslissing → Gevolgen. Gebruik NNNN-{slug} naamgeving (auto-suggested).', + template: template( + `## Context\n\nWelke situatie of vraag triggerde deze beslissing?\n\n## Beslissing\n\nWat is besloten en waarom?\n\n## Gevolgen\n\nWelke trade-offs neemt het team hiermee?\n`, + ), + }, + architecture: { + hint: 'Topisch overzicht van een component of subsysteem.', + template: template( + `## Overzicht\n\nWat is het doel van deze component?\n\n## Datastromen\n\nWelke data komt binnen, wat gebeurt ermee?\n\n## Aandachtspunten\n\n- ...\n`, + ), + }, + patterns: { + hint: 'Herbruikbaar code-pattern met voorbeeld.', + template: template( + `## Wanneer toepassen\n\n## Voorbeeld\n\n\`\`\`ts\n// ...\n\`\`\`\n\n## Valkuilen\n\n- ...\n`, + ), + }, + plans: { + hint: 'Feature/PBI-plan met scope, breakdown en verificatie.', + template: template(`## Context\n\n## Scope\n\n## Breakdown\n\n## Verificatie\n`), + }, + runbooks: { + hint: 'Operationele procedure of incident-flow.', + template: template( + `## Wanneer gebruiken\n\n## Stappen\n\n1. ...\n\n## Verificatie\n\n## Troubleshooting\n`, + ), + }, + specs: { + hint: 'Functionele specificatie met acceptatiecriteria.', + template: template( + `## Doel\n\n## User flow\n\n## Acceptatiecriteria\n\n- [ ] ...\n`, + ), + }, + manual: { + hint: 'Onderdeel van de gebruikers- of developer-manual.', + template: template(`## Inleiding\n\n## Stappen\n\n## Veelgestelde vragen\n`), + }, + api: { + hint: 'API-endpoint of contract-detail.', + template: template( + `## Endpoint\n\n\`POST /api/...\`\n\n## Request\n\n## Response\n\n## Fouten\n`, + ), + }, +} diff --git a/lib/schemas/product-doc.ts b/lib/schemas/product-doc.ts new file mode 100644 index 0000000..eb0b42a --- /dev/null +++ b/lib/schemas/product-doc.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' + +// API-laag werkt met lowercase folder-namen en lowercase statuses. +// DB-mapping (UPPER_SNAKE) leeft in `lib/product-doc-folder.ts`. + +export const PRODUCT_DOC_FOLDERS = [ + 'adr', + 'architecture', + 'patterns', + 'plans', + 'runbooks', + 'specs', + 'manual', + 'api', +] as const + +export const PRODUCT_DOC_STATUSES = [ + 'draft', + 'active', + 'deprecated', + 'archived', +] as const + +export const productDocFolderSchema = z.enum(PRODUCT_DOC_FOLDERS) +export const productDocStatusSchema = z.enum(PRODUCT_DOC_STATUSES) + +// Slugs zijn URL-safe: kleine letters, cijfers, koppeltekens; begint met +// letter/cijfer, max 80 tekens (matcht @db.VarChar(80) in schema.prisma). +export const productDocSlugSchema = z + .string() + .regex( + /^[a-z0-9][a-z0-9-]{0,79}$/, + 'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten (1-80 tekens, niet starten met streepje)', + ) + +// Maximum body-grootte gelijk aan `Idea.plan_md` (lib/schemas/idea.ts). +export const MAX_PRODUCT_DOC_CONTENT_LEN = 100_000 + +// Frontmatter dat in `content_md` als YAML-block staat. `last_updated` is +// optional omdat de server hem bij elke save normaliseert (P2-review-fix); +// `title` en `status` worden bij save naar de gerepliceerde kolommen +// gepushed (zie createProductDocAction in actions/product-docs.ts). +export const productDocFrontmatterSchema = z.object({ + title: z + .string() + .min(1, 'Titel is verplicht') + .max(200, 'Maximaal 200 tekens'), + status: productDocStatusSchema, + audience: z.union([z.string(), z.array(z.string())]).optional(), + applies_to: z.union([z.string(), z.array(z.string())]).optional(), + last_updated: z.string().optional(), +}) + +export const productDocCreateSchema = z.object({ + product_id: z.string().cuid('Ongeldig product'), + folder: productDocFolderSchema, + slug: productDocSlugSchema, + content_md: z + .string() + .min(1, 'Inhoud is verplicht') + .max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`), +}) + +export const productDocUpdateSchema = z.object({ + content_md: z + .string() + .min(1, 'Inhoud is verplicht') + .max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`), +}) + +export const productDocFolderToggleSchema = z.object({ + product_id: z.string().cuid('Ongeldig product'), + folder: productDocFolderSchema, + enabled: z.boolean(), +}) + +export type ProductDocFolderApi = z.infer +export type ProductDocStatusApi = z.infer +export type ProductDocFrontmatter = z.infer +export type ProductDocCreateInput = z.infer +export type ProductDocUpdateInput = z.infer +export type ProductDocFolderToggleInput = z.infer From afafbca855ee3f13f9bb03d98c3ae319f0a295b4 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:42:03 +0200 Subject: [PATCH 04/19] feat(PBI-96/T-1060): add frontmatter parser + serializer (P2-fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/product-doc-parser.ts: parseProductDocMd(md) → {ok, frontmatter, body} | {ok:false, errors[]} met line-info bij YAML-fouten. Pattern gespiegeld uit lib/idea-plan-parser.ts. - lib/product-doc-frontmatter.ts: setProductDocFrontmatterFields(md, patch) laat de server `last_updated` server-side normaliseren (P2-review-fix uit docs/recommendations/product-docs-storage-system-review-2026-05-16). Gebruikt yaml.parseDocument om field-ordering best-effort te behouden. - todayIsoDate() helper voor 'yyyy-mm-dd' string. - __tests__: 19 nieuwe tests groen — parse-success/fail-paden, en expliciete P2-coverage (vervangen + toevoegen last_updated, behoud overige velden + body). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/product-doc-frontmatter.test.ts | 108 ++++++++++++++ __tests__/lib/product-doc-parser.test.ts | 141 ++++++++++++++++++ lib/product-doc-frontmatter.ts | 59 ++++++++ lib/product-doc-parser.ts | 74 +++++++++ 4 files changed, 382 insertions(+) create mode 100644 __tests__/lib/product-doc-frontmatter.test.ts create mode 100644 __tests__/lib/product-doc-parser.test.ts create mode 100644 lib/product-doc-frontmatter.ts create mode 100644 lib/product-doc-parser.ts diff --git a/__tests__/lib/product-doc-frontmatter.test.ts b/__tests__/lib/product-doc-frontmatter.test.ts new file mode 100644 index 0000000..1d5fe44 --- /dev/null +++ b/__tests__/lib/product-doc-frontmatter.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' + +import { parseProductDocMd } from '@/lib/product-doc-parser' +import { + setProductDocFrontmatterFields, + todayIsoDate, +} from '@/lib/product-doc-frontmatter' + +const baseMd = `--- +title: "Deploy" +status: draft +audience: maintainer +last_updated: 2020-01-01 +--- + +# Body + +inhoud +` + +describe('setProductDocFrontmatterFields — P2-coverage', () => { + it('vervangt bestaand last_updated', () => { + const out = setProductDocFrontmatterFields(baseMd, { + last_updated: '2026-05-16', + }) + const parsed = parseProductDocMd(out) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + expect(parsed.frontmatter.last_updated).toBe('2026-05-16') + }) + + it('voegt last_updated toe als afwezig', () => { + const md = `--- +title: "Deploy" +status: draft +--- + +body +` + const out = setProductDocFrontmatterFields(md, { + last_updated: '2026-05-16', + }) + const parsed = parseProductDocMd(out) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + expect(parsed.frontmatter.last_updated).toBe('2026-05-16') + }) + + it('behoudt overige frontmatter-velden', () => { + const out = setProductDocFrontmatterFields(baseMd, { + last_updated: '2026-05-16', + }) + const parsed = parseProductDocMd(out) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + expect(parsed.frontmatter.title).toBe('Deploy') + expect(parsed.frontmatter.status).toBe('draft') + expect(parsed.frontmatter.audience).toBe('maintainer') + }) + + it('behoudt body-inhoud onveranderd', () => { + const out = setProductDocFrontmatterFields(baseMd, { + last_updated: '2026-05-16', + }) + expect(out).toContain('# Body') + expect(out).toContain('inhoud') + }) + + it('kan meerdere velden tegelijk patchen', () => { + const out = setProductDocFrontmatterFields(baseMd, { + last_updated: '2026-05-16', + status: 'active', + }) + const parsed = parseProductDocMd(out) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + expect(parsed.frontmatter.status).toBe('active') + expect(parsed.frontmatter.last_updated).toBe('2026-05-16') + }) + + it('throwed bij ontbrekende frontmatter', () => { + expect(() => + setProductDocFrontmatterFields('# alleen body', { last_updated: 'x' }), + ).toThrow(/yaml-frontmatter/i) + }) + + it('throwed bij broken yaml', () => { + const broken = `--- +title: "open quote +status: draft +--- + +body` + expect(() => + setProductDocFrontmatterFields(broken, { last_updated: 'x' }), + ).toThrow() + }) +}) + +describe('todayIsoDate', () => { + it('returnt yyyy-mm-dd format', () => { + expect(todayIsoDate()).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('respecteert de meegegeven Date', () => { + expect(todayIsoDate(new Date('2026-05-16T12:34:56Z'))).toBe('2026-05-16') + }) +}) diff --git a/__tests__/lib/product-doc-parser.test.ts b/__tests__/lib/product-doc-parser.test.ts new file mode 100644 index 0000000..be614f6 --- /dev/null +++ b/__tests__/lib/product-doc-parser.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest' + +import { parseProductDocMd } from '@/lib/product-doc-parser' + +const minimalValid = `--- +title: "Deploy stappen" +status: draft +--- + +# Body + +stappen hier +` + +describe('parseProductDocMd — succes', () => { + it('parseert minimaal valide doc', () => { + const r = parseProductDocMd(minimalValid) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.frontmatter.title).toBe('Deploy stappen') + expect(r.frontmatter.status).toBe('draft') + expect(r.body.startsWith('# Body')).toBe(true) + }) + + it('accepteert optionele velden (audience, applies_to, last_updated)', () => { + const md = `--- +title: "Doc" +status: active +audience: [maintainer, contributor] +applies_to: PBI-96 +last_updated: 2026-05-16 +--- + +body +` + const r = parseProductDocMd(md) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.frontmatter.audience).toEqual(['maintainer', 'contributor']) + expect(r.frontmatter.applies_to).toBe('PBI-96') + expect(r.frontmatter.last_updated).toBe('2026-05-16') + }) + + it('accepteert audience als single string', () => { + const md = `--- +title: "Doc" +status: draft +audience: maintainer +--- + +body` + const r = parseProductDocMd(md) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.frontmatter.audience).toBe('maintainer') + }) + + it('trimt leading whitespace van body', () => { + const md = `--- +title: "x" +status: draft +--- + + +body +` + const r = parseProductDocMd(md) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.body.startsWith('body')).toBe(true) + }) +}) + +describe('parseProductDocMd — fouten', () => { + it('weigert doc zonder frontmatter (regel 1 error)', () => { + const r = parseProductDocMd('# alleen body') + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.errors[0].line).toBe(1) + expect(r.errors[0].message).toMatch(/yaml-frontmatter/i) + }) + + it('weigert doc zonder afsluitende `---`', () => { + const md = `--- +title: "x" +status: draft + +body +` + const r = parseProductDocMd(md) + expect(r.ok).toBe(false) + }) + + it('weigert frontmatter zonder title', () => { + const md = `--- +status: draft +--- + +body` + const r = parseProductDocMd(md) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.errors.some((e) => e.message.includes('title'))).toBe(true) + }) + + it('weigert frontmatter zonder status', () => { + const md = `--- +title: "x" +--- + +body` + const r = parseProductDocMd(md) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.errors.some((e) => e.message.includes('status'))).toBe(true) + }) + + it('weigert status buiten enum-set', () => { + const md = `--- +title: "x" +status: wip +--- + +body` + const r = parseProductDocMd(md) + expect(r.ok).toBe(false) + }) + + it('geeft line-info bij bad yaml', () => { + const md = `--- +title: "x +status: draft +--- + +body` + const r = parseProductDocMd(md) + expect(r.ok).toBe(false) + if (r.ok) return + expect(r.errors[0].line).toBeGreaterThan(0) + }) +}) diff --git a/lib/product-doc-frontmatter.ts b/lib/product-doc-frontmatter.ts new file mode 100644 index 0000000..3b0d282 --- /dev/null +++ b/lib/product-doc-frontmatter.ts @@ -0,0 +1,59 @@ +// Server-side serializer die individuele frontmatter-velden bijwerkt in +// een al-gevalideerde markdown-doc. P2-review-fix uit +// docs/recommendations/product-docs-storage-system-review-2026-05-16.md +// (last_updated moet door de server worden gezet, niet door de user). +// +// Caller MOET parseProductDocMd al hebben aangeroepen voor pre-validatie +// — deze functie throwed bij parse-fouten. + +import { parseDocument } from 'yaml' + +const FRONTMATTER_RE = + /^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/ + +/** + * Mutates de YAML-frontmatter van `md` met de gegeven `patch`-keys + * (bv. `{ last_updated: '2026-05-16' }`) en geeft de nieuwe markdown + * terug. Behoudt body en frontmatter-delimiters; overige velden blijven + * staan (best-effort op ordering en whitespace via yaml-lib). + */ +export function setProductDocFrontmatterFields( + md: string, + patch: Record, +): string { + const match = md.match(FRONTMATTER_RE) + if (!match) { + throw new Error( + 'setProductDocFrontmatterFields: input mist yaml-frontmatter (geen `---` opener gevonden)', + ) + } + + const [, openMarker, frontmatterRaw, closeMarker, body] = match + + const doc = parseDocument(frontmatterRaw) + if (doc.errors.length > 0) { + throw new Error( + `setProductDocFrontmatterFields: yaml parse-error op regel ${ + doc.errors[0].linePos?.[0]?.line ?? '?' + }: ${doc.errors[0].message}`, + ) + } + + for (const [key, value] of Object.entries(patch)) { + doc.set(key, value) + } + + // yaml.Document.toString() voegt vaak een trailing newline toe — + // strippen voorkomt dubbele newlines vóór de afsluitende `---`. + const newFrontmatter = doc.toString().replace(/\r?\n$/, '') + + return `${openMarker}${newFrontmatter}${closeMarker}${body}` +} + +/** + * ISO-date (yyyy-mm-dd) van vandaag — handige helper voor de server om + * `last_updated` mee te zetten bij elke save. + */ +export function todayIsoDate(now: Date = new Date()): string { + return now.toISOString().slice(0, 10) +} diff --git a/lib/product-doc-parser.ts b/lib/product-doc-parser.ts new file mode 100644 index 0000000..01fcf47 --- /dev/null +++ b/lib/product-doc-parser.ts @@ -0,0 +1,74 @@ +// Parser voor de product_doc markdown. Format: yaml-frontmatter (gevalideerd +// via productDocFrontmatterSchema) + markdown-body. Synchroon — geen LLM. +// +// Wordt door alle Product Doc server-actions (create/update) gebruikt om +// frontmatter te valideren vóór save. Bij parse-fout: 422 met line-info. +// Pattern gespiegeld uit lib/idea-plan-parser.ts. + +import { parse as parseYaml, YAMLParseError } from 'yaml' + +import { + productDocFrontmatterSchema, + type ProductDocFrontmatter, +} from '@/lib/schemas/product-doc' + +export type ProductDocParseError = { + line?: number + message: string + hint?: string +} + +export type ProductDocParseResult = + | { ok: true; frontmatter: ProductDocFrontmatter; body: string } + | { ok: false; errors: ProductDocParseError[] } + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +export function parseProductDocMd(md: string): ProductDocParseResult { + const match = md.match(FRONTMATTER_RE) + if (!match) { + return { + ok: false, + errors: [ + { + line: 1, + message: + 'Doc mist yaml-frontmatter. Eerste regel moet `---` zijn, gevolgd door de frontmatter en een afsluitende `---`.', + }, + ], + } + } + + const [, frontmatterRaw, body] = match + + let parsed: unknown + try { + parsed = parseYaml(frontmatterRaw) + } catch (err) { + if (err instanceof YAMLParseError) { + const yamlLine = err.linePos?.[0]?.line + // +1 voor de openende `---`-regel (frontmatterRaw start op regel 2) + const fileLine = yamlLine != null ? yamlLine + 1 : undefined + return { + ok: false, + errors: [{ line: fileLine, message: err.message }], + } + } + return { + ok: false, + errors: [{ message: err instanceof Error ? err.message : String(err) }], + } + } + + const validation = productDocFrontmatterSchema.safeParse(parsed) + if (!validation.success) { + return { + ok: false, + errors: validation.error.issues.map((iss) => ({ + message: `${iss.path.join('.') || ''}: ${iss.message}`, + })), + } + } + + return { ok: true, frontmatter: validation.data, body: body.trimStart() } +} From 5ae78ff872fc9997761297d77ffdd4407659b2b3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:44:21 +0200 Subject: [PATCH 05/19] feat(PBI-96/T-1061): add rate-limit keys + server-only helpers - lib/rate-limit.ts: 'create-product-doc' (30/min) + 'edit-product-doc' (60/min) in eigen PBI-96-blok na M12-Ideas-keys. - lib/product-docs-server.ts: loadAccessibleProduct + folderApiToDbOrThrow als 'server-only' helpers. Wordt door create/update/list-actions hergebruikt; folder-toggle gebruikt direct user_id-scope (owner-only). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/product-docs-server.ts | 46 ++++++++++++++++++++++++++++++++++++++ lib/rate-limit.ts | 4 ++++ 2 files changed, 50 insertions(+) create mode 100644 lib/product-docs-server.ts diff --git a/lib/product-docs-server.ts b/lib/product-docs-server.ts new file mode 100644 index 0000000..324b38e --- /dev/null +++ b/lib/product-docs-server.ts @@ -0,0 +1,46 @@ +// Server-only helpers voor de Product Docs server-actions. Bevat +// prisma-toegang en mag NIET in client-componenten worden geïmporteerd +// (zie CLAUDE.md hardstop "Server/client grens"). +// +// Plan: docs/plans/PBI-96-product-docs.md §B.2. + +import 'server-only' + +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { productDocFolderFromApi } from '@/lib/product-doc-folder' + +import type { ProductDocFolder } from '@prisma/client' + +/** + * Laadt het Product gescoped op `productAccessFilter(userId)` voor een + * gegeven product_id. Returnt alleen de velden die de write-actions + * nodig hebben (`id`, eigenaar, folder-config). Voor folder-toggle + * gebruikt de owner-only-check direct `where: { id, user_id }` zodat + * ProductMember de folder-config niet kan wijzigen. + * + * Returnt `null` als de user geen access heeft — caller stuurt dan + * 404 (anti-enum, géén 403). + */ +export async function loadAccessibleProduct(productId: string, userId: string) { + return prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(userId) }, + select: { id: true, user_id: true, enabled_doc_folders: true }, + }) +} + +/** + * Converteert een API-folder-string (`'runbooks'`) naar het Prisma-enum + * (`'RUNBOOKS'`). Throwed bij onbekende waarden — server-actions hebben + * de input al via zod gevalideerd, dus dit dient alleen als type-narrowing + * vangnet. + */ +export function folderApiToDbOrThrow(folderApi: string): ProductDocFolder { + const db = productDocFolderFromApi(folderApi) + if (!db) { + throw new Error( + `Internal: folderApiToDbOrThrow ontving onbekende folder "${folderApi}" — zod-validatie zou dit niet door moeten laten`, + ) + } + return db +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 3d99843..cbc8da8 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -33,6 +33,10 @@ const CONFIGS: Record = { 'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers 'materialize-idea': { windowMs: 60_000, max: 5 }, 'create-user-question': { windowMs: 60_000, max: 20 }, // PLAN_CHAT vragen + + // PBI-96 — Per-product Product Docs (zie docs/plans/PBI-96-product-docs.md) + 'create-product-doc': { windowMs: 60_000, max: 30 }, + 'edit-product-doc': { windowMs: 60_000, max: 60 }, } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 } From ca301b5792e3aa27a47a786121a5385a87e732ee Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:46:24 +0200 Subject: [PATCH 06/19] feat(PBI-96/T-1062): createProductDocAction + updateProductDocAction (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/product-docs.ts: create+update volgens server-action.md pattern (auth → demo → rate-limit → zod → parse-md → scope → folder-enabled → normalize last_updated → tx(create+log) → revalidatePath). - P2-coverage volledig: • title/status uit parsed.frontmatter naar gerepliceerde kolommen • last_updated server-side overschreven via setProductDocFrontmatterFields • frontmatter-parse-fail → 422 zonder DB-write • slug-conflict (P2002) → 422 met heldere foutmelding • folder uitgeschakeld → 422 'staat uit' - update logt UPDATED met prev_status + new_status in metadata. - 14 nieuwe tests groen (auth-paden, P2-create, P2-last_updated, conflict-mapping, update-sync). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/product-docs.test.ts | 291 +++++++++++++++++++++++++ actions/product-docs.ts | 249 +++++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 __tests__/actions/product-docs.test.ts create mode 100644 actions/product-docs.ts diff --git a/__tests__/actions/product-docs.test.ts b/__tests__/actions/product-docs.test.ts new file mode 100644 index 0000000..9def6d7 --- /dev/null +++ b/__tests__/actions/product-docs.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSession } = vi.hoisted(() => ({ + mockSession: { userId: 'user-1', isDemo: false } as { + userId: string | undefined + isDemo: boolean + }, +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockImplementation(async () => mockSession), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { + cookieName: 'test', + password: 'test-password-32-chars-minimum-len', + }, +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + productDoc: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + productDocLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { _resetRateLimit } from '@/lib/rate-limit' +import { + createProductDocAction, + updateProductDocAction, +} from '@/actions/product-docs' + +const VALID_PRODUCT_ID = 'cmohrysyj0000rd17clnjy4tc' +const VALID_DOC_MD = `--- +title: "Deploy stappen" +status: draft +--- + +# Body + +stappen +` + +function setSession(s: Partial) { + Object.assign(mockSession, s) +} + +beforeEach(() => { + setSession({ userId: 'user-1', isDemo: false }) + _resetRateLimit() + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// createProductDocAction + +describe('createProductDocAction', () => { + const baseInput = { + product_id: VALID_PRODUCT_ID, + folder: 'runbooks' as const, + slug: 'deploy', + content_md: VALID_DOC_MD, + } + + it('returnt 401 als niet-ingelogd', async () => { + setSession({ userId: undefined }) + const r = await createProductDocAction(baseInput) + expect(r).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returnt 403 voor demo-user', async () => { + setSession({ isDemo: true }) + const r = await createProductDocAction(baseInput) + expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 }) + }) + + it('returnt 422 bij invalide product_id (zod-fail)', async () => { + const r = await createProductDocAction({ ...baseInput, product_id: 'not-a-cuid' }) + expect('code' in r && r.code).toBe(422) + }) + + it('returnt 422 als content_md geen frontmatter heeft (P2-validation)', async () => { + const r = await createProductDocAction({ ...baseInput, content_md: '# alleen body' }) + expect('code' in r && r.code).toBe(422) + expect((prisma.productDoc.create as ReturnType)).not.toHaveBeenCalled() + }) + + it('returnt 404 als product niet toegankelijk', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce(null) + const r = await createProductDocAction(baseInput) + expect(r).toEqual({ error: 'Product niet gevonden', code: 404 }) + }) + + it('returnt 422 als folder is uitgeschakeld', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'user-1', + enabled_doc_folders: ['ADR'], + }) + const r = await createProductDocAction(baseInput) + expect('code' in r && r.code).toBe(422) + expect('error' in r && r.error).toMatch(/staat uit/i) + }) + + it('schrijft title/status uit frontmatter naar de kolommen (P2-create)', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'user-1', + enabled_doc_folders: ['RUNBOOKS', 'ADR'], + }) + ;(prisma.$transaction as ReturnType).mockImplementationOnce( + async (cb: (tx: typeof prisma) => Promise) => { + const tx = { + productDoc: { + create: vi.fn().mockResolvedValue({ + id: 'doc-1', + folder: 'RUNBOOKS', + slug: 'deploy', + }), + }, + productDocLog: { create: vi.fn().mockResolvedValue({}) }, + } + const result = await cb(tx as unknown as typeof prisma) + ;(prisma.productDoc.create as ReturnType).mockImplementation( + tx.productDoc.create, + ) + ;(prisma.productDocLog.create as ReturnType).mockImplementation( + tx.productDocLog.create, + ) + // Bewaar de tx-mocks zodat de test ze kan inspecteren + ;(prisma.productDoc.create as ReturnType & { calls: unknown[] }) + .calls = tx.productDoc.create.mock.calls + return result + }, + ) + + const r = await createProductDocAction({ + ...baseInput, + content_md: `---\ntitle: "Custom Title"\nstatus: active\n---\n\nbody`, + }) + expect('success' in r && r.success).toBe(true) + + const txCalls = (prisma.productDoc.create as ReturnType & { + calls: unknown[] + }).calls + expect(txCalls.length).toBeGreaterThan(0) + const createArg = (txCalls[0] as [{ data: { title: string; status: string } }])[0] + expect(createArg.data.title).toBe('Custom Title') + expect(createArg.data.status).toBe('active') + }) + + it('overschrijft user-supplied last_updated met today (P2-last_updated)', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'user-1', + enabled_doc_folders: ['RUNBOOKS'], + }) + let capturedCreateArg: { data: { content_md: string } } | null = null + ;(prisma.$transaction as ReturnType).mockImplementationOnce( + async (cb: (tx: typeof prisma) => Promise) => { + const tx = { + productDoc: { + create: vi + .fn() + .mockImplementation(async (arg: { data: { content_md: string } }) => { + capturedCreateArg = arg + return { id: 'doc-1', folder: 'RUNBOOKS', slug: 'deploy' } + }), + }, + productDocLog: { create: vi.fn().mockResolvedValue({}) }, + } + return cb(tx as unknown as typeof prisma) + }, + ) + + const stale = `---\ntitle: "X"\nstatus: draft\nlast_updated: 2020-01-01\n---\n\nbody` + const r = await createProductDocAction({ ...baseInput, content_md: stale }) + expect('success' in r && r.success).toBe(true) + + expect(capturedCreateArg).not.toBeNull() + const savedMd = capturedCreateArg!.data.content_md + expect(savedMd).not.toMatch(/2020-01-01/) + expect(savedMd).toMatch(/last_updated:\s*['"]?\d{4}-\d{2}-\d{2}/) + }) + + it('returnt 422 bij slug-conflict (P2002)', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'user-1', + enabled_doc_folders: ['RUNBOOKS'], + }) + ;(prisma.$transaction as ReturnType).mockRejectedValueOnce({ + code: 'P2002', + }) + + const r = await createProductDocAction(baseInput) + expect('code' in r && r.code).toBe(422) + expect('error' in r && r.error).toMatch(/bestaat al/i) + }) +}) + +// --------------------------------------------------------------------------- +// updateProductDocAction + +describe('updateProductDocAction', () => { + const NEW_MD = `--- +title: "Updated" +status: active +--- + +new body +` + + it('returnt 401 als niet-ingelogd', async () => { + setSession({ userId: undefined }) + const r = await updateProductDocAction('doc-1', NEW_MD) + expect(r).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returnt 403 voor demo-user', async () => { + setSession({ isDemo: true }) + const r = await updateProductDocAction('doc-1', NEW_MD) + expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 }) + }) + + it('returnt 404 als doc niet toegankelijk', async () => { + ;(prisma.productDoc.findFirst as ReturnType).mockResolvedValueOnce(null) + const r = await updateProductDocAction('doc-1', NEW_MD) + expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 }) + }) + + it('returnt 422 bij broken frontmatter (zonder DB-write)', async () => { + ;(prisma.productDoc.findFirst as ReturnType).mockResolvedValueOnce({ + id: 'doc-1', + product_id: 'p', + folder: 'RUNBOOKS', + slug: 'deploy', + status: 'draft', + }) + const r = await updateProductDocAction('doc-1', '# alleen body') + expect('code' in r && r.code).toBe(422) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('sync titel/status + logt UPDATED met prev/new-status', async () => { + ;(prisma.productDoc.findFirst as ReturnType).mockResolvedValueOnce({ + id: 'doc-1', + product_id: 'p', + folder: 'RUNBOOKS', + slug: 'deploy', + status: 'draft', + }) + ;(prisma.$transaction as ReturnType).mockResolvedValueOnce([ + undefined, + undefined, + ]) + + const r = await updateProductDocAction('doc-1', NEW_MD) + expect('success' in r && r.success).toBe(true) + + expect(prisma.productDoc.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'doc-1' }, + data: expect.objectContaining({ + title: 'Updated', + status: 'active', + }), + }), + ) + expect(prisma.productDocLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'UPDATED', + metadata: expect.objectContaining({ + prev_status: 'draft', + new_status: 'active', + }), + }), + }), + ) + }) +}) diff --git a/actions/product-docs.ts b/actions/product-docs.ts new file mode 100644 index 0000000..e8d32e4 --- /dev/null +++ b/actions/product-docs.ts @@ -0,0 +1,249 @@ +'use server' + +// Server-actions voor de ProductDoc-entity (PBI-96). Volgt +// docs/patterns/server-action.md: auth → demo-guard → rate-limit → zod → +// scope-check → frontmatter-parse → tx-write+log → revalidatePath. Pattern +// gespiegeld uit actions/ideas.ts (markdown-edit flow, regels 232-313). +// +// Belangrijke review-fixes (zie +// docs/recommendations/product-docs-storage-system-review-2026-05-16.md): +// - P1 (delete-audit): log met doc_id:null vóór delete in $transaction +// — geen FK-race, geen interactieve tx nodig (in T-1063). +// - P2 (frontmatter-sync): title/status uit parsed.frontmatter worden +// naar de gerepliceerde kolommen geschreven; last_updated wordt +// server-side genormaliseerd via setProductDocFrontmatterFields. +// +// Plan: docs/plans/PBI-96-product-docs.md §B.2. + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' + +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' +import { productAccessFilter } from '@/lib/product-access' +import { + folderApiToDbOrThrow, + loadAccessibleProduct, +} from '@/lib/product-docs-server' +import { productDocFolderToApi } from '@/lib/product-doc-folder' +import { + productDocCreateSchema, + productDocUpdateSchema, + type ProductDocCreateInput, +} from '@/lib/schemas/product-doc' +import { parseProductDocMd } from '@/lib/product-doc-parser' +import { + setProductDocFrontmatterFields, + todayIsoDate, +} from '@/lib/product-doc-frontmatter' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +type ActionResult = + | { success: true; data?: T } + | { error: string; code?: number; details?: unknown } + +function isPrismaUniqueConstraintError(err: unknown): boolean { + return ( + err != null && + typeof err === 'object' && + 'code' in err && + (err as { code: string }).code === 'P2002' + ) +} + +// --------------------------------------------------------------------------- +// CREATE + +export async function createProductDocAction( + input: ProductDocCreateInput, +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) + return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('create-product-doc', session.userId) + if (limited) return limited + + const parsedInput = productDocCreateSchema.safeParse(input) + if (!parsedInput.success) { + return { + error: 'Validatie mislukt', + code: 422, + details: parsedInput.error.flatten().fieldErrors, + } + } + + // P2: parse + valideer frontmatter (422 met line-info bij fout) + const parsedMd = parseProductDocMd(parsedInput.data.content_md) + if (!parsedMd.ok) { + return { + error: 'content_md is niet parseerbaar', + code: 422, + details: parsedMd.errors, + } + } + + const userId = session.userId + const product = await loadAccessibleProduct( + parsedInput.data.product_id, + userId, + ) + if (!product) return { error: 'Product niet gevonden', code: 404 } + + const folderDb = folderApiToDbOrThrow(parsedInput.data.folder) + if (!product.enabled_doc_folders.includes(folderDb)) { + return { + error: `Folder '${parsedInput.data.folder}' staat uit voor dit product`, + code: 422, + } + } + + // P2: normaliseer last_updated server-side in het opgeslagen content_md + const normalized = setProductDocFrontmatterFields( + parsedInput.data.content_md, + { last_updated: todayIsoDate() }, + ) + + try { + const created = await prisma.$transaction(async (tx) => { + const doc = await tx.productDoc.create({ + data: { + product_id: product.id, + folder: folderDb, + slug: parsedInput.data.slug, + title: parsedMd.frontmatter.title, // P2: sync uit frontmatter + status: parsedMd.frontmatter.status, // P2: sync uit frontmatter + content_md: normalized, + created_by: userId, + }, + select: { id: true, folder: true, slug: true }, + }) + + await tx.productDocLog.create({ + data: { + product_id: product.id, + doc_id: doc.id, + actor_user_id: userId, + type: 'CREATED', + metadata: { + folder: productDocFolderToApi(folderDb), + slug: parsedInput.data.slug, + title: parsedMd.frontmatter.title, + length: normalized.length, + }, + }, + }) + + return doc + }) + + const folderApi = productDocFolderToApi(created.folder) + revalidatePath(`/products/${product.id}/docs`) + revalidatePath(`/products/${product.id}/docs/${folderApi}`) + + return { + success: true, + data: { id: created.id, folder: folderApi, slug: created.slug }, + } + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + return { + error: `Slug '${parsedInput.data.slug}' bestaat al in folder '${parsedInput.data.folder}'`, + code: 422, + } + } + throw err + } +} + +// --------------------------------------------------------------------------- +// UPDATE + +export async function updateProductDocAction( + id: string, + contentMd: string, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) + return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('edit-product-doc', session.userId) + if (limited) return limited + + const parsedInput = productDocUpdateSchema.safeParse({ content_md: contentMd }) + if (!parsedInput.success) { + return { + error: 'Validatie mislukt', + code: 422, + details: parsedInput.error.flatten().fieldErrors, + } + } + + const userId = session.userId + const existing = await prisma.productDoc.findFirst({ + where: { id, product: productAccessFilter(userId) }, + select: { + id: true, + product_id: true, + folder: true, + slug: true, + status: true, + }, + }) + if (!existing) return { error: 'Doc niet gevonden', code: 404 } + + const parsedMd = parseProductDocMd(parsedInput.data.content_md) + if (!parsedMd.ok) { + return { + error: 'content_md is niet parseerbaar', + code: 422, + details: parsedMd.errors, + } + } + + // P2: normaliseer last_updated server-side + const normalized = setProductDocFrontmatterFields( + parsedInput.data.content_md, + { last_updated: todayIsoDate() }, + ) + + await prisma.$transaction([ + prisma.productDoc.update({ + where: { id }, + data: { + title: parsedMd.frontmatter.title, // P2: sync uit frontmatter + status: parsedMd.frontmatter.status, // P2: sync uit frontmatter + content_md: normalized, + }, + }), + prisma.productDocLog.create({ + data: { + product_id: existing.product_id, + doc_id: id, + actor_user_id: userId, + type: 'UPDATED', + metadata: { + length: normalized.length, + prev_status: existing.status, + new_status: parsedMd.frontmatter.status, + }, + }, + }), + ]) + + const folderApi = productDocFolderToApi(existing.folder) + revalidatePath(`/products/${existing.product_id}/docs`) + revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`) + revalidatePath( + `/products/${existing.product_id}/docs/${folderApi}/${existing.slug}`, + ) + + return { success: true } +} From 1d116c44ff3083871d20bcbc5db67a3382e40354 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:47:09 +0200 Subject: [PATCH 07/19] feat(PBI-96/T-1063): deleteProductDocAction with P1-fix (no FK race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/product-docs.ts: deleteProductDocAction haalt eerst metadata op (folder/slug/title), schrijft dan log met doc_id:null + delete in één $transaction. Geen SetNull-race, geen interactieve tx nodig. - __tests__: 4 nieuwe tests (auth-paden + P1-coverage met expliciete check op doc_id:null, type:'DELETED' en metadata-velden gevuld). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/product-docs.test.ts | 64 ++++++++++++++++++++++++++ actions/product-docs.ts | 56 ++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/__tests__/actions/product-docs.test.ts b/__tests__/actions/product-docs.test.ts index 9def6d7..978263d 100644 --- a/__tests__/actions/product-docs.test.ts +++ b/__tests__/actions/product-docs.test.ts @@ -36,6 +36,7 @@ import { prisma } from '@/lib/prisma' import { _resetRateLimit } from '@/lib/rate-limit' import { createProductDocAction, + deleteProductDocAction, updateProductDocAction, } from '@/actions/product-docs' @@ -289,3 +290,66 @@ new body ) }) }) + +// --------------------------------------------------------------------------- +// deleteProductDocAction — P1-review-fix coverage + +describe('deleteProductDocAction', () => { + it('returnt 401 als niet-ingelogd', async () => { + setSession({ userId: undefined }) + const r = await deleteProductDocAction('doc-1') + expect(r).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returnt 403 voor demo-user', async () => { + setSession({ isDemo: true }) + const r = await deleteProductDocAction('doc-1') + expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 }) + }) + + it('returnt 404 als doc niet toegankelijk', async () => { + ;(prisma.productDoc.findFirst as ReturnType).mockResolvedValueOnce(null) + const r = await deleteProductDocAction('doc-1') + expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 }) + }) + + it('P1: log heeft doc_id:null + metadata met folder/slug/title (geen FK-race)', async () => { + ;(prisma.productDoc.findFirst as ReturnType).mockResolvedValueOnce({ + id: 'doc-1', + product_id: 'product-1', + folder: 'RUNBOOKS', + slug: 'deploy', + title: 'Deploy stappen', + }) + ;(prisma.$transaction as ReturnType).mockResolvedValueOnce([ + undefined, + undefined, + ]) + + const r = await deleteProductDocAction('doc-1') + expect('success' in r && r.success).toBe(true) + + // De $transaction krijgt een array met [log, delete] in die volgorde. + const txArg = (prisma.$transaction as ReturnType).mock.calls[0][0] + expect(Array.isArray(txArg)).toBe(true) + + // De productDocLog.create call moet doc_id:null hebben + DELETED + metadata + expect(prisma.productDocLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + product_id: 'product-1', + doc_id: null, // P1-fix + type: 'DELETED', + metadata: expect.objectContaining({ + folder: 'runbooks', + slug: 'deploy', + title: 'Deploy stappen', + }), + }), + }), + ) + + // En de delete moet aangeroepen zijn op het juiste id + expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } }) + }) +}) diff --git a/actions/product-docs.ts b/actions/product-docs.ts index e8d32e4..0daef2c 100644 --- a/actions/product-docs.ts +++ b/actions/product-docs.ts @@ -247,3 +247,59 @@ export async function updateProductDocAction( return { success: true } } + +// --------------------------------------------------------------------------- +// DELETE — verwerkt P1-review-fix (delete-audit FK-race) +// +// Probleem zoals omschreven in +// docs/recommendations/product-docs-storage-system-review-2026-05-16.md (P1): +// als de log na de delete met `doc_id:` wordt aangemaakt, faalt de +// foreign key. Met SetNull-cascade zou de FK 'genezen', maar dat vereist +// een interactieve transaction met juiste volgorde. +// +// Fix: schrijf log met `doc_id: null` VANAF HET BEGIN. Geen FK-race, geen +// interactieve tx nodig. Metadata bewaart `folder/slug/title` voor +// traceability — de relatie is wel verloren maar de informatie niet. + +export async function deleteProductDocAction(id: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) + return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const userId = session.userId + const existing = await prisma.productDoc.findFirst({ + where: { id, product: productAccessFilter(userId) }, + select: { + id: true, + product_id: true, + folder: true, + slug: true, + title: true, + }, + }) + if (!existing) return { error: 'Doc niet gevonden', code: 404 } + + await prisma.$transaction([ + prisma.productDocLog.create({ + data: { + product_id: existing.product_id, + doc_id: null, // P1-fix: null vanaf het begin + actor_user_id: userId, + type: 'DELETED', + metadata: { + folder: productDocFolderToApi(existing.folder), + slug: existing.slug, + title: existing.title, + }, + }, + }), + prisma.productDoc.delete({ where: { id } }), + ]) + + const folderApi = productDocFolderToApi(existing.folder) + revalidatePath(`/products/${existing.product_id}/docs`) + revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`) + + return { success: true } +} From 4539de1fff1246205ff69347b67f13daa7ca7cd3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:48:24 +0200 Subject: [PATCH 08/19] feat(PBI-96/T-1064): toggleProductDocFolderAction + listProductDocsAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toggleProductDocFolderAction: owner-only scope (where: id + user_id, NIET productAccessFilter — folder-config is product-setting). Idempotent (no-op + success als al in target-staat). Disabled folder verwijdert GEEN docs uit DB; alleen flag in enabled_doc_folders. Log met doc_id:null + FOLDER_ENABLED/FOLDER_DISABLED. - listProductDocsAction: read-only, scope via productAccessFilter (zonder demo-403 — demo MAG lezen, zie plan §B.4). Geen rate-limit. Select zonder content_md. OrderBy [folder, slug]. Mapt DB-enum naar API-string. - Tests: 10 nieuwe (owner-only-check, idempotent, enable+disable-logs, demo-read-OK, folder-filter, ontbreken content_md in select). Totaal 28 tests in product-docs actions; 1008 tests groen in monorepo. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/product-docs.test.ts | 171 ++++++++++++++++++++++++- actions/product-docs.ts | 122 ++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/__tests__/actions/product-docs.test.ts b/__tests__/actions/product-docs.test.ts index 978263d..9d27fbb 100644 --- a/__tests__/actions/product-docs.test.ts +++ b/__tests__/actions/product-docs.test.ts @@ -20,9 +20,10 @@ vi.mock('@/lib/session', () => ({ })) vi.mock('@/lib/prisma', () => ({ prisma: { - product: { findFirst: vi.fn() }, + product: { findFirst: vi.fn(), update: vi.fn() }, productDoc: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -37,6 +38,8 @@ import { _resetRateLimit } from '@/lib/rate-limit' import { createProductDocAction, deleteProductDocAction, + listProductDocsAction, + toggleProductDocFolderAction, updateProductDocAction, } from '@/actions/product-docs' @@ -353,3 +356,169 @@ describe('deleteProductDocAction', () => { expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } }) }) }) + +// --------------------------------------------------------------------------- +// toggleProductDocFolderAction — owner-only check + +describe('toggleProductDocFolderAction', () => { + const baseInput = { + product_id: VALID_PRODUCT_ID, + folder: 'api' as const, + enabled: false, + } + + it('returnt 401 als niet-ingelogd', async () => { + setSession({ userId: undefined }) + const r = await toggleProductDocFolderAction(baseInput) + expect(r).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returnt 403 voor demo-user', async () => { + setSession({ isDemo: true }) + const r = await toggleProductDocFolderAction(baseInput) + expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 }) + }) + + it('returnt 404 als de user geen owner is (ook niet als ProductMember)', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce(null) + const r = await toggleProductDocFolderAction(baseInput) + expect(r).toEqual({ error: 'Product niet gevonden', code: 404 }) + // Check dat de scope owner-only is: where bevat user_id (geen OR met members) + const call = (prisma.product.findFirst as ReturnType).mock.calls[0][0] + expect(call.where.user_id).toBe('user-1') + }) + + it('idempotent: target-staat == huidige staat → success zonder DB-write', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + enabled_doc_folders: ['ADR', 'API'], + }) + + // enabled:false maar API zit al uit (niet in array) → no-op + const r = await toggleProductDocFolderAction({ ...baseInput, folder: 'manual', enabled: false }) + expect('success' in r && r.success).toBe(true) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('disable folder: update + FOLDER_DISABLED-log', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + enabled_doc_folders: ['ADR', 'API'], + }) + ;(prisma.$transaction as ReturnType).mockResolvedValueOnce([ + undefined, + undefined, + ]) + + const r = await toggleProductDocFolderAction({ + product_id: VALID_PRODUCT_ID, + folder: 'api', + enabled: false, + }) + expect('success' in r && r.success).toBe(true) + + expect(prisma.product.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: VALID_PRODUCT_ID }, + data: { enabled_doc_folders: ['ADR'] }, + }), + ) + expect(prisma.productDocLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'FOLDER_DISABLED', + doc_id: null, + metadata: { folder: 'api' }, + }), + }), + ) + }) + + it('enable folder: update + FOLDER_ENABLED-log', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + enabled_doc_folders: ['ADR'], + }) + ;(prisma.$transaction as ReturnType).mockResolvedValueOnce([ + undefined, + undefined, + ]) + + const r = await toggleProductDocFolderAction({ + product_id: VALID_PRODUCT_ID, + folder: 'api', + enabled: true, + }) + expect('success' in r && r.success).toBe(true) + expect(prisma.productDocLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'FOLDER_ENABLED' }), + }), + ) + }) +}) + +// --------------------------------------------------------------------------- +// listProductDocsAction — read-only; demo MAG lezen + +describe('listProductDocsAction', () => { + it('returnt 401 als niet-ingelogd', async () => { + setSession({ userId: undefined }) + const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID }) + expect(r).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('demo MAG lezen (geen 403)', async () => { + setSession({ isDemo: true }) + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'other', + enabled_doc_folders: [], + }) + ;(prisma.productDoc.findMany as ReturnType).mockResolvedValueOnce([]) + const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID }) + expect('success' in r && r.success).toBe(true) + }) + + it('returnt 404 als product niet toegankelijk', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce(null) + const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID }) + expect(r).toEqual({ error: 'Product niet gevonden', code: 404 }) + }) + + it('returnt gemapte items zonder content_md', async () => { + ;(prisma.product.findFirst as ReturnType).mockResolvedValueOnce({ + id: VALID_PRODUCT_ID, + user_id: 'user-1', + enabled_doc_folders: ['RUNBOOKS'], + }) + ;(prisma.productDoc.findMany as ReturnType).mockResolvedValueOnce([ + { + id: 'd1', + folder: 'RUNBOOKS', + slug: 'deploy', + title: 'Deploy', + status: 'active', + updated_at: new Date('2026-05-16'), + }, + ]) + const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID, folder: 'runbooks' }) + expect('success' in r && r.success).toBe(true) + if ('data' in r && r.data) { + expect(r.data).toHaveLength(1) + expect(r.data[0]).toMatchObject({ + id: 'd1', + folder: 'runbooks', // lowercase mapping + slug: 'deploy', + title: 'Deploy', + status: 'active', + }) + } + + // Check dat folder-filter in de query zit + const call = (prisma.productDoc.findMany as ReturnType).mock.calls[0][0] + expect(call.where.folder).toBe('RUNBOOKS') + // En dat content_md niet geselecteerd is + expect(call.select.content_md).toBeUndefined() + }) +}) diff --git a/actions/product-docs.ts b/actions/product-docs.ts index 0daef2c..62f4cc1 100644 --- a/actions/product-docs.ts +++ b/actions/product-docs.ts @@ -30,8 +30,10 @@ import { import { productDocFolderToApi } from '@/lib/product-doc-folder' import { productDocCreateSchema, + productDocFolderToggleSchema, productDocUpdateSchema, type ProductDocCreateInput, + type ProductDocFolderToggleInput, } from '@/lib/schemas/product-doc' import { parseProductDocMd } from '@/lib/product-doc-parser' import { @@ -303,3 +305,123 @@ export async function deleteProductDocAction(id: string): Promise return { success: true } } + +// --------------------------------------------------------------------------- +// FOLDER TOGGLE — owner-only (NIET productAccessFilter, folder-config is +// een product-setting, niet een doc-mutation). ProductMember kan dus geen +// folders aan/uit zetten. +// +// Idempotent: als de target-staat al de huidige staat is, doet de action +// niets en returnt success — geen log-rij, geen revalidate. + +export async function toggleProductDocFolderAction( + input: ProductDocFolderToggleInput, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) + return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = productDocFolderToggleSchema.safeParse(input) + if (!parsed.success) { + return { + error: 'Validatie mislukt', + code: 422, + details: parsed.error.flatten().fieldErrors, + } + } + + const userId = session.userId + // Owner-only — NIET productAccessFilter + const product = await prisma.product.findFirst({ + where: { id: parsed.data.product_id, user_id: userId }, + select: { id: true, enabled_doc_folders: true }, + }) + if (!product) return { error: 'Product niet gevonden', code: 404 } + + const folderDb = folderApiToDbOrThrow(parsed.data.folder) + const isEnabledNow = product.enabled_doc_folders.includes(folderDb) + + if (parsed.data.enabled === isEnabledNow) { + return { success: true } // idempotent + } + + const next = parsed.data.enabled + ? Array.from(new Set([...product.enabled_doc_folders, folderDb])) + : product.enabled_doc_folders.filter((f) => f !== folderDb) + + await prisma.$transaction([ + prisma.product.update({ + where: { id: product.id }, + data: { enabled_doc_folders: next }, + }), + prisma.productDocLog.create({ + data: { + product_id: product.id, + doc_id: null, + actor_user_id: userId, + type: parsed.data.enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED', + metadata: { folder: parsed.data.folder }, + }, + }), + ]) + + revalidatePath(`/products/${product.id}/docs`) + revalidatePath(`/products/${product.id}/docs/settings`) + + return { success: true } +} + +// --------------------------------------------------------------------------- +// LIST — read-only. Demo MAG lezen (zie plan §B.4). Geen rate-limit. + +export interface ProductDocListItem { + id: string + folder: string + slug: string + title: string + status: string + updated_at: Date +} + +export async function listProductDocsAction(input: { + product_id: string + folder?: string +}): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + + const userId = session.userId + const product = await loadAccessibleProduct(input.product_id, userId) + if (!product) return { error: 'Product niet gevonden', code: 404 } + + const folderDb = input.folder ? folderApiToDbOrThrow(input.folder) : undefined + + const docs = await prisma.productDoc.findMany({ + where: { + product_id: product.id, + ...(folderDb ? { folder: folderDb } : {}), + }, + select: { + id: true, + folder: true, + slug: true, + title: true, + status: true, + updated_at: true, + }, + orderBy: [{ folder: 'asc' }, { slug: 'asc' }], + }) + + return { + success: true, + data: docs.map((d) => ({ + id: d.id, + folder: productDocFolderToApi(d.folder), + slug: d.slug, + title: d.title, + status: d.status, + updated_at: d.updated_at, + })), + } +} From 73324209147a044d58356c5f0f7afeae7610c20b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 16 May 2026 11:50:50 +0200 Subject: [PATCH 09/19] feat(PBI-96/T-1065): extract MarkdownDocEditor as shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/shared/markdown-doc-editor.tsx: geëxtraheerd uit components/ideas/idea-md-editor.tsx zodat Ideas (grill/plan) en Product Docs dezelfde editor-stack delen (CLAUDE.md dialog-discipline: "twee keer kopieren = promote 'm meteen"). - Props: storageKey + initialValue + validate? + onSave + onSaved? + onCancel + rows? + placeholder? + saveLabel? + validationErrorsHeader? + debug-attrs. Component bevat geen entity-specifieke logica. - 14 nieuwe tests groen (rendering/dirty-state, localStorage persist+ restore+clear, Cmd+S/Ctrl+S save, success-clear+onSaved+onCancel, error-rendering, validation blocks submit, cancel-button). - T-1066 (volgende) refactort idea-md-editor naar wrapper rond deze. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/markdown-doc-editor.test.tsx | 263 ++++++++++++++++++ components/shared/markdown-doc-editor.tsx | 222 +++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 __tests__/components/shared/markdown-doc-editor.test.tsx create mode 100644 components/shared/markdown-doc-editor.tsx diff --git a/__tests__/components/shared/markdown-doc-editor.test.tsx b/__tests__/components/shared/markdown-doc-editor.test.tsx new file mode 100644 index 0000000..29196d3 --- /dev/null +++ b/__tests__/components/shared/markdown-doc-editor.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})) + +import { toast } from 'sonner' +import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor' + +beforeEach(() => { + window.localStorage.clear() + vi.clearAllMocks() +}) + +describe('MarkdownDocEditor — rendering + dirty-state', () => { + it('rendert textarea met initialValue', () => { + render( + , + ) + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + expect(textarea.value).toBe('hello') + }) + + it('save-knop is disabled wanneer niet dirty', () => { + render( + , + ) + const saveBtn = screen.getByRole('button', { name: /opslaan/i }) + expect((saveBtn as HTMLButtonElement).disabled).toBe(true) + }) + + it('save-knop is enabled na wijziging', () => { + render( + , + ) + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: 'hello world' } }) + const saveBtn = screen.getByRole('button', { name: /opslaan/i }) + expect((saveBtn as HTMLButtonElement).disabled).toBe(false) + }) +}) + +describe('MarkdownDocEditor — localStorage draft', () => { + it('persisteert draft naar localStorage on change', () => { + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'changed' }, + }) + expect(window.localStorage.getItem('test-draft-1')).toBe('changed') + }) + + it('verwijdert draft als waarde terug op initialValue staat', () => { + window.localStorage.setItem('test-draft-2', 'staleDraft') + render( + , + ) + // Restore from draft → toast.info wordt aangeroepen + // Reset naar initialValue + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } }) + expect(window.localStorage.getItem('test-draft-2')).toBeNull() + }) + + it('restored draft uit localStorage bij mount + toast.info', () => { + window.localStorage.setItem('test-restore', 'restored content') + render( + , + ) + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + expect(textarea.value).toBe('restored content') + expect(toast.info).toHaveBeenCalled() + }) +}) + +describe('MarkdownDocEditor — save flow', () => { + it('Cmd+S triggert onSave', async () => { + const onSave = vi.fn().mockResolvedValue({ success: true }) + const onCancel = vi.fn() + render( + , + ) + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: 'changed' } }) + fireEvent.keyDown(textarea, { key: 's', metaKey: true }) + + await waitFor(() => expect(onSave).toHaveBeenCalledWith('changed')) + }) + + it('Ctrl+S triggert ook onSave', async () => { + const onSave = vi.fn().mockResolvedValue({ success: true }) + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } }) + fireEvent.keyDown(screen.getByRole('textbox'), { key: 's', ctrlKey: true }) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + }) + + it('na success: localStorage clear + onSaved + onCancel + toast.success', async () => { + const onSave = vi.fn().mockResolvedValue({ success: true }) + const onSaved = vi.fn() + const onCancel = vi.fn() + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } }) + fireEvent.click(screen.getByRole('button', { name: /opslaan/i })) + + await waitFor(() => { + expect(onSaved).toHaveBeenCalled() + expect(onCancel).toHaveBeenCalled() + expect(window.localStorage.getItem('test-save-3')).toBeNull() + expect(toast.success).toHaveBeenCalled() + }) + }) + + it('na error: toast.error + submitErrors renderen', async () => { + const onSave = vi.fn().mockResolvedValue({ + error: 'Server-fout', + code: 422, + details: [{ line: 5, message: 'bad yaml' }], + }) + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } }) + fireEvent.click(screen.getByRole('button', { name: /opslaan/i })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Server-fout') + expect(screen.queryByText(/Regel 5/i)).not.toBeNull() + expect(screen.queryByText(/bad yaml/i)).not.toBeNull() + }) + }) +}) + +describe('MarkdownDocEditor — validation', () => { + it('valide-errors blokkeren submit (save-knop disabled)', () => { + const validate = vi.fn().mockReturnValue([{ message: 'yaml fout' }]) + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } }) + const saveBtn = screen.getByRole('button', { name: /opslaan/i }) + expect((saveBtn as HTMLButtonElement).disabled).toBe(true) + }) + + it('valide-errors worden gerendered in error-box', () => { + const validate = vi.fn().mockReturnValue([ + { line: 3, message: 'yaml fout', hint: 'check de indenting' }, + ]) + render( + , + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } }) + expect(screen.queryByText(/YAML-frontmatter fouten/i)).not.toBeNull() + expect(screen.queryByText(/Regel 3/i)).not.toBeNull() + expect(screen.queryByText(/yaml fout/i)).not.toBeNull() + expect(screen.queryByText(/check de indenting/i)).not.toBeNull() + }) + + it('validate wordt niet aangeroepen als waarde nog op initialValue staat', () => { + const validate = vi.fn().mockReturnValue([]) + render( + , + ) + expect(validate).not.toHaveBeenCalled() + }) +}) + +describe('MarkdownDocEditor — cancel', () => { + it('Annuleer-knop roept onCancel', () => { + const onCancel = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /annuleer/i })) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/components/shared/markdown-doc-editor.tsx b/components/shared/markdown-doc-editor.tsx new file mode 100644 index 0000000..83900db --- /dev/null +++ b/components/shared/markdown-doc-editor.tsx @@ -0,0 +1,222 @@ +'use client' + +// Generieke markdown-editor met state, draft-persistence en keyboard shortcut. +// Geëxtraheerd uit components/ideas/idea-md-editor.tsx zodat zowel Ideas +// (grill/plan) als Product Docs dezelfde editor-stack gebruiken (CLAUDE.md +// dialog-discipline: "twee keer kopieren = promote 'm meteen"). +// +// Plan: docs/plans/PBI-96-product-docs.md §D.1. +// +// Patronen die deze component levert: +// - Cmd/Ctrl+S → onSave +// - localStorage-backed draft per `storageKey`, restore bij heropening +// - live validatie via optionele `validate`-callback (blokkeert submit) +// - server-side error-details (uit ActionResult.details) renderen +// - toast-feedback (sonner) +// - dirty-state-indicator in footer +// +// Entity-specifieke logica (welke action, welke validator) wordt door de +// caller geïnjecteerd; de component is volledig generic. + +import { useEffect, useMemo, useState, useTransition } from 'react' +import { Save, X } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { debugProps } from '@/lib/debug' + +export interface MarkdownDocEditorError { + line?: number + message: string + hint?: string +} + +interface ActionSuccess { + success: true +} + +interface ActionFailure { + error: string + code?: number + details?: unknown +} + +export type MarkdownDocEditorActionResult = ActionSuccess | ActionFailure + +interface Props { + /** Unieke key voor localStorage-draft (bv. `idea-md-${id}-${kind}` of `product-doc-${id}`). */ + storageKey: string + /** Originele inhoud — wijzigingen worden ten opzichte hiervan als 'dirty' beschouwd. */ + initialValue: string + /** Optionele live-validator. Returnt errors[] (leeg = OK). Submit wordt geblokkeerd bij errors. */ + validate?: (value: string) => MarkdownDocEditorError[] + /** Server-action wrapper. Returnt {success:true} of {error, code, details}. */ + onSave: (value: string) => Promise + /** Hook om na success extra werk te doen (bv. router.refresh()). Aangeroepen vóór onCancel. */ + onSaved?: () => void + /** Sluit de editor (zowel bij annuleren als na succesvolle save). */ + onCancel: () => void + rows?: number + placeholder?: string + saveLabel?: string + validationErrorsHeader?: string + /** Voor debug-attribuut op de root div. */ + debugId?: string + debugComponentName?: string + debugFile?: string +} + +function readSeed(storageKey: string, initialValue: string): { + value: string + restored: boolean +} { + if (typeof window === 'undefined') { + return { value: initialValue, restored: false } + } + const draft = window.localStorage.getItem(storageKey) + if (draft && draft !== initialValue) return { value: draft, restored: true } + return { value: initialValue, restored: false } +} + +export function MarkdownDocEditor({ + storageKey, + initialValue, + validate, + onSave, + onSaved, + onCancel, + rows = 24, + placeholder, + saveLabel = 'Opslaan', + validationErrorsHeader = 'Validatiefouten', + debugId = 'markdown-doc-editor', + debugComponentName = 'MarkdownDocEditor', + debugFile = 'components/shared/markdown-doc-editor.tsx', +}: Props) { + const [seed] = useState(() => readSeed(storageKey, initialValue)) + const [value, setValue] = useState(seed.value) + const [submitErrors, setSubmitErrors] = useState([]) + const [submitting, startSubmit] = useTransition() + + useEffect(() => { + if (seed.restored) { + toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.') + } + }, [seed.restored]) + + useEffect(() => { + if (typeof window === 'undefined') return + if (value === initialValue) { + window.localStorage.removeItem(storageKey) + } else { + window.localStorage.setItem(storageKey, value) + } + }, [value, initialValue, storageKey]) + + const validationErrors = useMemo(() => { + if (!validate) return [] + if (value === '' || value === initialValue) return [] + return validate(value) + }, [value, initialValue, validate]) + + const errors = submitErrors.length > 0 ? submitErrors : validationErrors + const hasValidationErrors = validationErrors.length > 0 + const dirty = value !== initialValue + + function save() { + if (hasValidationErrors) { + toast.error('Inhoud heeft fouten — fix die eerst.') + return + } + setSubmitErrors([]) + startSubmit(async () => { + const r = await onSave(value) + if ('error' in r) { + toast.error(r.error) + if ('details' in r && Array.isArray(r.details)) { + setSubmitErrors(r.details as MarkdownDocEditorError[]) + } + return + } + toast.success('Opgeslagen') + if (typeof window !== 'undefined') { + window.localStorage.removeItem(storageKey) + } + onSaved?.() + onCancel() + }) + } + + function onKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault() + save() + } + } + + return ( +
+ {errors.length > 0 && ( +
+

+ {validationErrorsHeader} +

+
    + {errors.map((err, i) => ( +
  • + {err.line ? `Regel ${err.line}: ` : ''} + {err.message} + {err.hint && ( +
    Tip: {err.hint}
    + )} +
  • + ))} +
+
+ )} + +