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