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:
parent
410cd7c123
commit
73cb61d3a2
2 changed files with 586 additions and 1 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
584
docs/plans/PBI-96-product-docs.md
Normal file
584
docs/plans/PBI-96-product-docs.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue