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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 11:34:02 +02:00
parent 410cd7c123
commit 73cb61d3a2
2 changed files with 586 additions and 1 deletions

View file

@ -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 |

View file

@ -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/<ts>_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: <result of create>, 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) `<DemoTooltip>`-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 (
<>
<SetCurrentProduct id={id} name={product.name} />
<ProductSubNav productId={id} />
{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 | `<Markdown>` ([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`; `<DemoTooltip>` |
| `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 `<DisabledFolderBanner>` 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<ActionResult>
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/<ts>_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 — `<ProductSubNav>` |
| 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 |
| `<DemoTooltip>`-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 | `<DemoTooltip>`-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 `<DisabledFolderBanner>`, 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.