Merge pull request 'claude/friendly-williamson-02ff6b' (#3) from claude/friendly-williamson-02ff6b into main
Some checks are pending
CI / Lint, Typecheck, Test & Build (push) Waiting to run
CI / Detect deploy-relevant changes (push) Blocked by required conditions
CI / Deploy Preview (PR) (push) Blocked by required conditions
CI / Deploy Production (main) (push) Blocked by required conditions
CI / Deploy Manual (workflow_dispatch) (push) Waiting to run

Reviewed-on: #3
This commit is contained in:
Janpeter Visser 2026-05-16 14:52:02 +02:00
commit 9fc15f279a
42 changed files with 5043 additions and 234 deletions

View file

@ -0,0 +1,524 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSession } = vi.hoisted(() => ({
mockSession: { userId: 'user-1', isDemo: false } as {
userId: string | undefined
isDemo: boolean
},
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockImplementation(async () => mockSession),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: {
cookieName: 'test',
password: 'test-password-32-chars-minimum-len',
},
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn(), update: vi.fn() },
productDoc: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
productDocLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { _resetRateLimit } from '@/lib/rate-limit'
import {
createProductDocAction,
deleteProductDocAction,
listProductDocsAction,
toggleProductDocFolderAction,
updateProductDocAction,
} from '@/actions/product-docs'
const VALID_PRODUCT_ID = 'cmohrysyj0000rd17clnjy4tc'
const VALID_DOC_MD = `---
title: "Deploy stappen"
status: draft
---
# Body
stappen
`
function setSession(s: Partial<typeof mockSession>) {
Object.assign(mockSession, s)
}
beforeEach(() => {
setSession({ userId: 'user-1', isDemo: false })
_resetRateLimit()
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// createProductDocAction
describe('createProductDocAction', () => {
const baseInput = {
product_id: VALID_PRODUCT_ID,
folder: 'runbooks' as const,
slug: 'deploy',
content_md: VALID_DOC_MD,
}
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 422 bij invalide product_id (zod-fail)', async () => {
const r = await createProductDocAction({ ...baseInput, product_id: 'not-a-cuid' })
expect('code' in r && r.code).toBe(422)
})
it('returnt 422 als content_md geen frontmatter heeft (P2-validation)', async () => {
const r = await createProductDocAction({ ...baseInput, content_md: '# alleen body' })
expect('code' in r && r.code).toBe(422)
expect((prisma.productDoc.create as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
})
it('returnt 404 als product niet toegankelijk', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
})
it('returnt 422 als folder is uitgeschakeld', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['ADR'],
})
const r = await createProductDocAction(baseInput)
expect('code' in r && r.code).toBe(422)
expect('error' in r && r.error).toMatch(/staat uit/i)
})
it('schrijft title/status uit frontmatter naar de kolommen (P2-create)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS', 'ADR'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
const tx = {
productDoc: {
create: vi.fn().mockResolvedValue({
id: 'doc-1',
folder: 'RUNBOOKS',
slug: 'deploy',
}),
},
productDocLog: { create: vi.fn().mockResolvedValue({}) },
}
const result = await cb(tx as unknown as typeof prisma)
;(prisma.productDoc.create as ReturnType<typeof vi.fn>).mockImplementation(
tx.productDoc.create,
)
;(prisma.productDocLog.create as ReturnType<typeof vi.fn>).mockImplementation(
tx.productDocLog.create,
)
// Bewaar de tx-mocks zodat de test ze kan inspecteren
;(prisma.productDoc.create as ReturnType<typeof vi.fn> & { calls: unknown[] })
.calls = tx.productDoc.create.mock.calls
return result
},
)
const r = await createProductDocAction({
...baseInput,
content_md: `---\ntitle: "Custom Title"\nstatus: active\n---\n\nbody`,
})
expect('success' in r && r.success).toBe(true)
const txCalls = (prisma.productDoc.create as ReturnType<typeof vi.fn> & {
calls: unknown[]
}).calls
expect(txCalls.length).toBeGreaterThan(0)
const createArg = (txCalls[0] as [{ data: { title: string; status: string } }])[0]
expect(createArg.data.title).toBe('Custom Title')
expect(createArg.data.status).toBe('active')
})
it('overschrijft user-supplied last_updated met today (P2-last_updated)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
let capturedCreateArg: { data: { content_md: string } } | null = null
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
const tx = {
productDoc: {
create: vi
.fn()
.mockImplementation(async (arg: { data: { content_md: string } }) => {
capturedCreateArg = arg
return { id: 'doc-1', folder: 'RUNBOOKS', slug: 'deploy' }
}),
},
productDocLog: { create: vi.fn().mockResolvedValue({}) },
}
return cb(tx as unknown as typeof prisma)
},
)
const stale = `---\ntitle: "X"\nstatus: draft\nlast_updated: 2020-01-01\n---\n\nbody`
const r = await createProductDocAction({ ...baseInput, content_md: stale })
expect('success' in r && r.success).toBe(true)
expect(capturedCreateArg).not.toBeNull()
const savedMd = capturedCreateArg!.data.content_md
expect(savedMd).not.toMatch(/2020-01-01/)
expect(savedMd).toMatch(/last_updated:\s*['"]?\d{4}-\d{2}-\d{2}/)
})
it('returnt 422 bij slug-conflict (P2002)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
code: 'P2002',
})
const r = await createProductDocAction(baseInput)
expect('code' in r && r.code).toBe(422)
expect('error' in r && r.error).toMatch(/bestaat al/i)
})
})
// ---------------------------------------------------------------------------
// updateProductDocAction
describe('updateProductDocAction', () => {
const NEW_MD = `---
title: "Updated"
status: active
---
new body
`
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await updateProductDocAction('doc-1', NEW_MD)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await updateProductDocAction('doc-1', NEW_MD)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 404 als doc niet toegankelijk', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await updateProductDocAction('doc-1', NEW_MD)
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
})
it('returnt 422 bij broken frontmatter (zonder DB-write)', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'doc-1',
product_id: 'p',
folder: 'RUNBOOKS',
slug: 'deploy',
status: 'draft',
})
const r = await updateProductDocAction('doc-1', '# alleen body')
expect('code' in r && r.code).toBe(422)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('sync titel/status + logt UPDATED met prev/new-status', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'doc-1',
product_id: 'p',
folder: 'RUNBOOKS',
slug: 'deploy',
status: 'draft',
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await updateProductDocAction('doc-1', NEW_MD)
expect('success' in r && r.success).toBe(true)
expect(prisma.productDoc.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'doc-1' },
data: expect.objectContaining({
title: 'Updated',
status: 'active',
}),
}),
)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'UPDATED',
metadata: expect.objectContaining({
prev_status: 'draft',
new_status: 'active',
}),
}),
}),
)
})
})
// ---------------------------------------------------------------------------
// deleteProductDocAction — P1-review-fix coverage
describe('deleteProductDocAction', () => {
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await deleteProductDocAction('doc-1')
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await deleteProductDocAction('doc-1')
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 404 als doc niet toegankelijk', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await deleteProductDocAction('doc-1')
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
})
it('P1: log heeft doc_id:null + metadata met folder/slug/title (geen FK-race)', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'doc-1',
product_id: 'product-1',
folder: 'RUNBOOKS',
slug: 'deploy',
title: 'Deploy stappen',
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await deleteProductDocAction('doc-1')
expect('success' in r && r.success).toBe(true)
// De $transaction krijgt een array met [log, delete] in die volgorde.
const txArg = (prisma.$transaction as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(Array.isArray(txArg)).toBe(true)
// De productDocLog.create call moet doc_id:null hebben + DELETED + metadata
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
product_id: 'product-1',
doc_id: null, // P1-fix
type: 'DELETED',
metadata: expect.objectContaining({
folder: 'runbooks',
slug: 'deploy',
title: 'Deploy stappen',
}),
}),
}),
)
// En de delete moet aangeroepen zijn op het juiste id
expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } })
})
})
// ---------------------------------------------------------------------------
// toggleProductDocFolderAction — owner-only check
describe('toggleProductDocFolderAction', () => {
const baseInput = {
product_id: VALID_PRODUCT_ID,
folder: 'api' as const,
enabled: false,
}
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 404 als de user geen owner is (ook niet als ProductMember)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
// Check dat de scope owner-only is: where bevat user_id (geen OR met members)
const call = (prisma.product.findFirst as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.user_id).toBe('user-1')
})
it('idempotent: target-staat == huidige staat → success zonder DB-write', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
// enabled:false maar API zit al uit (niet in array) → no-op
const r = await toggleProductDocFolderAction({ ...baseInput, folder: 'manual', enabled: false })
expect('success' in r && r.success).toBe(true)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('disable folder: update + FOLDER_DISABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: false,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.product.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: VALID_PRODUCT_ID },
data: { enabled_doc_folders: ['ADR'] },
}),
)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'FOLDER_DISABLED',
doc_id: null,
metadata: { folder: 'api' },
}),
}),
)
})
it('enable folder: update + FOLDER_ENABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: true,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'FOLDER_ENABLED' }),
}),
)
})
})
// ---------------------------------------------------------------------------
// listProductDocsAction — read-only; demo MAG lezen
describe('listProductDocsAction', () => {
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('demo MAG lezen (geen 403)', async () => {
setSession({ isDemo: true })
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'other',
enabled_doc_folders: [],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect('success' in r && r.success).toBe(true)
})
it('returnt 404 als product niet toegankelijk', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
})
it('returnt gemapte items zonder content_md', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
id: 'd1',
folder: 'RUNBOOKS',
slug: 'deploy',
title: 'Deploy',
status: 'active',
updated_at: new Date('2026-05-16'),
},
])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID, folder: 'runbooks' })
expect('success' in r && r.success).toBe(true)
if ('data' in r && r.data) {
expect(r.data).toHaveLength(1)
expect(r.data[0]).toMatchObject({
id: 'd1',
folder: 'runbooks', // lowercase mapping
slug: 'deploy',
title: 'Deploy',
status: 'active',
})
}
// Check dat folder-filter in de query zit
const call = (prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.folder).toBe('RUNBOOKS')
// En dat content_md niet geselecteerd is
expect(call.select.content_md).toBeUndefined()
})
})

View file

@ -0,0 +1,263 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
import { toast } from 'sonner'
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
beforeEach(() => {
window.localStorage.clear()
vi.clearAllMocks()
})
describe('MarkdownDocEditor — rendering + dirty-state', () => {
it('rendert textarea met initialValue', () => {
render(
<MarkdownDocEditor
storageKey="test-1"
initialValue="hello"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.value).toBe('hello')
})
it('save-knop is disabled wanneer niet dirty', () => {
render(
<MarkdownDocEditor
storageKey="test-2"
initialValue="hello"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
})
it('save-knop is enabled na wijziging', () => {
render(
<MarkdownDocEditor
storageKey="test-3"
initialValue="hello"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'hello world' } })
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
expect((saveBtn as HTMLButtonElement).disabled).toBe(false)
})
})
describe('MarkdownDocEditor — localStorage draft', () => {
it('persisteert draft naar localStorage on change', () => {
render(
<MarkdownDocEditor
storageKey="test-draft-1"
initialValue="hello"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'changed' },
})
expect(window.localStorage.getItem('test-draft-1')).toBe('changed')
})
it('verwijdert draft als waarde terug op initialValue staat', () => {
window.localStorage.setItem('test-draft-2', 'staleDraft')
render(
<MarkdownDocEditor
storageKey="test-draft-2"
initialValue="hello"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
// Restore from draft → toast.info wordt aangeroepen
// Reset naar initialValue
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } })
expect(window.localStorage.getItem('test-draft-2')).toBeNull()
})
it('restored draft uit localStorage bij mount + toast.info', () => {
window.localStorage.setItem('test-restore', 'restored content')
render(
<MarkdownDocEditor
storageKey="test-restore"
initialValue="original"
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
expect(textarea.value).toBe('restored content')
expect(toast.info).toHaveBeenCalled()
})
})
describe('MarkdownDocEditor — save flow', () => {
it('Cmd+S triggert onSave', async () => {
const onSave = vi.fn().mockResolvedValue({ success: true })
const onCancel = vi.fn()
render(
<MarkdownDocEditor
storageKey="test-save-1"
initialValue="hello"
onSave={onSave}
onCancel={onCancel}
/>,
)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'changed' } })
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
await waitFor(() => expect(onSave).toHaveBeenCalledWith('changed'))
})
it('Ctrl+S triggert ook onSave', async () => {
const onSave = vi.fn().mockResolvedValue({ success: true })
render(
<MarkdownDocEditor
storageKey="test-save-2"
initialValue="hello"
onSave={onSave}
onCancel={vi.fn()}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
fireEvent.keyDown(screen.getByRole('textbox'), { key: 's', ctrlKey: true })
await waitFor(() => expect(onSave).toHaveBeenCalled())
})
it('na success: localStorage clear + onSaved + onCancel + toast.success', async () => {
const onSave = vi.fn().mockResolvedValue({ success: true })
const onSaved = vi.fn()
const onCancel = vi.fn()
render(
<MarkdownDocEditor
storageKey="test-save-3"
initialValue="hello"
onSave={onSave}
onSaved={onSaved}
onCancel={onCancel}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
await waitFor(() => {
expect(onSaved).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalled()
expect(window.localStorage.getItem('test-save-3')).toBeNull()
expect(toast.success).toHaveBeenCalled()
})
})
it('na error: toast.error + submitErrors renderen', async () => {
const onSave = vi.fn().mockResolvedValue({
error: 'Server-fout',
code: 422,
details: [{ line: 5, message: 'bad yaml' }],
})
render(
<MarkdownDocEditor
storageKey="test-error"
initialValue="hello"
onSave={onSave}
onCancel={vi.fn()}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Server-fout')
expect(screen.queryByText(/Regel 5/i)).not.toBeNull()
expect(screen.queryByText(/bad yaml/i)).not.toBeNull()
})
})
})
describe('MarkdownDocEditor — validation', () => {
it('valide-errors blokkeren submit (save-knop disabled)', () => {
const validate = vi.fn().mockReturnValue([{ message: 'yaml fout' }])
render(
<MarkdownDocEditor
storageKey="test-val-1"
initialValue="hello"
validate={validate}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
})
it('valide-errors worden gerendered in error-box', () => {
const validate = vi.fn().mockReturnValue([
{ line: 3, message: 'yaml fout', hint: 'check de indenting' },
])
render(
<MarkdownDocEditor
storageKey="test-val-2"
initialValue="hello"
validate={validate}
onSave={vi.fn()}
onCancel={vi.fn()}
validationErrorsHeader="YAML-frontmatter fouten"
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
expect(screen.queryByText(/YAML-frontmatter fouten/i)).not.toBeNull()
expect(screen.queryByText(/Regel 3/i)).not.toBeNull()
expect(screen.queryByText(/yaml fout/i)).not.toBeNull()
expect(screen.queryByText(/check de indenting/i)).not.toBeNull()
})
it('validate wordt niet aangeroepen als waarde nog op initialValue staat', () => {
const validate = vi.fn().mockReturnValue([])
render(
<MarkdownDocEditor
storageKey="test-val-3"
initialValue="hello"
validate={validate}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(validate).not.toHaveBeenCalled()
})
})
describe('MarkdownDocEditor — cancel', () => {
it('Annuleer-knop roept onCancel', () => {
const onCancel = vi.fn()
render(
<MarkdownDocEditor
storageKey="test-cancel"
initialValue="hello"
onSave={vi.fn()}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /annuleer/i }))
expect(onCancel).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import {
setProductDocFrontmatterFields,
todayIsoDate,
} from '@/lib/product-doc-frontmatter'
const baseMd = `---
title: "Deploy"
status: draft
audience: maintainer
last_updated: 2020-01-01
---
# Body
inhoud
`
describe('setProductDocFrontmatterFields — P2-coverage', () => {
it('vervangt bestaand last_updated', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('voegt last_updated toe als afwezig', () => {
const md = `---
title: "Deploy"
status: draft
---
body
`
const out = setProductDocFrontmatterFields(md, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('behoudt overige frontmatter-velden', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.title).toBe('Deploy')
expect(parsed.frontmatter.status).toBe('draft')
expect(parsed.frontmatter.audience).toBe('maintainer')
})
it('behoudt body-inhoud onveranderd', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
expect(out).toContain('# Body')
expect(out).toContain('inhoud')
})
it('kan meerdere velden tegelijk patchen', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
status: 'active',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.status).toBe('active')
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('throwed bij ontbrekende frontmatter', () => {
expect(() =>
setProductDocFrontmatterFields('# alleen body', { last_updated: 'x' }),
).toThrow(/yaml-frontmatter/i)
})
it('throwed bij broken yaml', () => {
const broken = `---
title: "open quote
status: draft
---
body`
expect(() =>
setProductDocFrontmatterFields(broken, { last_updated: 'x' }),
).toThrow()
})
})
describe('todayIsoDate', () => {
it('returnt yyyy-mm-dd format', () => {
expect(todayIsoDate()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('respecteert de meegegeven Date', () => {
expect(todayIsoDate(new Date('2026-05-16T12:34:56Z'))).toBe('2026-05-16')
})
})

View file

@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest'
import { parseProductDocMd } from '@/lib/product-doc-parser'
const minimalValid = `---
title: "Deploy stappen"
status: draft
---
# Body
stappen hier
`
describe('parseProductDocMd — succes', () => {
it('parseert minimaal valide doc', () => {
const r = parseProductDocMd(minimalValid)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.title).toBe('Deploy stappen')
expect(r.frontmatter.status).toBe('draft')
expect(r.body.startsWith('# Body')).toBe(true)
})
it('accepteert optionele velden (audience, applies_to, last_updated)', () => {
const md = `---
title: "Doc"
status: active
audience: [maintainer, contributor]
applies_to: PBI-96
last_updated: 2026-05-16
---
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.audience).toEqual(['maintainer', 'contributor'])
expect(r.frontmatter.applies_to).toBe('PBI-96')
expect(r.frontmatter.last_updated).toBe('2026-05-16')
})
it('accepteert audience als single string', () => {
const md = `---
title: "Doc"
status: draft
audience: maintainer
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.audience).toBe('maintainer')
})
it('trimt leading whitespace van body', () => {
const md = `---
title: "x"
status: draft
---
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.body.startsWith('body')).toBe(true)
})
})
describe('parseProductDocMd — fouten', () => {
it('weigert doc zonder frontmatter (regel 1 error)', () => {
const r = parseProductDocMd('# alleen body')
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors[0].line).toBe(1)
expect(r.errors[0].message).toMatch(/yaml-frontmatter/i)
})
it('weigert doc zonder afsluitende `---`', () => {
const md = `---
title: "x"
status: draft
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
})
it('weigert frontmatter zonder title', () => {
const md = `---
status: draft
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors.some((e) => e.message.includes('title'))).toBe(true)
})
it('weigert frontmatter zonder status', () => {
const md = `---
title: "x"
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors.some((e) => e.message.includes('status'))).toBe(true)
})
it('weigert status buiten enum-set', () => {
const md = `---
title: "x"
status: wip
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
})
it('geeft line-info bij bad yaml', () => {
const md = `---
title: "x
status: draft
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors[0].line).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
nextAdrPrefix,
parseAdrNumber,
slugify,
suggestAdrSlug,
suggestSlug,
} from '@/lib/product-doc-slug'
describe('slugify', () => {
it('maakt simpele titels lowercase met koppeltekens', () => {
expect(slugify('Deploy stappen')).toBe('deploy-stappen')
expect(slugify('Hello, World!')).toBe('hello-world')
})
it('stript diakritieken', () => {
expect(slugify('Café écrasé')).toBe('cafe-ecrase')
expect(slugify('Ångström')).toBe('angstrom')
})
it('verwijdert leading/trailing dashes', () => {
expect(slugify(' --- hello --- ')).toBe('hello')
})
it('capt lengte op 80 tekens', () => {
const long = 'a'.repeat(100)
expect(slugify(long).length).toBe(80)
})
it('geeft lege string voor lege/whitespace-only input', () => {
expect(slugify('')).toBe('')
expect(slugify(' ')).toBe('')
expect(slugify('!@#$%')).toBe('')
})
})
describe('suggestSlug', () => {
it('returnt base-slug zonder collision', () => {
expect(suggestSlug('Deploy', [])).toBe('deploy')
})
it('voegt -2 suffix toe bij eerste collision', () => {
expect(suggestSlug('Deploy', ['deploy'])).toBe('deploy-2')
})
it('telt door bij meerdere collisions', () => {
expect(suggestSlug('Deploy', ['deploy', 'deploy-2', 'deploy-3'])).toBe('deploy-4')
})
it('geeft lege string voor lege titel', () => {
expect(suggestSlug('', ['x'])).toBe('')
})
it('respecteert max-len bij toevoegen suffix', () => {
const long80 = 'a'.repeat(80)
const result = suggestSlug(long80, [long80])
expect(result.length).toBeLessThanOrEqual(80)
expect(result.endsWith('-2')).toBe(true)
})
})
describe('nextAdrPrefix', () => {
it('geeft 0001 als er nog geen ADRs zijn', () => {
expect(nextAdrPrefix(null)).toBe('0001')
})
it('telt door op currentMax', () => {
expect(nextAdrPrefix(0)).toBe('0001')
expect(nextAdrPrefix(41)).toBe('0042')
expect(nextAdrPrefix(999)).toBe('1000')
})
it('pad altijd tot minimaal 4 cijfers', () => {
expect(nextAdrPrefix(null)).toMatch(/^\d{4}$/)
expect(nextAdrPrefix(8)).toBe('0009')
})
})
describe('parseAdrNumber', () => {
it('parseert geldig NNNN-prefix', () => {
expect(parseAdrNumber('0001-context')).toBe(1)
expect(parseAdrNumber('0042-some-slug')).toBe(42)
})
it('returns null voor slugs zonder geldig prefix', () => {
expect(parseAdrNumber('context')).toBeNull()
expect(parseAdrNumber('abc-context')).toBeNull()
expect(parseAdrNumber('1-context')).toBeNull()
expect(parseAdrNumber('12345-context')).toBeNull() // 5 cijfers
})
})
describe('suggestAdrSlug', () => {
it('bouwt NNNN-{slug} format', () => {
expect(suggestAdrSlug('Use base-ui not Radix', null)).toBe('0001-use-base-ui-not-radix')
expect(suggestAdrSlug('Use base-ui not Radix', 41)).toBe('0042-use-base-ui-not-radix')
})
it('geeft alleen prefix bij lege titel', () => {
expect(suggestAdrSlug('', null)).toBe('0001')
expect(suggestAdrSlug(' ', 5)).toBe('0006')
})
it('respecteert max-len van 80 tekens', () => {
const longTitle = 'x'.repeat(100)
const slug = suggestAdrSlug(longTitle, null)
expect(slug.length).toBeLessThanOrEqual(80)
expect(slug.startsWith('0001-')).toBe(true)
})
})

View file

@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest'
import {
PRODUCT_DOC_FOLDERS,
PRODUCT_DOC_STATUSES,
productDocCreateSchema,
productDocFolderToggleSchema,
productDocFrontmatterSchema,
productDocSlugSchema,
productDocUpdateSchema,
} from '@/lib/schemas/product-doc'
const validProductId = 'cmohrysyj0000rd17clnjy4tc'
describe('productDocSlugSchema', () => {
it('accepteert geldige slugs', () => {
expect(productDocSlugSchema.safeParse('deploy').success).toBe(true)
expect(productDocSlugSchema.safeParse('0001-context-decision').success).toBe(true)
expect(productDocSlugSchema.safeParse('a').success).toBe(true)
})
it('weigert hoofdletters, spaties en speciale tekens', () => {
expect(productDocSlugSchema.safeParse('Deploy').success).toBe(false)
expect(productDocSlugSchema.safeParse('deploy stappen').success).toBe(false)
expect(productDocSlugSchema.safeParse('deploy/stappen').success).toBe(false)
})
it('weigert slug die met streepje begint', () => {
expect(productDocSlugSchema.safeParse('-deploy').success).toBe(false)
})
it('weigert slug > 80 tekens', () => {
expect(productDocSlugSchema.safeParse('a'.repeat(81)).success).toBe(false)
expect(productDocSlugSchema.safeParse('a'.repeat(80)).success).toBe(true)
})
})
describe('productDocFrontmatterSchema', () => {
it('accepteert minimaal valide frontmatter', () => {
const r = productDocFrontmatterSchema.safeParse({ title: 'Deploy', status: 'draft' })
expect(r.success).toBe(true)
})
it('weigert ontbrekende title of status', () => {
expect(
productDocFrontmatterSchema.safeParse({ status: 'draft' }).success,
).toBe(false)
expect(
productDocFrontmatterSchema.safeParse({ title: 'Deploy' }).success,
).toBe(false)
})
it('weigert status die niet in de enum zit', () => {
expect(
productDocFrontmatterSchema.safeParse({ title: 'D', status: 'wip' }).success,
).toBe(false)
})
it('accepteert audience als string of array', () => {
expect(
productDocFrontmatterSchema.safeParse({
title: 'D',
status: 'draft',
audience: 'maintainer',
}).success,
).toBe(true)
expect(
productDocFrontmatterSchema.safeParse({
title: 'D',
status: 'draft',
audience: ['maintainer', 'contributor'],
}).success,
).toBe(true)
})
it('weigert oversized title', () => {
expect(
productDocFrontmatterSchema.safeParse({
title: 'x'.repeat(201),
status: 'draft',
}).success,
).toBe(false)
})
})
describe('productDocCreateSchema', () => {
const base = {
product_id: validProductId,
folder: 'runbooks' as const,
slug: 'deploy',
content_md: '---\ntitle: "Deploy"\nstatus: draft\n---\n\nbody',
}
it('accepteert geldige input', () => {
expect(productDocCreateSchema.safeParse(base).success).toBe(true)
})
it('weigert ongeldige folder', () => {
expect(
productDocCreateSchema.safeParse({ ...base, folder: 'wiki' }).success,
).toBe(false)
})
it('weigert ongeldige product_id (geen cuid)', () => {
expect(
productDocCreateSchema.safeParse({ ...base, product_id: 'not-a-cuid' }).success,
).toBe(false)
})
it('weigert leeg of te lang content_md', () => {
expect(productDocCreateSchema.safeParse({ ...base, content_md: '' }).success).toBe(false)
expect(
productDocCreateSchema.safeParse({ ...base, content_md: 'x'.repeat(100_001) }).success,
).toBe(false)
})
})
describe('productDocUpdateSchema', () => {
it('accepteert valide content_md', () => {
expect(
productDocUpdateSchema.safeParse({ content_md: '---\ntitle: "x"\nstatus: draft\n---\n\nbody' })
.success,
).toBe(true)
})
it('weigert leeg content_md', () => {
expect(productDocUpdateSchema.safeParse({ content_md: '' }).success).toBe(false)
})
})
describe('productDocFolderToggleSchema', () => {
it('accepteert valide toggle-input', () => {
expect(
productDocFolderToggleSchema.safeParse({
product_id: validProductId,
folder: 'api',
enabled: false,
}).success,
).toBe(true)
})
it('weigert ontbrekende enabled-vlag', () => {
expect(
productDocFolderToggleSchema.safeParse({
product_id: validProductId,
folder: 'api',
}).success,
).toBe(false)
})
})
describe('PRODUCT_DOC_FOLDERS + STATUSES', () => {
it('bevat exact 8 folders', () => {
expect(PRODUCT_DOC_FOLDERS).toHaveLength(8)
})
it('bevat exact 4 statussen', () => {
expect(PRODUCT_DOC_STATUSES).toHaveLength(4)
})
})

427
actions/product-docs.ts Normal file
View file

@ -0,0 +1,427 @@
'use server'
// Server-actions voor de ProductDoc-entity (PBI-96). Volgt
// docs/patterns/server-action.md: auth → demo-guard → rate-limit → zod →
// scope-check → frontmatter-parse → tx-write+log → revalidatePath. Pattern
// gespiegeld uit actions/ideas.ts (markdown-edit flow, regels 232-313).
//
// Belangrijke review-fixes (zie
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md):
// - P1 (delete-audit): log met doc_id:null vóór delete in $transaction
// — geen FK-race, geen interactieve tx nodig (in T-1063).
// - P2 (frontmatter-sync): title/status uit parsed.frontmatter worden
// naar de gerepliceerde kolommen geschreven; last_updated wordt
// server-side genormaliseerd via setProductDocFrontmatterFields.
//
// Plan: docs/plans/PBI-96-product-docs.md §B.2.
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { enforceUserRateLimit } from '@/lib/rate-limit'
import { productAccessFilter } from '@/lib/product-access'
import {
folderApiToDbOrThrow,
loadAccessibleProduct,
} from '@/lib/product-docs-server'
import { productDocFolderToApi } from '@/lib/product-doc-folder'
import {
productDocCreateSchema,
productDocFolderToggleSchema,
productDocUpdateSchema,
type ProductDocCreateInput,
type ProductDocFolderToggleInput,
} from '@/lib/schemas/product-doc'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import {
setProductDocFrontmatterFields,
todayIsoDate,
} from '@/lib/product-doc-frontmatter'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
type ActionResult<T = void> =
| { success: true; data?: T }
| { error: string; code?: number; details?: unknown }
function isPrismaUniqueConstraintError(err: unknown): boolean {
return (
err != null &&
typeof err === 'object' &&
'code' in err &&
(err as { code: string }).code === 'P2002'
)
}
// ---------------------------------------------------------------------------
// CREATE
export async function createProductDocAction(
input: ProductDocCreateInput,
): Promise<ActionResult<{ id: string; folder: string; slug: string }>> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo)
return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('create-product-doc', session.userId)
if (limited) return limited
const parsedInput = productDocCreateSchema.safeParse(input)
if (!parsedInput.success) {
return {
error: 'Validatie mislukt',
code: 422,
details: parsedInput.error.flatten().fieldErrors,
}
}
// P2: parse + valideer frontmatter (422 met line-info bij fout)
const parsedMd = parseProductDocMd(parsedInput.data.content_md)
if (!parsedMd.ok) {
return {
error: 'content_md is niet parseerbaar',
code: 422,
details: parsedMd.errors,
}
}
const userId = session.userId
const product = await loadAccessibleProduct(
parsedInput.data.product_id,
userId,
)
if (!product) return { error: 'Product niet gevonden', code: 404 }
const folderDb = folderApiToDbOrThrow(parsedInput.data.folder)
if (!product.enabled_doc_folders.includes(folderDb)) {
return {
error: `Folder '${parsedInput.data.folder}' staat uit voor dit product`,
code: 422,
}
}
// P2: normaliseer last_updated server-side in het opgeslagen content_md
const normalized = setProductDocFrontmatterFields(
parsedInput.data.content_md,
{ last_updated: todayIsoDate() },
)
try {
const created = await prisma.$transaction(async (tx) => {
const doc = await tx.productDoc.create({
data: {
product_id: product.id,
folder: folderDb,
slug: parsedInput.data.slug,
title: parsedMd.frontmatter.title, // P2: sync uit frontmatter
status: parsedMd.frontmatter.status, // P2: sync uit frontmatter
content_md: normalized,
created_by: userId,
},
select: { id: true, folder: true, slug: true },
})
await tx.productDocLog.create({
data: {
product_id: product.id,
doc_id: doc.id,
actor_user_id: userId,
type: 'CREATED',
metadata: {
folder: productDocFolderToApi(folderDb),
slug: parsedInput.data.slug,
title: parsedMd.frontmatter.title,
length: normalized.length,
},
},
})
return doc
})
const folderApi = productDocFolderToApi(created.folder)
revalidatePath(`/products/${product.id}/docs`)
revalidatePath(`/products/${product.id}/docs/${folderApi}`)
return {
success: true,
data: { id: created.id, folder: folderApi, slug: created.slug },
}
} catch (err) {
if (isPrismaUniqueConstraintError(err)) {
return {
error: `Slug '${parsedInput.data.slug}' bestaat al in folder '${parsedInput.data.folder}'`,
code: 422,
}
}
throw err
}
}
// ---------------------------------------------------------------------------
// UPDATE
export async function updateProductDocAction(
id: string,
contentMd: string,
): Promise<ActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo)
return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('edit-product-doc', session.userId)
if (limited) return limited
const parsedInput = productDocUpdateSchema.safeParse({ content_md: contentMd })
if (!parsedInput.success) {
return {
error: 'Validatie mislukt',
code: 422,
details: parsedInput.error.flatten().fieldErrors,
}
}
const userId = session.userId
const existing = await prisma.productDoc.findFirst({
where: { id, product: productAccessFilter(userId) },
select: {
id: true,
product_id: true,
folder: true,
slug: true,
status: true,
},
})
if (!existing) return { error: 'Doc niet gevonden', code: 404 }
const parsedMd = parseProductDocMd(parsedInput.data.content_md)
if (!parsedMd.ok) {
return {
error: 'content_md is niet parseerbaar',
code: 422,
details: parsedMd.errors,
}
}
// P2: normaliseer last_updated server-side
const normalized = setProductDocFrontmatterFields(
parsedInput.data.content_md,
{ last_updated: todayIsoDate() },
)
await prisma.$transaction([
prisma.productDoc.update({
where: { id },
data: {
title: parsedMd.frontmatter.title, // P2: sync uit frontmatter
status: parsedMd.frontmatter.status, // P2: sync uit frontmatter
content_md: normalized,
},
}),
prisma.productDocLog.create({
data: {
product_id: existing.product_id,
doc_id: id,
actor_user_id: userId,
type: 'UPDATED',
metadata: {
length: normalized.length,
prev_status: existing.status,
new_status: parsedMd.frontmatter.status,
},
},
}),
])
const folderApi = productDocFolderToApi(existing.folder)
revalidatePath(`/products/${existing.product_id}/docs`)
revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`)
revalidatePath(
`/products/${existing.product_id}/docs/${folderApi}/${existing.slug}`,
)
return { success: true }
}
// ---------------------------------------------------------------------------
// DELETE — verwerkt P1-review-fix (delete-audit FK-race)
//
// Probleem zoals omschreven in
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md (P1):
// als de log na de delete met `doc_id:<oldId>` wordt aangemaakt, faalt de
// foreign key. Met SetNull-cascade zou de FK 'genezen', maar dat vereist
// een interactieve transaction met juiste volgorde.
//
// Fix: schrijf log met `doc_id: null` VANAF HET BEGIN. Geen FK-race, geen
// interactieve tx nodig. Metadata bewaart `folder/slug/title` voor
// traceability — de relatie is wel verloren maar de informatie niet.
export async function deleteProductDocAction(id: string): Promise<ActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo)
return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const userId = session.userId
const existing = await prisma.productDoc.findFirst({
where: { id, product: productAccessFilter(userId) },
select: {
id: true,
product_id: true,
folder: true,
slug: true,
title: true,
},
})
if (!existing) return { error: 'Doc niet gevonden', code: 404 }
await prisma.$transaction([
prisma.productDocLog.create({
data: {
product_id: existing.product_id,
doc_id: null, // P1-fix: null vanaf het begin
actor_user_id: userId,
type: 'DELETED',
metadata: {
folder: productDocFolderToApi(existing.folder),
slug: existing.slug,
title: existing.title,
},
},
}),
prisma.productDoc.delete({ where: { id } }),
])
const folderApi = productDocFolderToApi(existing.folder)
revalidatePath(`/products/${existing.product_id}/docs`)
revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`)
return { success: true }
}
// ---------------------------------------------------------------------------
// FOLDER TOGGLE — owner-only (NIET productAccessFilter, folder-config is
// een product-setting, niet een doc-mutation). ProductMember kan dus geen
// folders aan/uit zetten.
//
// Idempotent: als de target-staat al de huidige staat is, doet de action
// niets en returnt success — geen log-rij, geen revalidate.
export async function toggleProductDocFolderAction(
input: ProductDocFolderToggleInput,
): Promise<ActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo)
return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = productDocFolderToggleSchema.safeParse(input)
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
details: parsed.error.flatten().fieldErrors,
}
}
const userId = session.userId
// Owner-only — NIET productAccessFilter
const product = await prisma.product.findFirst({
where: { id: parsed.data.product_id, user_id: userId },
select: { id: true, enabled_doc_folders: true },
})
if (!product) return { error: 'Product niet gevonden', code: 404 }
const folderDb = folderApiToDbOrThrow(parsed.data.folder)
const isEnabledNow = product.enabled_doc_folders.includes(folderDb)
if (parsed.data.enabled === isEnabledNow) {
return { success: true } // idempotent
}
const next = parsed.data.enabled
? Array.from(new Set([...product.enabled_doc_folders, folderDb]))
: product.enabled_doc_folders.filter((f) => f !== folderDb)
await prisma.$transaction([
prisma.product.update({
where: { id: product.id },
data: { enabled_doc_folders: next },
}),
prisma.productDocLog.create({
data: {
product_id: product.id,
doc_id: null,
actor_user_id: userId,
type: parsed.data.enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED',
metadata: { folder: parsed.data.folder },
},
}),
])
revalidatePath(`/products/${product.id}/docs`)
revalidatePath(`/products/${product.id}/docs/settings`)
return { success: true }
}
// ---------------------------------------------------------------------------
// LIST — read-only. Demo MAG lezen (zie plan §B.4). Geen rate-limit.
export interface ProductDocListItem {
id: string
folder: string
slug: string
title: string
status: string
updated_at: Date
}
export async function listProductDocsAction(input: {
product_id: string
folder?: string
}): Promise<ActionResult<ProductDocListItem[]>> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
const userId = session.userId
const product = await loadAccessibleProduct(input.product_id, userId)
if (!product) return { error: 'Product niet gevonden', code: 404 }
const folderDb = input.folder ? folderApiToDbOrThrow(input.folder) : undefined
const docs = await prisma.productDoc.findMany({
where: {
product_id: product.id,
...(folderDb ? { folder: folderDb } : {}),
},
select: {
id: true,
folder: true,
slug: true,
title: true,
status: true,
updated_at: true,
},
orderBy: [{ folder: 'asc' }, { slug: 'asc' }],
})
return {
success: true,
data: docs.map((d) => ({
id: d.id,
folder: productDocFolderToApi(d.folder),
slug: d.slug,
title: d.title,
status: d.status,
updated_at: d.updated_at,
})),
}
}

View file

@ -0,0 +1,158 @@
// Product Doc detail-page (PBI-96 / T-1069). Server-component.
//
// Loadt + scope-checked de doc, parseert frontmatter, en switcht tussen
// viewer en editor via `?edit=1`. Edit-knop verborgen bij disabled folder
// (zie plan §C.4 + T-1071). Delete-knop blijft altijd zichtbaar (voor
// cleanup van docs in disabled folder); wel DemoTooltip-wrapped.
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Pencil } from 'lucide-react'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { productDocFolderFromApi, productDocFolderToApi } from '@/lib/product-doc-folder'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import { Button } from '@/components/ui/button'
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
import { ProductDocViewer } from '@/components/product-docs/product-doc-viewer'
import { ProductDocEditor } from '@/components/product-docs/product-doc-editor'
import { DeleteProductDocButton } from '@/components/product-docs/delete-product-doc-button'
import { DisabledFolderBanner } from '@/components/product-docs/disabled-folder-banner'
interface Props {
params: Promise<{ id: string; folder: string; slug: string }>
searchParams: Promise<{ edit?: string }>
}
export default async function ProductDocDetailPage({
params,
searchParams,
}: Props) {
const { id, folder: folderApiParam, slug } = await params
const { edit } = await searchParams
const session = await getSession()
if (!session.userId) redirect('/login')
const folderDb = productDocFolderFromApi(folderApiParam)
if (!folderDb) notFound()
const folderApi = productDocFolderToApi(folderDb)
const docsUrl = `/products/${id}/docs/${folderApi}`
const docUrl = `${docsUrl}/${slug}`
// Scope: doc moet onder een toegankelijk product hangen
const doc = await prisma.productDoc.findFirst({
where: {
product_id: id,
folder: folderDb,
slug,
product: productAccessFilter(session.userId),
},
select: {
id: true,
content_md: true,
status: true,
updated_at: true,
product: { select: { enabled_doc_folders: true } },
},
})
if (!doc) notFound()
const isFolderEnabled = doc.product.enabled_doc_folders.includes(folderDb)
const isDemo = session.isDemo ?? false
const isEditMode = edit === '1'
const parsed = parseProductDocMd(doc.content_md)
const label = FOLDER_LABELS[folderDb]
return (
<div className="p-6 max-w-4xl mx-auto space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
href={`/products/${id}/docs`}
className="hover:text-foreground"
>
Documentatie
</Link>
<span>/</span>
<Link href={docsUrl} className="hover:text-foreground">
{label.title}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{slug}</span>
</div>
<div className="flex items-center justify-between gap-3">
<Link
href={docsUrl}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
Terug naar {label.title}
</Link>
{!isEditMode && (
<div className="flex items-center gap-2">
{isFolderEnabled && !isDemo && (
<Button
variant="outline"
size="sm"
render={<Link href={`${docUrl}?edit=1`} />}
>
<Pencil className="size-3.5 mr-1" />
Bewerken
</Button>
)}
<DeleteProductDocButton
docId={doc.id}
docTitle={parsed.ok ? parsed.frontmatter.title : slug}
redirectHref={docsUrl}
isDemo={isDemo}
/>
</div>
)}
</div>
{!isFolderEnabled && <DisabledFolderBanner productId={id} />}
{isEditMode ? (
<ProductDocEditor
docId={doc.id}
initialValue={doc.content_md}
cancelHref={docUrl}
/>
) : parsed.ok ? (
<ProductDocViewer
title={parsed.frontmatter.title}
status={parsed.frontmatter.status}
body={parsed.body}
audience={parsed.frontmatter.audience}
applies_to={parsed.frontmatter.applies_to}
lastUpdated={parsed.frontmatter.last_updated}
/>
) : (
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 text-sm">
<p className="font-medium text-status-blocked mb-2">
Frontmatter is niet parseerbaar
</p>
<ul className="text-xs space-y-1 text-status-blocked">
{parsed.errors.map((err, i) => (
<li key={i}>
{err.line ? `Regel ${err.line}: ` : ''}
{err.message}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground mt-3">
Klik &lsquo;Bewerken&rsquo; om de fout te herstellen, of bekijk
de raw inhoud hieronder.
</p>
<pre className="mt-3 rounded bg-surface-container p-3 overflow-x-auto text-xs">
{doc.content_md}
</pre>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,100 @@
// Folder-listing page (PBI-96 / T-1068). Server-component.
//
// Valideert de folder-segment tegen ProductDocFolder-enum (404 anders).
// Toont een tabel met alle docs in deze folder (sortering [slug ASC]).
// "Nieuwe doc" knop wordt in T-1070 functioneel via een dialog; voor nu
// is het een link naar `?new=1`. Disabled-folder banner komt in T-1071.
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { productDocFolderFromApi } from '@/lib/product-doc-folder'
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
import {
ProductDocsFolderList,
type ProductDocListRow,
} from '@/components/product-docs/product-docs-folder-list'
import { NewProductDocDialog } from '@/components/product-docs/new-product-doc-dialog'
import { DisabledFolderBanner } from '@/components/product-docs/disabled-folder-banner'
interface Props {
params: Promise<{ id: string; folder: string }>
}
export default async function ProductDocsFolderPage({ params }: Props) {
const { id, folder: folderApiParam } = await params
const session = await getSession()
if (!session.userId) redirect('/login')
const folderDb = productDocFolderFromApi(folderApiParam)
if (!folderDb) notFound()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const docs = await prisma.productDoc.findMany({
where: { product_id: id, folder: folderDb },
select: {
id: true,
slug: true,
title: true,
status: true,
updated_at: true,
},
orderBy: [{ slug: 'asc' }],
})
const rows: ProductDocListRow[] = docs.map((d) => ({
id: d.id,
slug: d.slug,
title: d.title,
status: d.status,
updated_at: d.updated_at,
}))
const label = FOLDER_LABELS[folderDb]
const folderApi = folderApiParam.toLowerCase()
const isFolderEnabled = product.enabled_doc_folders.includes(folderDb)
const isDemo = session.isDemo ?? false
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
href={`/products/${id}/docs`}
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ArrowLeft className="size-3" />
Documentatie
</Link>
<span>/</span>
<span className="text-foreground font-medium">{label.title}</span>
</div>
<div className="flex items-baseline justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">{label.title}</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{label.description}
</p>
</div>
{isFolderEnabled && (
<NewProductDocDialog
productId={id}
enabledFolders={product.enabled_doc_folders}
initialFolder={folderApi}
isDemo={isDemo}
/>
)}
</div>
{!isFolderEnabled && <DisabledFolderBanner productId={id} />}
<ProductDocsFolderList productId={id} folderApi={folderApi} docs={rows} />
</div>
)
}

View file

@ -0,0 +1,73 @@
// Product Docs INDEX-pagina (PBI-96 / T-1067). Server-component.
//
// Loadt het product + de meest-recente 3 docs per enabled folder, en
// rendert de grid via ProductDocsIndex. Disabled folders worden NIET
// getoond op de INDEX maar blijven via directe URL bereikbaar (zie
// plan §C.4 / T-1071 voor de banner-flow).
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import {
ProductDocsIndex,
} from '@/components/product-docs/product-docs-index'
import type { ProductDocCardItem } from '@/components/product-docs/product-docs-folder-card'
import type { ProductDocFolder } from '@prisma/client'
interface Props {
params: Promise<{ id: string }>
}
export default async function ProductDocsIndexPage({ 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()
const enabledFolders = product.enabled_doc_folders
// Eén findMany, in-memory groeperen + slicen tot 3 per folder. Voorkomt
// 8 separate queries; voor doc-aantallen tot ~100 is dit verwaarloosbaar.
const recentDocs =
enabledFolders.length === 0
? []
: await prisma.productDoc.findMany({
where: { product_id: id, folder: { in: enabledFolders } },
select: {
id: true,
folder: true,
slug: true,
title: true,
status: true,
updated_at: true,
},
orderBy: [{ updated_at: 'desc' }],
})
const docsByFolder: Partial<Record<ProductDocFolder, ProductDocCardItem[]>> = {}
for (const doc of recentDocs) {
const bucket = docsByFolder[doc.folder] ?? []
if (bucket.length < 3) {
bucket.push({
id: doc.id,
slug: doc.slug,
title: doc.title,
status: doc.status,
updated_at: doc.updated_at,
})
docsByFolder[doc.folder] = bucket
}
}
return (
<ProductDocsIndex
productId={id}
enabledFolders={enabledFolders}
docsByFolder={docsByFolder}
/>
)
}

View file

@ -0,0 +1,56 @@
// Product Docs folder-settings page (PBI-96 / T-1072). Server-component.
// Owner-only voor schrijven; ProductMember ziet read-only checkboxes.
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { ProductDocFolderToggle } from '@/components/product-docs/product-doc-folder-toggle'
interface Props {
params: Promise<{ id: string }>
}
export default async function ProductDocsSettingsPage({ 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()
const isOwner = product.user_id === session.userId
const isDemo = session.isDemo ?? false
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
href={`/products/${id}/docs`}
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ArrowLeft className="size-3" />
Documentatie
</Link>
<span>/</span>
<span className="text-foreground font-medium">Folder-instellingen</span>
</div>
<div>
<h1 className="text-xl font-semibold">Folder-instellingen</h1>
<p className="text-xs text-muted-foreground mt-0.5">
Bepaal welke documentatie-folders zichtbaar zijn voor dit product.
</p>
</div>
<ProductDocFolderToggle
productId={id}
initialEnabledFolders={product.enabled_doc_folders}
isOwner={isOwner}
isDemo={isDemo}
/>
</div>
)
}

View file

@ -2,6 +2,7 @@ import { redirect, notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { SetCurrentProduct } from '@/components/shared/set-current-product'
import { ProductSubNav } from '@/components/products/product-subnav'
interface Props {
children: React.ReactNode
@ -16,9 +17,12 @@ export default async function ProductLayout({ children, params }: Props) {
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const showDocs = product.enabled_doc_folders.length > 0
return (
<>
<SetCurrentProduct id={id} name={product.name} />
<ProductSubNav productId={id} showDocs={showDocs} />
{children}
</>
)

View file

@ -1,23 +1,16 @@
'use client'
// IdeaMdEditor — bewerk grill_md of plan_md.
// IdeaMdEditor — dunne wrapper rond MarkdownDocEditor (shared) die de
// idea-specifieke action + validator injecteert.
//
// - kind='grill': geen yaml-validatie (vrije markdown).
// - kind='plan' : preflight via parsePlanMd (server-side action herhaalt
// validation, dit is alleen UX om eerder te falen).
//
// Save → updateGrillMdAction / updatePlanMdAction. Cmd/Ctrl+S triggert save.
// LocalStorage-backed draft per idea+kind, restore bij heropening.
// - kind='plan' : live yaml-frontmatter validatie via parsePlanMd. Server-
// action herhaalt validation; dit is alleen UX om eerder te falen.
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Save, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { debugProps } from '@/lib/debug'
import { parsePlanMd, type PlanParseError } from '@/lib/idea-plan-parser'
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
import { parsePlanMd } from '@/lib/idea-plan-parser'
import { updateGrillMdAction, updatePlanMdAction } from '@/actions/ideas'
type Kind = 'grill' | 'plan'
@ -29,145 +22,37 @@ interface Props {
onCancel: () => void
}
// Lazily compute the seed: read draft from localStorage on first render, fall
// back to initialValue. Avoids setState-in-useEffect for hydration.
function readSeed(draftKey: string, initialValue: string): {
value: string
restored: boolean
} {
if (typeof window === 'undefined') return { value: initialValue, restored: false }
const draft = window.localStorage.getItem(draftKey)
if (draft && draft !== initialValue) return { value: draft, restored: true }
return { value: initialValue, restored: false }
function planValidator(value: string) {
const r = parsePlanMd(value)
return r.ok ? [] : r.errors
}
export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) {
const router = useRouter()
const draftKey = `idea-md-draft-${ideaId}-${kind}`
const [seed] = useState(() => readSeed(draftKey, initialValue))
const [value, setValue] = useState(seed.value)
const [submitErrors, setSubmitErrors] = useState<PlanParseError[]>([])
const [submitting, startSubmit] = useTransition()
// Eenmalige toast voor restore — de seed is al toegepast bij mount.
useEffect(() => {
if (seed.restored) {
toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.')
}
}, [seed.restored])
// Auto-save naar localStorage on change.
useEffect(() => {
if (typeof window === 'undefined') return
if (value === initialValue) {
window.localStorage.removeItem(draftKey)
} else {
window.localStorage.setItem(draftKey, value)
}
}, [value, initialValue, draftKey])
// Live yaml-validatie als afgeleide state — geen useEffect nodig.
const validationErrors = useMemo<PlanParseError[]>(() => {
if (kind !== 'plan') return []
if (value === '' || value === initialValue) return []
const r = parsePlanMd(value)
return r.ok ? [] : r.errors
}, [value, initialValue, kind])
// Combine: validation errors voor live feedback, submitErrors voor server-side details.
const errors = submitErrors.length > 0 ? submitErrors : validationErrors
function save() {
if (errors.length > 0 && kind === 'plan') {
toast.error('Frontmatter heeft fouten — fix die eerst.')
return
}
setSubmitErrors([])
startSubmit(async () => {
const r =
kind === 'grill'
? await updateGrillMdAction(ideaId, value)
: await updatePlanMdAction(ideaId, value)
if ('error' in r) {
toast.error(r.error)
if ('details' in r && Array.isArray(r.details)) {
setSubmitErrors(r.details as PlanParseError[])
}
return
}
toast.success('Opgeslagen')
window.localStorage.removeItem(draftKey)
router.refresh()
onCancel()
})
}
// Cmd/Ctrl+S → save
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
const dirty = value !== initialValue
return (
<div className="space-y-3" {...debugProps('idea-md-editor', 'IdeaMdEditor', 'components/ideas/idea-md-editor.tsx')}>
{errors.length > 0 && (
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1">
<p className="text-xs font-medium text-status-blocked">
{kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'}
</p>
<ul className="text-xs text-status-blocked space-y-0.5">
{errors.map((err, i) => (
<li key={i}>
{err.line ? `Regel ${err.line}: ` : ''}
{err.message}
{err.hint && (
<div className="mt-1 text-foreground/80">Tip: {err.hint}</div>
)}
</li>
))}
</ul>
</div>
)}
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
rows={24}
className="font-mono text-sm leading-relaxed"
data-debug-id="idea-md-editor__textarea"
placeholder={
kind === 'grill'
? '# Idee — ...\n## Scope\n...'
: '---\npbi:\n title: ...\n priority: 2\nstories:\n - title: ...\n---\n\n# Overwegingen\n...'
}
disabled={submitting}
/>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{dirty ? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan' : 'Geen wijzigingen'}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onCancel} disabled={submitting}>
<X className="size-3.5 mr-1" />
Annuleer
</Button>
<Button
size="sm"
onClick={save}
disabled={!dirty || submitting || (errors.length > 0 && kind === 'plan')}
data-debug-id="idea-md-editor__save"
>
<Save className="size-3.5 mr-1" />
Opslaan
</Button>
</div>
</div>
</div>
<MarkdownDocEditor
storageKey={`idea-md-draft-${ideaId}-${kind}`}
initialValue={initialValue}
validate={kind === 'plan' ? planValidator : undefined}
onSave={(value) =>
kind === 'grill'
? updateGrillMdAction(ideaId, value)
: updatePlanMdAction(ideaId, value)
}
onSaved={() => router.refresh()}
onCancel={onCancel}
placeholder={
kind === 'grill'
? '# Idee — ...\n## Scope\n...'
: '---\npbi:\n title: ...\n priority: 2\nstories:\n - title: ...\n---\n\n# Overwegingen\n...'
}
validationErrorsHeader={
kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'
}
debugId="idea-md-editor"
debugComponentName="IdeaMdEditor"
debugFile="components/ideas/idea-md-editor.tsx"
/>
)
}

View file

@ -0,0 +1,104 @@
'use client'
// Confirm-dialog + delete-action voor een Product Doc. DemoTooltip-wrapped
// (laag 3 van de drie-laagse demo-policy). Na succesvolle delete: redirect
// naar de folder-page.
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug'
import { deleteProductDocAction } from '@/actions/product-docs'
interface Props {
docId: string
docTitle: string
/** URL waar we naar redirecten na succes. */
redirectHref: string
isDemo: boolean
}
export function DeleteProductDocButton({
docId,
docTitle,
redirectHref,
isDemo,
}: Props) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [submitting, startSubmit] = useTransition()
function confirmDelete() {
startSubmit(async () => {
const r = await deleteProductDocAction(docId)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success('Doc verwijderd')
setOpen(false)
router.push(redirectHref)
router.refresh()
})
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<DemoTooltip show={isDemo}>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
disabled={isDemo}
{...debugProps(
'delete-product-doc-button',
'DeleteProductDocButton',
'components/product-docs/delete-product-doc-button.tsx',
)}
>
<Trash2 className="size-3.5 mr-1" />
Verwijderen
</Button>
}
/>
</DemoTooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Doc verwijderen?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{docTitle}&rdquo; wordt permanent verwijderd. Een audit-log-rij
blijft bewaard, maar de inhoud is daarna niet meer leesbaar.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Annuleer</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={submitting}
variant="destructive"
data-debug-id="delete-product-doc-button__confirm"
>
Ja, verwijder
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,44 @@
// Banner voor pages die naar een disabled folder verwijzen. Server-component.
//
// P2-review-fix uit
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md:
// disabled folder is "verborgen maar leesbaar" — directe URLs blijven werken,
// maar mutaties zijn uitgeschakeld in de UI. Banner maakt de staat zichtbaar.
import Link from 'next/link'
import { AlertTriangle } from 'lucide-react'
import { debugProps } from '@/lib/debug'
interface Props {
productId: string
}
export function DisabledFolderBanner({ productId }: Props) {
return (
<div
className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 flex items-start gap-2"
{...debugProps(
'disabled-folder-banner',
'DisabledFolderBanner',
'components/product-docs/disabled-folder-banner.tsx',
)}
>
<AlertTriangle className="size-4 text-status-blocked shrink-0 mt-0.5" />
<div className="text-xs space-y-1 flex-1">
<p className="font-medium text-status-blocked">Folder uitgeschakeld</p>
<p className="text-muted-foreground">
Deze folder staat uit in de instellingen. Bestaande docs blijven
leesbaar; nieuwe docs kunnen niet worden aangemaakt en bestaande
kunnen niet worden bewerkt. Verwijderen is wel mogelijk voor cleanup.
</p>
<Link
href={`/products/${productId}/docs/settings`}
className="inline-block text-primary hover:underline mt-1"
>
Folder-instellingen openen
</Link>
</div>
</div>
)
}

View file

@ -0,0 +1,285 @@
'use client'
// NewProductDocDialog — trigger-knop + dialog voor nieuwe Product Doc.
// Eén client-component dat zowel de trigger als de dialog beheert.
//
// Volgt docs/patterns/dialog.md (entity-dialog conventie). Specifieke
// keuzes en afwijkingen: docs/specs/dialogs/product-doc.md.
//
// Deep-link: opent automatisch wanneer `?new=1` in searchParams staat.
// De folder-card CTA in de INDEX-grid linkt daarheen, en folder-page
// laat de knop ook handmatig opzetten.
import { useId, useState, useTransition } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug'
import { productDocFolderToApi } from '@/lib/product-doc-folder'
import { slugify } from '@/lib/product-doc-slug'
import { PRODUCT_DOC_FOLDER_DEFAULTS } from '@/lib/schemas/product-doc-frontmatter-defaults'
import {
PRODUCT_DOC_FOLDERS,
type ProductDocFolderApi,
} from '@/lib/schemas/product-doc'
import { createProductDocAction } from '@/actions/product-docs'
import type { ProductDocFolder } from '@prisma/client'
interface Props {
productId: string
enabledFolders: ProductDocFolder[]
/** Voorgeselecteerde folder (typisch de current folder van de pagina). */
initialFolder?: string
isDemo: boolean
}
export function NewProductDocDialog({
productId,
enabledFolders,
initialFolder,
isDemo,
}: Props) {
const router = useRouter()
const searchParams = useSearchParams()
const folderId = useId()
const titleId = useId()
const slugId = useId()
const bodyId = useId()
const enabledFolderApis: ProductDocFolderApi[] = enabledFolders.map((f) =>
productDocFolderToApi(f),
)
function isValidFolderApi(v: string): v is ProductDocFolderApi {
return (PRODUCT_DOC_FOLDERS as readonly string[]).includes(v)
}
const defaultFolder: ProductDocFolderApi =
initialFolder &&
isValidFolderApi(initialFolder) &&
enabledFolderApis.includes(initialFolder)
? initialFolder
: (enabledFolderApis[0] ?? 'runbooks')
// Auto-open on `?new=1` (deep-link uit folder-card CTA) via initial
// state — voorkomt setState-in-useEffect.
const [open, setOpen] = useState(() => searchParams.get('new') === '1')
const [folder, setFolder] = useState<ProductDocFolderApi>(defaultFolder)
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [body, setBody] = useState('')
const [slugTouched, setSlugTouched] = useState(false)
const [submitting, startSubmit] = useTransition()
function clearNewParam() {
if (searchParams.get('new') !== '1') return
const next = new URLSearchParams(searchParams)
next.delete('new')
const q = next.toString()
router.replace(q ? `?${q}` : '?')
}
function close() {
setOpen(false)
clearNewParam()
}
function onTitleChange(v: string) {
setTitle(v)
if (!slugTouched) setSlug(slugify(v))
}
function applyStarterTemplate() {
const tpl =
PRODUCT_DOC_FOLDER_DEFAULTS[
folder as keyof typeof PRODUCT_DOC_FOLDER_DEFAULTS
]
if (!tpl) return
const tplWithTitle = tpl.template.replace(
/title:\s*"\.\.\."/,
`title: "${title || '...'}"`,
)
setBody(tplWithTitle)
}
function submit() {
if (!title.trim() || !slug.trim()) {
toast.error('Titel en slug zijn verplicht')
return
}
startSubmit(async () => {
// Wrap minimaal frontmatter rond de body als de user nog geen
// starter-template heeft toegepast.
const content = body.startsWith('---')
? body
: `---\ntitle: "${title}"\nstatus: draft\n---\n\n${body}`
const r = await createProductDocAction({
product_id: productId,
folder,
slug,
content_md: content,
})
if ('error' in r) {
toast.error(r.error)
return
}
toast.success('Doc aangemaakt')
if (r.data) {
router.push(
`/products/${productId}/docs/${r.data.folder}/${r.data.slug}`,
)
}
setOpen(false)
clearNewParam()
})
}
return (
<>
<DemoTooltip show={isDemo}>
<Button
variant="outline"
size="sm"
disabled={isDemo}
onClick={() => setOpen(true)}
{...debugProps(
'new-product-doc-dialog__trigger',
'NewProductDocDialog',
'components/product-docs/new-product-doc-dialog.tsx',
)}
>
<Plus className="size-3.5 mr-1" />
Nieuwe doc
</Button>
</DemoTooltip>
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : close())}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Nieuwe doc</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<label htmlFor={folderId} className="text-xs font-medium">
Folder
</label>
<Select
value={folder}
onValueChange={(v) => {
if (v && isValidFolderApi(v)) setFolder(v)
}}
>
<SelectTrigger id={folderId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledFolderApis.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label htmlFor={titleId} className="text-xs font-medium">
Titel
</label>
<Input
id={titleId}
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Bijv. Deploy stappen"
disabled={submitting}
/>
</div>
<div className="space-y-1.5">
<label htmlFor={slugId} className="text-xs font-medium">
Slug
</label>
<Input
id={slugId}
value={slug}
onChange={(e) => {
setSlug(e.target.value)
setSlugTouched(true)
}}
placeholder="auto-gegenereerd uit titel"
disabled={submitting}
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<label htmlFor={bodyId} className="text-xs font-medium">
Inhoud (markdown + yaml-frontmatter)
</label>
<button
type="button"
onClick={applyStarterTemplate}
className="text-xs text-primary hover:underline"
disabled={submitting}
>
Gebruik {folder}-template
</button>
</div>
<Textarea
id={bodyId}
rows={10}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'---\ntitle: "..."\nstatus: draft\n---\n\n# Inhoud\n'}
disabled={submitting}
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
Frontmatter moet beginnen met <code>---</code> en velden{' '}
<code>title</code> + <code>status</code> bevatten.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={close}
disabled={submitting}
>
Annuleer
</Button>
<Button size="sm" onClick={submit} disabled={submitting}>
<Plus className="size-3.5 mr-1" />
Aanmaken
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,44 @@
'use client'
// Editor-wrapper voor Product Docs. Dunne wrapper rond MarkdownDocEditor
// (shared) die de product-doc-specifieke action + validator injecteert.
// Onderdeel van de viewer-page (T-1069) — wordt getoond wanneer `?edit=1`
// in de searchParams staat.
import { useRouter } from 'next/navigation'
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import { updateProductDocAction } from '@/actions/product-docs'
interface Props {
docId: string
initialValue: string
/** URL waar de gebruiker naar terug navigeert na annuleren of opslaan. */
cancelHref: string
}
function frontmatterValidator(value: string) {
const r = parseProductDocMd(value)
return r.ok ? [] : r.errors
}
export function ProductDocEditor({ docId, initialValue, cancelHref }: Props) {
const router = useRouter()
return (
<MarkdownDocEditor
storageKey={`product-doc-${docId}`}
initialValue={initialValue}
validate={frontmatterValidator}
onSave={(value) => updateProductDocAction(docId, value)}
onSaved={() => router.refresh()}
onCancel={() => router.push(cancelHref)}
placeholder={`---\ntitle: "..."\nstatus: draft\n---\n\n# Inhoud\n`}
validationErrorsHeader="YAML-frontmatter fouten"
debugId="product-doc-editor"
debugComponentName="ProductDocEditor"
debugFile="components/product-docs/product-doc-editor.tsx"
/>
)
}

View file

@ -0,0 +1,142 @@
'use client'
// Folder-toggle UI voor /docs/settings. 8 checkboxes (één per
// ProductDocFolder enum-lid). Owner kan toggelen; ProductMember ziet
// read-only checkboxes met waarschuwing.
//
// DemoTooltip-wrapped per checkbox; demo-user kan niets togglen.
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug'
import {
PRODUCT_DOC_FOLDERS,
type ProductDocFolderApi,
} from '@/lib/schemas/product-doc'
import { productDocFolderToApi } from '@/lib/product-doc-folder'
import { toggleProductDocFolderAction } from '@/actions/product-docs'
import type { ProductDocFolder } from '@prisma/client'
const FOLDER_LABELS: Record<ProductDocFolderApi, string> = {
adr: 'ADRs — Architecture Decision Records',
architecture: 'Architecture — Topische arch-bestanden',
patterns: 'Patterns — Herbruikbare code-patronen',
plans: 'Plans — Feature- en PBI-plannen',
runbooks: 'Runbooks — Operationele procedures',
specs: 'Specs — Functionele specificaties',
manual: 'Manual — Developer manual',
api: 'API — API-contract details',
}
interface Props {
productId: string
/** Folders die nu enabled zijn (DB-enum). */
initialEnabledFolders: ProductDocFolder[]
isOwner: boolean
isDemo: boolean
}
export function ProductDocFolderToggle({
productId,
initialEnabledFolders,
isOwner,
isDemo,
}: Props) {
const router = useRouter()
const initialApiSet = new Set(
initialEnabledFolders.map((f) => productDocFolderToApi(f)),
)
const [enabledSet, setEnabledSet] = useState<Set<ProductDocFolderApi>>(initialApiSet)
const [pendingFolder, setPendingFolder] = useState<ProductDocFolderApi | null>(null)
const [submitting, startSubmit] = useTransition()
function toggle(folder: ProductDocFolderApi) {
const currentlyEnabled = enabledSet.has(folder)
const targetEnabled = !currentlyEnabled
// Optimistic update
const next = new Set(enabledSet)
if (targetEnabled) next.add(folder)
else next.delete(folder)
setEnabledSet(next)
setPendingFolder(folder)
startSubmit(async () => {
const r = await toggleProductDocFolderAction({
product_id: productId,
folder,
enabled: targetEnabled,
})
if ('error' in r) {
// Rollback
setEnabledSet(enabledSet)
toast.error(r.error)
} else {
toast.success(targetEnabled ? `Folder ${folder} aangezet` : `Folder ${folder} uitgezet`)
router.refresh()
}
setPendingFolder(null)
})
}
return (
<div
className="space-y-3"
{...debugProps(
'product-doc-folder-toggle',
'ProductDocFolderToggle',
'components/product-docs/product-doc-folder-toggle.tsx',
)}
>
{!isOwner && (
<p className="text-xs text-muted-foreground italic">
Alleen de eigenaar van dit product kan folders aan- of uitzetten.
</p>
)}
<ul className="space-y-2">
{PRODUCT_DOC_FOLDERS.map((folder) => {
const checked = enabledSet.has(folder)
const isPending = pendingFolder === folder
const disabled = !isOwner || isDemo || submitting
return (
<li key={folder}>
<DemoTooltip show={isDemo}>
<label
className={`flex items-start gap-2 rounded-md border border-border p-2 ${
disabled ? 'opacity-70' : 'hover:bg-surface-container/60 cursor-pointer'
}`}
data-debug-id={`product-doc-folder-toggle__row--${folder}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggle(folder)}
className="mt-0.5 accent-primary"
/>
<div className="flex-1 text-xs">
<p className="font-medium">{FOLDER_LABELS[folder]}</p>
{isPending && (
<p className="text-muted-foreground text-[10px]">Bezig</p>
)}
</div>
</label>
</DemoTooltip>
</li>
)
})}
</ul>
<p className="text-xs text-muted-foreground">
Folders uitzetten verwijdert geen bestaande docs die blijven leesbaar
via directe URL en kunnen worden verwijderd voor cleanup.
</p>
</div>
)
}

View file

@ -0,0 +1,43 @@
// Status-badge voor ProductDoc.status. MD3-tokens; géén bg-blue-500
// (CLAUDE.md hardstop). Status komt uit frontmatter dus theoretisch
// een free-form string — onbekende waarden krijgen de neutrale 'muted'
// stijl.
import { cn } from '@/lib/utils'
const STATUS_CLASS: Record<string, string> = {
draft: 'bg-muted text-muted-foreground',
active: 'bg-status-done/20 text-status-done',
deprecated: 'bg-status-blocked/20 text-status-blocked',
archived: 'bg-muted/50 text-muted-foreground',
}
const STATUS_LABEL: Record<string, string> = {
draft: 'draft',
active: 'active',
deprecated: 'deprecated',
archived: 'archived',
}
interface Props {
status: string
className?: string
}
export function ProductDocStatusBadge({ status, className }: Props) {
const normalized = status.toLowerCase()
const klass = STATUS_CLASS[normalized] ?? 'bg-muted text-muted-foreground'
const label = STATUS_LABEL[normalized] ?? status
return (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
klass,
className,
)}
data-debug-id={`product-doc-status-badge--${normalized}`}
>
{label}
</span>
)
}

View file

@ -0,0 +1,78 @@
// Doc-viewer voor Product Docs. Server-component. Rendert een
// frontmatter-kop (title + status-badge + meta) gevolgd door de markdown-
// body via components/markdown.tsx (XSS-safe; iframe/script disabled).
//
// Frontmatter wordt door de pagina geparseerd en als losse props
// doorgegeven — voorkomt dat de viewer parsing-fouten moet afhandelen.
import { debugProps } from '@/lib/debug'
import { Markdown } from '@/components/markdown'
import { ProductDocStatusBadge } from './product-doc-status-badge'
interface Props {
title: string
status: string
body: string
audience?: string | string[] | undefined
applies_to?: string | string[] | undefined
lastUpdated?: string | undefined
}
function renderList(value: string | string[] | undefined): string | null {
if (!value) return null
return Array.isArray(value) ? value.join(', ') : value
}
export function ProductDocViewer({
title,
status,
body,
audience,
applies_to,
lastUpdated,
}: Props) {
const audienceLabel = renderList(audience)
const appliesToLabel = renderList(applies_to)
return (
<article
className="space-y-4"
{...debugProps(
'product-doc-viewer',
'ProductDocViewer',
'components/product-docs/product-doc-viewer.tsx',
)}
>
<header className="space-y-2 pb-3 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">{title}</h1>
<ProductDocStatusBadge status={status} />
</div>
{(audienceLabel || appliesToLabel || lastUpdated) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{audienceLabel && (
<span>
<span className="uppercase tracking-wide">Audience:</span>{' '}
{audienceLabel}
</span>
)}
{appliesToLabel && (
<span>
<span className="uppercase tracking-wide">Applies to:</span>{' '}
{appliesToLabel}
</span>
)}
{lastUpdated && (
<span>
<span className="uppercase tracking-wide">Bijgewerkt:</span>{' '}
{lastUpdated}
</span>
)}
</div>
)}
</header>
<Markdown>{body}</Markdown>
</article>
)
}

View file

@ -0,0 +1,86 @@
// Card per folder op de Product Docs INDEX-pagina. Server-component.
// Toont folder-titel + omschrijving + telling + de laatste 3 docs (of CTA
// bij lege folder). MD3-tokens, géén bg-blue-500 (CLAUDE.md hardstop).
import Link from 'next/link'
import { FileText, Plus } from 'lucide-react'
import { productDocFolderToApi } from '@/lib/product-doc-folder'
import { debugProps } from '@/lib/debug'
import type { ProductDocFolder } from '@prisma/client'
export interface ProductDocCardItem {
id: string
slug: string
title: string
status: string
updated_at: Date
}
interface Props {
productId: string
folder: ProductDocFolder
label: { title: string; description: string }
docs: ProductDocCardItem[]
}
export function ProductDocsFolderCard({ productId, folder, label, docs }: Props) {
const folderApi = productDocFolderToApi(folder)
const folderUrl = `/products/${productId}/docs/${folderApi}`
return (
<div
className="rounded-lg border border-border bg-surface-container-low p-4 space-y-3"
{...debugProps(
`product-docs-folder-card--${folderApi}`,
'ProductDocsFolderCard',
'components/product-docs/product-docs-folder-card.tsx',
)}
>
<div className="flex items-baseline justify-between gap-2">
<div className="min-w-0">
<Link
href={folderUrl}
className="text-sm font-semibold hover:underline"
>
{label.title}
</Link>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{label.description}
</p>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{docs.length} {docs.length === 1 ? 'doc' : 'docs'}
</span>
</div>
{docs.length === 0 ? (
<div className="text-xs text-muted-foreground space-y-2">
<p>Nog geen docs in deze folder.</p>
<Link
href={`${folderUrl}?new=1`}
className="inline-flex items-center gap-1 text-primary hover:underline"
data-debug-id={`product-docs-folder-card--${folderApi}__cta`}
>
<Plus className="size-3" />
Maak eerste doc
</Link>
</div>
) : (
<ul className="space-y-1">
{docs.map((doc) => (
<li key={doc.id}>
<Link
href={`${folderUrl}/${doc.slug}`}
className="flex items-center gap-2 text-xs hover:bg-surface-container px-1 py-1 -mx-1 rounded transition-colors"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{doc.title}</span>
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,94 @@
// Folder-listing tabel. Server-component (geen interactiviteit in v1 —
// sorteren komt later via TanStack-table indien nodig). Toont
// slug · title · status-badge · updated_at.
import Link from 'next/link'
import { FileText } from 'lucide-react'
import { debugProps } from '@/lib/debug'
import { ProductDocStatusBadge } from './product-doc-status-badge'
export interface ProductDocListRow {
id: string
slug: string
title: string
status: string
updated_at: Date
}
interface Props {
productId: string
folderApi: string
docs: ProductDocListRow[]
}
const dateFmt = new Intl.DateTimeFormat('nl-NL', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
export function ProductDocsFolderList({ productId, folderApi, docs }: Props) {
if (docs.length === 0) {
return (
<div
className="text-sm text-muted-foreground p-6 text-center"
{...debugProps(
'product-docs-folder-list--empty',
'ProductDocsFolderList',
'components/product-docs/product-docs-folder-list.tsx',
)}
>
Nog geen docs in deze folder.
</div>
)
}
const docsUrl = `/products/${productId}/docs/${folderApi}`
return (
<div
className="rounded-lg border border-border bg-surface-container-low overflow-hidden"
{...debugProps(
'product-docs-folder-list',
'ProductDocsFolderList',
'components/product-docs/product-docs-folder-list.tsx',
)}
>
<table className="w-full text-sm">
<thead className="border-b border-border bg-surface-container">
<tr className="text-xs text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">Slug</th>
<th className="text-left px-3 py-2 font-medium">Titel</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-right px-3 py-2 font-medium">Bijgewerkt</th>
</tr>
</thead>
<tbody>
{docs.map((doc) => (
<tr
key={doc.id}
className="border-t border-border first:border-t-0 hover:bg-surface-container/60"
>
<td className="px-3 py-2 font-mono text-xs">
<Link
href={`${docsUrl}/${doc.slug}`}
className="text-primary hover:underline inline-flex items-center gap-1.5"
>
<FileText className="size-3 shrink-0" />
{doc.slug}
</Link>
</td>
<td className="px-3 py-2 truncate max-w-[420px]">{doc.title}</td>
<td className="px-3 py-2">
<ProductDocStatusBadge status={doc.status} />
</td>
<td className="px-3 py-2 text-right text-xs text-muted-foreground tabular-nums">
{dateFmt.format(doc.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,114 @@
// INDEX-grid voor Product Docs. Server-component. Toont alleen folders
// die in product.enabled_doc_folders staan, in vaste volgorde.
import Link from 'next/link'
import { debugProps } from '@/lib/debug'
import type { ProductDocFolder } from '@prisma/client'
import {
ProductDocsFolderCard,
type ProductDocCardItem,
} from './product-docs-folder-card'
interface Props {
productId: string
enabledFolders: ProductDocFolder[]
/** Per-folder lijst met (max 3) meest recent bijgewerkte docs. */
docsByFolder: Partial<Record<ProductDocFolder, ProductDocCardItem[]>>
}
// Vaste display-order (matcht Scrum4Me's eigen docs-tree).
const FOLDER_ORDER: ProductDocFolder[] = [
'ADR',
'ARCHITECTURE',
'PATTERNS',
'PLANS',
'RUNBOOKS',
'SPECS',
'MANUAL',
'API',
]
export const FOLDER_LABELS: Record<
ProductDocFolder,
{ title: string; description: string }
> = {
ADR: { title: 'ADRs', description: 'Architecture Decision Records' },
ARCHITECTURE: { title: 'Architecture', description: 'Topische arch-bestanden' },
PATTERNS: { title: 'Patterns', description: 'Herbruikbare code-patronen' },
PLANS: { title: 'Plans', description: 'Feature- en PBI-plannen' },
RUNBOOKS: { title: 'Runbooks', description: 'Operationele procedures' },
SPECS: { title: 'Specs', description: 'Functionele specificaties' },
MANUAL: { title: 'Manual', description: 'Developer manual' },
API: { title: 'API', description: 'API-contract details' },
}
export function ProductDocsIndex({
productId,
enabledFolders,
docsByFolder,
}: Props) {
const orderedEnabled = FOLDER_ORDER.filter((f) => enabledFolders.includes(f))
if (orderedEnabled.length === 0) {
return (
<div
className="p-8 text-center space-y-3"
{...debugProps(
'product-docs-index--empty',
'ProductDocsIndex',
'components/product-docs/product-docs-index.tsx',
)}
>
<p className="text-sm text-muted-foreground">
Geen documentatie-folders ingeschakeld voor dit product.
</p>
<Link
href={`/products/${productId}/docs/settings`}
className="text-xs text-primary hover:underline"
>
Folders configureren
</Link>
</div>
)
}
return (
<div
className="p-6 space-y-4"
{...debugProps(
'product-docs-index',
'ProductDocsIndex',
'components/product-docs/product-docs-index.tsx',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">Documentatie</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{orderedEnabled.length} van 8 folders ingeschakeld
</p>
</div>
<Link
href={`/products/${productId}/docs/settings`}
className="text-xs text-muted-foreground hover:text-foreground"
data-debug-id="product-docs-index__settings-link"
>
Folder-config
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{orderedEnabled.map((folder) => (
<ProductDocsFolderCard
key={folder}
productId={productId}
folder={folder}
label={FOLDER_LABELS[folder]}
docs={docsByFolder[folder] ?? []}
/>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,98 @@
'use client'
// Per-product sub-navigation. Toont tabs voor de verschillende product-
// pages (Backlog, Sprint, Solo, Docs, Instellingen). Active-state via
// `usePathname`. Patroon gebaseerd op `navLink` uit components/shared/nav-bar.tsx.
//
// Plan: docs/plans/PBI-96-product-docs.md §C.2.
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
interface Props {
productId: string
/** Wanneer false: verberg de Docs-tab (geen folders enabled). */
showDocs?: boolean
}
interface Tab {
href: string
label: string
isActive: (pathname: string) => boolean
}
export function ProductSubNav({ productId, showDocs = true }: Props) {
const pathname = usePathname()
const base = `/products/${productId}`
const tabs: Tab[] = [
{
href: base,
label: 'Backlog',
isActive: (p) =>
p === base ||
(p.startsWith(base) &&
!p.startsWith(`${base}/sprint`) &&
!p.startsWith(`${base}/solo`) &&
!p.startsWith(`${base}/docs`) &&
!p.startsWith(`${base}/settings`)),
},
{
href: `${base}/sprint`,
label: 'Sprint',
isActive: (p) => p.startsWith(`${base}/sprint`),
},
{
href: `${base}/solo`,
label: 'Solo',
isActive: (p) => p.startsWith(`${base}/solo`),
},
...(showDocs
? [
{
href: `${base}/docs`,
label: 'Docs',
isActive: (p: string) => p.startsWith(`${base}/docs`),
},
]
: []),
{
href: `${base}/settings`,
label: 'Instellingen',
isActive: (p) => p.startsWith(`${base}/settings`),
},
]
return (
<nav
className="border-b border-border bg-surface-container-low px-4 py-2 flex items-center gap-1 shrink-0"
{...debugProps(
'product-subnav',
'ProductSubNav',
'components/products/product-subnav.tsx',
)}
>
{tabs.map((tab) => {
const active = tab.isActive(pathname)
return (
<Link
key={tab.label}
href={tab.href}
className={cn(
'px-3 py-1 rounded-md text-xs transition-colors',
active
? 'bg-primary-container text-primary-container-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container',
)}
data-debug-id={`product-subnav__tab--${tab.label.toLowerCase()}`}
>
{tab.label}
</Link>
)
})}
</nav>
)
}

View file

@ -0,0 +1,222 @@
'use client'
// Generieke markdown-editor met state, draft-persistence en keyboard shortcut.
// Geëxtraheerd uit components/ideas/idea-md-editor.tsx zodat zowel Ideas
// (grill/plan) als Product Docs dezelfde editor-stack gebruiken (CLAUDE.md
// dialog-discipline: "twee keer kopieren = promote 'm meteen").
//
// Plan: docs/plans/PBI-96-product-docs.md §D.1.
//
// Patronen die deze component levert:
// - Cmd/Ctrl+S → onSave
// - localStorage-backed draft per `storageKey`, restore bij heropening
// - live validatie via optionele `validate`-callback (blokkeert submit)
// - server-side error-details (uit ActionResult.details) renderen
// - toast-feedback (sonner)
// - dirty-state-indicator in footer
//
// Entity-specifieke logica (welke action, welke validator) wordt door de
// caller geïnjecteerd; de component is volledig generic.
import { useEffect, useMemo, useState, useTransition } from 'react'
import { Save, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { debugProps } from '@/lib/debug'
export interface MarkdownDocEditorError {
line?: number
message: string
hint?: string
}
interface ActionSuccess {
success: true
}
interface ActionFailure {
error: string
code?: number
details?: unknown
}
export type MarkdownDocEditorActionResult = ActionSuccess | ActionFailure
interface Props {
/** Unieke key voor localStorage-draft (bv. `idea-md-${id}-${kind}` of `product-doc-${id}`). */
storageKey: string
/** Originele inhoud — wijzigingen worden ten opzichte hiervan als 'dirty' beschouwd. */
initialValue: string
/** Optionele live-validator. Returnt errors[] (leeg = OK). Submit wordt geblokkeerd bij errors. */
validate?: (value: string) => MarkdownDocEditorError[]
/** Server-action wrapper. Returnt {success:true} of {error, code, details}. */
onSave: (value: string) => Promise<MarkdownDocEditorActionResult>
/** Hook om na success extra werk te doen (bv. router.refresh()). Aangeroepen vóór onCancel. */
onSaved?: () => void
/** Sluit de editor (zowel bij annuleren als na succesvolle save). */
onCancel: () => void
rows?: number
placeholder?: string
saveLabel?: string
validationErrorsHeader?: string
/** Voor debug-attribuut op de root div. */
debugId?: string
debugComponentName?: string
debugFile?: string
}
function readSeed(storageKey: string, initialValue: string): {
value: string
restored: boolean
} {
if (typeof window === 'undefined') {
return { value: initialValue, restored: false }
}
const draft = window.localStorage.getItem(storageKey)
if (draft && draft !== initialValue) return { value: draft, restored: true }
return { value: initialValue, restored: false }
}
export function MarkdownDocEditor({
storageKey,
initialValue,
validate,
onSave,
onSaved,
onCancel,
rows = 24,
placeholder,
saveLabel = 'Opslaan',
validationErrorsHeader = 'Validatiefouten',
debugId = 'markdown-doc-editor',
debugComponentName = 'MarkdownDocEditor',
debugFile = 'components/shared/markdown-doc-editor.tsx',
}: Props) {
const [seed] = useState(() => readSeed(storageKey, initialValue))
const [value, setValue] = useState(seed.value)
const [submitErrors, setSubmitErrors] = useState<MarkdownDocEditorError[]>([])
const [submitting, startSubmit] = useTransition()
useEffect(() => {
if (seed.restored) {
toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.')
}
}, [seed.restored])
useEffect(() => {
if (typeof window === 'undefined') return
if (value === initialValue) {
window.localStorage.removeItem(storageKey)
} else {
window.localStorage.setItem(storageKey, value)
}
}, [value, initialValue, storageKey])
const validationErrors = useMemo<MarkdownDocEditorError[]>(() => {
if (!validate) return []
if (value === '' || value === initialValue) return []
return validate(value)
}, [value, initialValue, validate])
const errors = submitErrors.length > 0 ? submitErrors : validationErrors
const hasValidationErrors = validationErrors.length > 0
const dirty = value !== initialValue
function save() {
if (hasValidationErrors) {
toast.error('Inhoud heeft fouten — fix die eerst.')
return
}
setSubmitErrors([])
startSubmit(async () => {
const r = await onSave(value)
if ('error' in r) {
toast.error(r.error)
if ('details' in r && Array.isArray(r.details)) {
setSubmitErrors(r.details as MarkdownDocEditorError[])
}
return
}
toast.success('Opgeslagen')
if (typeof window !== 'undefined') {
window.localStorage.removeItem(storageKey)
}
onSaved?.()
onCancel()
})
}
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
return (
<div
className="space-y-3"
{...debugProps(debugId, debugComponentName, debugFile)}
>
{errors.length > 0 && (
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1">
<p className="text-xs font-medium text-status-blocked">
{validationErrorsHeader}
</p>
<ul className="text-xs text-status-blocked space-y-0.5">
{errors.map((err, i) => (
<li key={i}>
{err.line ? `Regel ${err.line}: ` : ''}
{err.message}
{err.hint && (
<div className="mt-1 text-foreground/80">Tip: {err.hint}</div>
)}
</li>
))}
</ul>
</div>
)}
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
rows={rows}
className="font-mono text-sm leading-relaxed"
data-debug-id={`${debugId}__textarea`}
placeholder={placeholder}
disabled={submitting}
/>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{dirty
? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan'
: 'Geen wijzigingen'}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={submitting}
>
<X className="size-3.5 mr-1" />
Annuleer
</Button>
<Button
size="sm"
onClick={save}
disabled={!dirty || submitting || hasValidationErrors}
data-debug-id={`${debugId}__save`}
>
<Save className="size-3.5 mr-1" />
{saveLabel}
</Button>
</div>
</div>
</div>
)
}

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
@ -28,6 +28,7 @@ Auto-generated on 2026-05-15 from front-matter and headings.
| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 |
| [IdeaDialog Profiel](./specs/dialogs/idea.md) | active | 2026-05-04 |
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
| [ProductDocDialog Profiel](./specs/dialogs/product-doc.md) | active | 2026-05-16 |
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-04 |
@ -49,6 +50,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 |
@ -94,6 +96,7 @@ Auto-generated on 2026-05-15 from front-matter and headings.
| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-08 |
| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-08 |
| [Product Backlog page — workflow & states](./architecture/product-backlog-workflow.md) | `architecture/product-backlog-workflow.md` | active | 2026-05-14 |
| [Product Docs (per-product documentatie)](./architecture/product-docs.md) | `architecture/product-docs.md` | active | 2026-05-16 |
| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-08 |
| [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 |
| [Sprint execution modes — PER_TASK vs SPRINT_BATCH](./architecture/sprint-execution-modes.md) | `architecture/sprint-execution-modes.md` | active | 2026-05-07 |
@ -129,6 +132,7 @@ Auto-generated on 2026-05-15 from front-matter and headings.
| [Review — M8 bootstrap-wizard plan v3.4](./recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md` | — | — |
| [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 |
| [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 |
| [PBI-96 demo-policy audit](./recommendations/PBI-96-demo-audit-2026-05-16.md) | `recommendations/PBI-96-demo-audit-2026-05-16.md` | review | 2026-05-16 |
| [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 |
| [Auto-PR flow: van story-DONE naar gemergde PR](./runbooks/auto-pr-flow.md) | `runbooks/auto-pr-flow.md` | active | 2026-05-06 |
| [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 |

View file

@ -20,3 +20,4 @@ last_updated: 2026-05-03
| Projectstructuur, Stores, Realtime, Job queue | [architecture/project-structure.md](./architecture/project-structure.md) |
| Sprint execution modes (PER_TASK vs SPRINT_BATCH) | [architecture/sprint-execution-modes.md](./architecture/sprint-execution-modes.md) |
| Product Backlog page — workflow & states | [architecture/product-backlog-workflow.md](./architecture/product-backlog-workflow.md) |
| Per-product documentatie (Product Docs) | [architecture/product-docs.md](./architecture/product-docs.md) |

View file

@ -0,0 +1,75 @@
---
title: "Product Docs (per-product documentatie)"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-16
applies_to: PBI-96
---
# Per-product documentatie (Product Docs)
Elk product in de app krijgt een sjabloon-documentatiestructuur met 8 kern-folders, configureerbaar per product, en beheerd via een in-app markdown-editor. Voor het volledige implementatieplan: [docs/plans/PBI-96-product-docs.md](../plans/PBI-96-product-docs.md).
## Folder-sjabloon
Identiek aan de Scrum4Me-eigen `docs/`-tree:
| Folder | Doel |
|---|---|
| `adr` | Architecture Decision Records |
| `architecture` | Topische arch-bestanden |
| `patterns` | Herbruikbare code-patronen |
| `plans` | Feature- en PBI-plannen |
| `runbooks` | Operationele procedures |
| `specs` | Functionele specificaties |
| `manual` | Developer manual |
| `api` | API-contract details |
Per product configureerbaar via `Product.enabled_doc_folders` (Postgres array van `ProductDocFolder`-enum). Default: alle 8 enabled. Toggleable in `/products/[id]/docs/settings` (owner-only).
## Datamodel
- **`ProductDoc`** ([prisma/schema.prisma:527](../../prisma/schema.prisma#L527)): `id`, `product_id`, `folder`, `slug` (uniek per `(product_id, folder)`), `title` + `status` (uit frontmatter gesynct), `content_md` (raw markdown met YAML-frontmatter), `created_by`, timestamps.
- **`ProductDocLog`**: audit-trail met `actor_user_id` (denormalized, geen FK), `type` (`CREATED|UPDATED|DELETED|FOLDER_ENABLED|FOLDER_DISABLED`), `metadata` (Json). `doc_id` is nullable + `SetNull` zodat delete-rijen overleven (zie P1-fix hieronder).
## Belangrijke design-keuzes
- **`title`/`status` als gerepliceerde kolommen** — list-queries hoeven MD niet te parsen.
- **`status` als VARCHAR i.p.v. enum** — frontmatter is user-input; geen schema-migratie bij nieuwe waarde.
- **Actor-velden denormalized (geen FK)** — minder schema-coupling voor v1; index voor toekomstige timeline-queries staat klaar. Migratie naar FK is reversible.
## Review-fixes (P1 + P2)
Verwerkt vanuit [docs/recommendations/product-docs-storage-system-review-2026-05-16.md](../recommendations/product-docs-storage-system-review-2026-05-16.md):
- **P1 (delete-audit)**`deleteProductDocAction` schrijft de DELETED-log met `doc_id: null` vóór de delete in dezelfde `$transaction`; geen FK-race.
- **P2 (frontmatter-sync)**`createProductDocAction` en `updateProductDocAction` synchroniseren `title` + `status` uit `parsed.frontmatter` naar de kolommen; `last_updated` wordt server-side overschreven via `setProductDocFrontmatterFields`.
- **Disabled-folder semantiek** — verborgen op INDEX/nav, maar directe URLs blijven leesbaar met een banner. Anti-data-loss.
## Server-actions
[actions/product-docs.ts](../../actions/product-docs.ts) implementeert 5 actions volgens `docs/patterns/server-action.md`:
| Action | Demo-403 | Owner-only | Notes |
|---|---|---|---|
| `createProductDocAction` | ✅ | nee (ProductMember ook) | P2: title/status sync, P2: last_updated normalisatie |
| `updateProductDocAction` | ✅ | nee | UPDATED-log met `prev_status`/`new_status` |
| `deleteProductDocAction` | ✅ | nee | P1: log met `doc_id:null` vóór delete |
| `toggleProductDocFolderAction` | ✅ | **ja** | folder-config = product-setting |
| `listProductDocsAction` | nee | nee | demo MAG lezen |
## UI
Routes onder `app/(app)/products/[id]/docs/`:
- `page.tsx` — INDEX-grid van enabled folders
- `settings/page.tsx` — folder-toggle UI
- `[folder]/page.tsx` — folder-listing tabel
- `[folder]/[slug]/page.tsx` — viewer/editor
Sub-navigatie via `components/products/product-subnav.tsx` (tabs: Backlog/Sprint/Solo/Docs/Instellingen).
## Editor-extractie
[components/shared/markdown-doc-editor.tsx](../../components/shared/markdown-doc-editor.tsx) is geëxtraheerd uit `idea-md-editor.tsx`. Beide Ideas en Product Docs gebruiken dezelfde editor-stack (Cmd+S, localStorage-draft, live yaml-validatie). De wrappers ([idea-md-editor.tsx](../../components/ideas/idea-md-editor.tsx), [product-doc-editor.tsx](../../components/product-docs/product-doc-editor.tsx)) injecteren de entity-specifieke validator + save-action.

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.

View file

@ -0,0 +1,63 @@
---
title: "PBI-96 demo-policy audit"
status: review
audience: [maintainer]
language: nl
last_updated: 2026-05-16
applies_to: PBI-96
---
# PBI-96 — Demo-policy audit (T-1075)
Verifieert dat de drie-laagse demo-policy (CLAUDE.md hardstop) volledig is geïmplementeerd voor de Product Docs feature. Audit-methode: grep + handmatige cross-check tegen plan §B.4.
## Laag 1 — proxy.ts
**Status: ✅ N.v.t.**
`proxy.ts` blokkeert niet-GET requests op API-paden voor demo-users. PBI-96 voegt **geen REST-routes** toe in v1 — alle mutations gaan via server-actions (Next.js `/_next/`-internals), die niet door `proxy.ts` worden gefilterd. Demo-policy laag 2 vangt dit op.
Indien in v1.1 een REST-endpoint wordt toegevoegd voor MCP-tools (`/api/product-docs/...`): demo-user wordt automatisch 403 door bestaande catch-all.
## Laag 2 — server-actions (`actions/product-docs.ts`)
**Status: ✅ Volledig**
| Action | Regel | Demo-guard regel | Verwacht gedrag |
|---|---|---|---|
| `createProductDocAction` | 64 | 69 | demo → 403 |
| `updateProductDocAction` | 170 | 176 | demo → 403 |
| `deleteProductDocAction` | 266 | 269 | demo → 403 |
| `toggleProductDocFolderAction` | 317 | 322 | demo → 403 |
| `listProductDocsAction` | 387 | (geen) | demo MAG lezen — bewust geen guard |
Alle 4 write-actions hebben `if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }` direct na de 401-check, vóór rate-limit en validatie. List-action is read-only — demo-user kan dus alle docs in toegankelijke producten lezen, conform plan §B.4.
Tests bevestigen 403-gedrag in `__tests__/actions/product-docs.test.ts` voor alle 4 write-actions.
## Laag 3 — UI
**Status: ✅ Volledig**
Alle write-knoppen zijn `<DemoTooltip show={isDemo}>`-wrapped en `disabled={isDemo}`:
| Component | Knop | Locatie | Status |
|---|---|---|---|
| `new-product-doc-dialog.tsx` | "Nieuwe doc" trigger | :160-175 | ✅ |
| `delete-product-doc-button.tsx` | "Verwijderen" trigger | :62-80 | ✅ |
| `product-doc-folder-toggle.tsx` | Folder-checkboxes (per checkbox) | :109-130 | ✅ |
| `[folder]/[slug]/page.tsx` | "Bewerken"-button | :97 (`isFolderEnabled && !isDemo`) | ✅ |
`isDemo` wordt op elke server-page geladen via `session.isDemo ?? false` en als prop doorgegeven aan de client-componenten.
## Bevindingen
Geen actie nodig. Demo-policy is volledig conform CLAUDE.md hardstop ("Demo: drie lagen — proxy.ts + server action + UI disabled knop").
## Manual e2e (uit te voeren bij review)
10-stappen smoke uit `docs/plans/PBI-96-product-docs.md` §Verificatie. Demo-stappen zijn:
- **Stap 9**: Login als demo → docs/inhoud lezen werkt; "Nieuwe doc"/"Bewerken"/"Verwijderen"/folder-toggle knoppen disabled met tooltip; directe action-call (via DevTools) → 403.
Te valideren door beheerder vóór merge. Audit is gereed.

View file

@ -0,0 +1,88 @@
---
title: "ProductDocDialog Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-16
---
# NewProductDocDialog Profiel
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
> Dit document beschrijft alleen de Product-Doc-specifieke afwijkingen — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming) staan in de generieke spec.
Plan: [PBI-96](../../plans/PBI-96-product-docs.md) §C.3 + §D.
---
## Modes
Eén mode voor v1: **`create`**. Edit/delete gebeuren via aparte routes (`?edit=1` op de detail-page → `ProductDocEditor`-wrapper; `DeleteProductDocButton`-AlertDialog).
| Mode | Trigger | Component |
|---|---|---|
| `create` | "Nieuwe doc"-knop op folder-page of `?new=1` deep-link | `NewProductDocDialog` |
| `edit` | `?edit=1` searchParam op `/[folder]/[slug]` | `ProductDocEditor` (geen modal — inline op page) |
| `delete` | "Verwijderen"-knop op detail-page | `DeleteProductDocButton` (AlertDialog) |
**Afwijking van generieke spec:** edit gebruikt geen dialog maar een inline-editor — vergelijkbaar met de keuze in IdeaDialog (markdown-editor verdient meer ruimte dan een modal).
---
## Velden (`create`)
| Veld | Type | Validatie | Bron-zod |
|---|---|---|---|
| `folder` | enum-string | one of `product.enabled_doc_folders` (UI filtert al) | `productDocCreateSchema.folder` |
| `title` | string (required) | trim, 1-200 chars | uit frontmatter → `productDocFrontmatterSchema.title` |
| `slug` | string (required) | regex `/^[a-z0-9][a-z0-9-]{0,79}$/`; uniek per (product_id, folder) | `productDocCreateSchema.slug` |
| `content_md` | string (required) | 1-100 000 chars; eerste regel `---`; frontmatter parseerbaar via `parseProductDocMd` | `productDocCreateSchema.content_md` |
### UX-details
- **Slug auto-suggest**: bij elke title-change → `slugify(title)`, totdat user de slug zelf bewerkt (`slugTouched` flag). Daarna leidend wat de user typt.
- **Starter-template**: "Gebruik {folder}-template"-knop vult `content_md` met `PRODUCT_DOC_FOLDER_DEFAULTS[folder].template` (uit `lib/schemas/product-doc-frontmatter-defaults.ts`), met `title: "..."` vervangen door de huidige titel.
- **Minimale frontmatter-wrap**: als de user geen `---` aan begin heeft staan bij submit, wordt automatisch `---\ntitle: ...\nstatus: draft\n---\n\n` toegevoegd. Voor power-users die alleen body typen.
- **Geen live-validatie in dialog**: het frontmatter wordt server-side gevalideerd via `parseProductDocMd` (422 met line-info bij fout). Voor live-validatie tijdens edit zie `ProductDocEditor` (T-1069).
---
## Server-action
- **Create**: `createProductDocAction({ product_id, folder, slug, content_md })` — zie `actions/product-docs.ts` (T-1062).
- Op success: redirect naar `/products/[id]/docs/[folder]/[slug]` via `router.push`.
### Foutmappings
| Fout | Code | UX |
|---|---|---|
| Niet ingelogd | 401 | toast.error |
| Demo-user (laag 2) | 403 | toast.error (UI laag 3 zou dit al moeten voorkomen — DemoTooltip + disabled) |
| Slug-conflict (P2002) | 422 | toast.error met "Slug X bestaat al in folder Y" |
| Folder uitgeschakeld | 422 | toast.error met "Folder X staat uit voor dit product" — UI filtert dit normaal al |
| Frontmatter parse-fail | 422 + `details` | toast.error met server-message; details zichtbaar bij volgende edit-poging |
---
## Demo-policy (drie lagen, generieke spec § 6)
| Laag | Implementatie |
|---|---|
| 1 (proxy.ts) | geen wijziging — geen REST-route in v1 |
| 2 (server-action) | `createProductDocAction` heeft `if (session.isDemo) return { error: ..., code: 403 }` aan top |
| 3 (UI) | "Nieuwe doc"-trigger is `<DemoTooltip show={isDemo}>` wrapped + `disabled={isDemo}` |
---
## Deep-link `?new=1`
Folder-card CTA op de INDEX-page linkt naar `/products/[id]/docs/[folder]?new=1`. De dialog-component leest `searchParams.get('new')` en opent automatisch via `useEffect`. Bij sluiten wordt `?new=1` uit de URL gestript via `router.replace`.
---
## Niet-doelen (v1)
- Geen rich-text-editor — alleen `<Textarea>`.
- Geen template-picker UI — alleen een knop per huidige folder.
- Geen dirty-close-guard — content gaat verloren bij Esc/buiten-klikken. Dit accepteren we omdat de user nog niets heeft opgeslagen; de generieke spec § 7 staat dit toe voor `create` mode wanneer er nog geen meaningful data is.
- Geen folder-switch-warning als de user de starter-template heeft toegepast en daarna folder wijzigt. Edge case; user kan opnieuw "Gebruik X-template" klikken.

View file

@ -548,6 +548,59 @@ De `/jobs`-pagina geeft een overzicht van alle `ClaudeJob`-records voor het acti
---
### F-15: Per-product documentatie (Product Docs)
**Prioriteit:** v1 — Productdocumentatie
**Persona:** Maker
**Plan:** [docs/plans/PBI-96-product-docs.md](../plans/PBI-96-product-docs.md)
**Omschrijving:**
Elk product krijgt een sjabloon-documentatiestructuur met 8 kern-folders (ADR, Architecture, Patterns, Plans, Runbooks, Specs, Manual, API), configureerbaar per product, beheerd via een in-app markdown-editor. Frontmatter (YAML) bevat `title` + `status` (`draft|active|deprecated|archived`).
**Acceptatiecriteria:**
#### Overzicht & navigatie
- [ ] `/products/[id]/docs` toont een grid van **enabled** folders met per folder de 3 meest recent bijgewerkte docs.
- [ ] Sub-nav (Backlog · Sprint · Solo · Docs · Instellingen) wordt op elke product-page getoond; "Docs"-tab is alleen zichtbaar als ≥1 folder enabled.
- [ ] `/products/[id]/docs/[folder]` toont een tabel (slug · title · status-badge · updated_at) gesorteerd op slug ASC.
- [ ] Disabled folders zijn niet zichtbaar op de INDEX maar blijven via directe URL leesbaar (banner toont "folder uitgeschakeld").
#### Aanmaken
- [ ] "Nieuwe doc"-knop opent een dialog met folder-select (enabled only), title, slug (auto-suggested via `slugify(title)`), en een Textarea voor markdown + YAML-frontmatter.
- [ ] "Gebruik {folder}-template"-knop vult de body met een per-folder template-string.
- [ ] Bij submit: `title`/`status` uit de parsed frontmatter worden naar de gerepliceerde kolommen geschreven; `last_updated` wordt server-side genormaliseerd naar today.
- [ ] Slug-conflict → 422 "Slug X bestaat al in folder Y".
#### Bewerken & verwijderen
- [ ] Detail-page toont viewer met frontmatter-kop + markdown body via `react-markdown`. Bij broken frontmatter: fallback met errors + raw `content_md` in een `<pre>`.
- [ ] "Bewerken"-knop opent inline-editor (Cmd/Ctrl+S → save); verborgen bij disabled folder of in demo.
- [ ] "Verwijderen"-knop opent AlertDialog; na bevestiging: doc weg, audit-log overleeft met `doc_id: null` + `metadata.{folder, slug, title}`.
#### Folder-configuratie (settings)
- [ ] `/products/[id]/docs/settings` toont 8 toggle-checkboxes; alleen owner kan wijzigen, ProductMember ziet read-only met waarschuwingstekst.
- [ ] Folder uitschakelen verwijdert **geen** bestaande docs (anti-data-loss).
#### Demo-policy (drie lagen)
- [ ] Server-actions: create/update/delete/folder-toggle blokkeren demo → 403; list mag wel.
- [ ] UI-write-knoppen zijn `DemoTooltip`-wrapped + `disabled={isDemo}`.
#### Toegang
- [ ] Eigenaar + ProductMember mogen lezen + create/update/delete docs.
- [ ] Niet-toegankelijke product → 404 (anti-enum).
**Randgevallen:**
- Frontmatter zonder `title` of `status` → 422 zonder DB-write.
- User-supplied `last_updated: 2020-01-01` → server overschrijft met today bij save.
- Folder uitgeschakeld tijdens edit-flow → server-action blijft toestaan (UI verbergt knop); data-laag dwingt geen folder-check af bij update.
---
## Navigatiestructuur
```

41
lib/product-doc-folder.ts Normal file
View file

@ -0,0 +1,41 @@
// Bidirectionele case-mapper voor de REST API-boundary van ProductDocFolder.
// DB houdt UPPER_SNAKE; API exposeert lowercase. Spiegel van lib/task-status.ts.
import type { ProductDocFolder } from '@prisma/client'
import {
PRODUCT_DOC_FOLDERS,
type ProductDocFolderApi,
} from '@/lib/schemas/product-doc'
const FOLDER_DB_TO_API = {
ADR: 'adr',
ARCHITECTURE: 'architecture',
PATTERNS: 'patterns',
PLANS: 'plans',
RUNBOOKS: 'runbooks',
SPECS: 'specs',
MANUAL: 'manual',
API: 'api',
} as const satisfies Record<ProductDocFolder, ProductDocFolderApi>
const FOLDER_API_TO_DB: Record<string, ProductDocFolder> = {
adr: 'ADR',
architecture: 'ARCHITECTURE',
patterns: 'PATTERNS',
plans: 'PLANS',
runbooks: 'RUNBOOKS',
specs: 'SPECS',
manual: 'MANUAL',
api: 'API',
}
export function productDocFolderToApi(f: ProductDocFolder): ProductDocFolderApi {
return FOLDER_DB_TO_API[f]
}
export function productDocFolderFromApi(s: string): ProductDocFolder | null {
return FOLDER_API_TO_DB[s.toLowerCase()] ?? null
}
export const PRODUCT_DOC_FOLDER_API_VALUES = PRODUCT_DOC_FOLDERS

View file

@ -0,0 +1,59 @@
// Server-side serializer die individuele frontmatter-velden bijwerkt in
// een al-gevalideerde markdown-doc. P2-review-fix uit
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md
// (last_updated moet door de server worden gezet, niet door de user).
//
// Caller MOET parseProductDocMd al hebben aangeroepen voor pre-validatie
// — deze functie throwed bij parse-fouten.
import { parseDocument } from 'yaml'
const FRONTMATTER_RE =
/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/
/**
* Mutates de YAML-frontmatter van `md` met de gegeven `patch`-keys
* (bv. `{ last_updated: '2026-05-16' }`) en geeft de nieuwe markdown
* terug. Behoudt body en frontmatter-delimiters; overige velden blijven
* staan (best-effort op ordering en whitespace via yaml-lib).
*/
export function setProductDocFrontmatterFields(
md: string,
patch: Record<string, unknown>,
): string {
const match = md.match(FRONTMATTER_RE)
if (!match) {
throw new Error(
'setProductDocFrontmatterFields: input mist yaml-frontmatter (geen `---` opener gevonden)',
)
}
const [, openMarker, frontmatterRaw, closeMarker, body] = match
const doc = parseDocument(frontmatterRaw)
if (doc.errors.length > 0) {
throw new Error(
`setProductDocFrontmatterFields: yaml parse-error op regel ${
doc.errors[0].linePos?.[0]?.line ?? '?'
}: ${doc.errors[0].message}`,
)
}
for (const [key, value] of Object.entries(patch)) {
doc.set(key, value)
}
// yaml.Document.toString() voegt vaak een trailing newline toe —
// strippen voorkomt dubbele newlines vóór de afsluitende `---`.
const newFrontmatter = doc.toString().replace(/\r?\n$/, '')
return `${openMarker}${newFrontmatter}${closeMarker}${body}`
}
/**
* ISO-date (yyyy-mm-dd) van vandaag handige helper voor de server om
* `last_updated` mee te zetten bij elke save.
*/
export function todayIsoDate(now: Date = new Date()): string {
return now.toISOString().slice(0, 10)
}

74
lib/product-doc-parser.ts Normal file
View file

@ -0,0 +1,74 @@
// Parser voor de product_doc markdown. Format: yaml-frontmatter (gevalideerd
// via productDocFrontmatterSchema) + markdown-body. Synchroon — geen LLM.
//
// Wordt door alle Product Doc server-actions (create/update) gebruikt om
// frontmatter te valideren vóór save. Bij parse-fout: 422 met line-info.
// Pattern gespiegeld uit lib/idea-plan-parser.ts.
import { parse as parseYaml, YAMLParseError } from 'yaml'
import {
productDocFrontmatterSchema,
type ProductDocFrontmatter,
} from '@/lib/schemas/product-doc'
export type ProductDocParseError = {
line?: number
message: string
hint?: string
}
export type ProductDocParseResult =
| { ok: true; frontmatter: ProductDocFrontmatter; body: string }
| { ok: false; errors: ProductDocParseError[] }
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
export function parseProductDocMd(md: string): ProductDocParseResult {
const match = md.match(FRONTMATTER_RE)
if (!match) {
return {
ok: false,
errors: [
{
line: 1,
message:
'Doc mist yaml-frontmatter. Eerste regel moet `---` zijn, gevolgd door de frontmatter en een afsluitende `---`.',
},
],
}
}
const [, frontmatterRaw, body] = match
let parsed: unknown
try {
parsed = parseYaml(frontmatterRaw)
} catch (err) {
if (err instanceof YAMLParseError) {
const yamlLine = err.linePos?.[0]?.line
// +1 voor de openende `---`-regel (frontmatterRaw start op regel 2)
const fileLine = yamlLine != null ? yamlLine + 1 : undefined
return {
ok: false,
errors: [{ line: fileLine, message: err.message }],
}
}
return {
ok: false,
errors: [{ message: err instanceof Error ? err.message : String(err) }],
}
}
const validation = productDocFrontmatterSchema.safeParse(parsed)
if (!validation.success) {
return {
ok: false,
errors: validation.error.issues.map((iss) => ({
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
})),
}
}
return { ok: true, frontmatter: validation.data, body: body.trimStart() }
}

78
lib/product-doc-slug.ts Normal file
View file

@ -0,0 +1,78 @@
// Slug-helpers voor ProductDoc. Pure functies — geen DB-koppeling.
// Caller (server-action) doet de DB-query om `existing` slugs te leveren.
const MAX_SLUG_LEN = 80
/**
* Genereert een URL-safe slug uit een titel. Diakritieken worden gestript,
* alles buiten [a-z0-9-] wordt vervangen door `-`, en het resultaat wordt
* gecapped op MAX_SLUG_LEN tekens.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG_LEN)
.replace(/-+$/, '')
}
/**
* Suggesteert een unieke slug door, bij conflict, een numeriek suffix
* (`-2`, `-3`, ...) toe te voegen. Returns '' als de titel leeg slugified.
*
* Caller is verantwoordelijk voor het leveren van bestaande slugs binnen
* dezelfde (product_id, folder)-scope.
*/
export function suggestSlug(title: string, existing: readonly string[]): string {
const base = slugify(title)
if (!base) return ''
if (!existing.includes(base)) return base
for (let n = 2; n <= 999; n++) {
const suffix = `-${n}`
const trimmed = base.slice(0, MAX_SLUG_LEN - suffix.length).replace(/-+$/, '')
const candidate = `${trimmed}${suffix}`
if (!existing.includes(candidate)) return candidate
}
throw new Error('Te veel slug-collisions; kies een andere titel')
}
const ADR_PREFIX_RE = /^(\d{4})-/
/**
* Geeft het volgende ADR-nummer als 4-cijferige string (`'0042'`).
* `currentMax` is het hoogste reeds gebruikte ADR-nummer binnen deze
* (product_id, folder='adr')-scope, of `null` als er nog geen ADRs zijn
* (eerste = `'0001'`).
*/
export function nextAdrPrefix(currentMax: number | null): string {
const next = (currentMax ?? 0) + 1
return next.toString().padStart(4, '0')
}
/**
* Parseert het ADR-volgnummer uit een slug die het `NNNN-...` pattern
* volgt. Returns `null` voor slugs zonder geldig prefix.
*/
export function parseAdrNumber(slug: string): number | null {
const m = ADR_PREFIX_RE.exec(slug)
if (!m) return null
const n = parseInt(m[1], 10)
return Number.isFinite(n) ? n : null
}
/**
* Bouwt een ADR-slug `NNNN-{slugified-title}` waarbij `NNNN` volgt op
* `currentMax`. Bij lege titel: alleen het prefix.
*/
export function suggestAdrSlug(title: string, currentMax: number | null): string {
const prefix = nextAdrPrefix(currentMax)
const titleSlug = slugify(title)
if (!titleSlug) return prefix
const maxTitleLen = MAX_SLUG_LEN - prefix.length - 1
return `${prefix}-${titleSlug.slice(0, maxTitleLen).replace(/-+$/, '')}`
}

View file

@ -0,0 +1,46 @@
// Server-only helpers voor de Product Docs server-actions. Bevat
// prisma-toegang en mag NIET in client-componenten worden geïmporteerd
// (zie CLAUDE.md hardstop "Server/client grens").
//
// Plan: docs/plans/PBI-96-product-docs.md §B.2.
import 'server-only'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { productDocFolderFromApi } from '@/lib/product-doc-folder'
import type { ProductDocFolder } from '@prisma/client'
/**
* Laadt het Product gescoped op `productAccessFilter(userId)` voor een
* gegeven product_id. Returnt alleen de velden die de write-actions
* nodig hebben (`id`, eigenaar, folder-config). Voor folder-toggle
* gebruikt de owner-only-check direct `where: { id, user_id }` zodat
* ProductMember de folder-config niet kan wijzigen.
*
* Returnt `null` als de user geen access heeft caller stuurt dan
* 404 (anti-enum, géén 403).
*/
export async function loadAccessibleProduct(productId: string, userId: string) {
return prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(userId) },
select: { id: true, user_id: true, enabled_doc_folders: true },
})
}
/**
* Converteert een API-folder-string (`'runbooks'`) naar het Prisma-enum
* (`'RUNBOOKS'`). Throwed bij onbekende waarden server-actions hebben
* de input al via zod gevalideerd, dus dit dient alleen als type-narrowing
* vangnet.
*/
export function folderApiToDbOrThrow(folderApi: string): ProductDocFolder {
const db = productDocFolderFromApi(folderApi)
if (!db) {
throw new Error(
`Internal: folderApiToDbOrThrow ontving onbekende folder "${folderApi}" — zod-validatie zou dit niet door moeten laten`,
)
}
return db
}

View file

@ -33,6 +33,10 @@ const CONFIGS: Record<string, RateLimitConfig> = {
'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers
'materialize-idea': { windowMs: 60_000, max: 5 },
'create-user-question': { windowMs: 60_000, max: 20 }, // PLAN_CHAT vragen
// PBI-96 — Per-product Product Docs (zie docs/plans/PBI-96-product-docs.md)
'create-product-doc': { windowMs: 60_000, max: 30 },
'edit-product-doc': { windowMs: 60_000, max: 60 },
}
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }

View file

@ -0,0 +1,70 @@
// Per-folder template-strings voor het "Nieuwe doc"-dialog. UI-only:
// server gebruikt deze niet. `last_updated` wordt bij save door de server
// gezet (zie setProductDocFrontmatterFields), dus laten we het hier weg.
import type { ProductDocFolderApi } from '@/lib/schemas/product-doc'
export interface ProductDocFolderTemplate {
hint: string
template: string
}
function template(body: string): string {
return `---
title: "..."
status: draft
audience: [contributor]
---
${body}`
}
export const PRODUCT_DOC_FOLDER_DEFAULTS: Record<
ProductDocFolderApi,
ProductDocFolderTemplate
> = {
adr: {
hint: 'Context → Beslissing → Gevolgen. Gebruik NNNN-{slug} naamgeving (auto-suggested).',
template: template(
`## Context\n\nWelke situatie of vraag triggerde deze beslissing?\n\n## Beslissing\n\nWat is besloten en waarom?\n\n## Gevolgen\n\nWelke trade-offs neemt het team hiermee?\n`,
),
},
architecture: {
hint: 'Topisch overzicht van een component of subsysteem.',
template: template(
`## Overzicht\n\nWat is het doel van deze component?\n\n## Datastromen\n\nWelke data komt binnen, wat gebeurt ermee?\n\n## Aandachtspunten\n\n- ...\n`,
),
},
patterns: {
hint: 'Herbruikbaar code-pattern met voorbeeld.',
template: template(
`## Wanneer toepassen\n\n## Voorbeeld\n\n\`\`\`ts\n// ...\n\`\`\`\n\n## Valkuilen\n\n- ...\n`,
),
},
plans: {
hint: 'Feature/PBI-plan met scope, breakdown en verificatie.',
template: template(`## Context\n\n## Scope\n\n## Breakdown\n\n## Verificatie\n`),
},
runbooks: {
hint: 'Operationele procedure of incident-flow.',
template: template(
`## Wanneer gebruiken\n\n## Stappen\n\n1. ...\n\n## Verificatie\n\n## Troubleshooting\n`,
),
},
specs: {
hint: 'Functionele specificatie met acceptatiecriteria.',
template: template(
`## Doel\n\n## User flow\n\n## Acceptatiecriteria\n\n- [ ] ...\n`,
),
},
manual: {
hint: 'Onderdeel van de gebruikers- of developer-manual.',
template: template(`## Inleiding\n\n## Stappen\n\n## Veelgestelde vragen\n`),
},
api: {
hint: 'API-endpoint of contract-detail.',
template: template(
`## Endpoint\n\n\`POST /api/...\`\n\n## Request\n\n## Response\n\n## Fouten\n`,
),
},
}

View file

@ -0,0 +1,82 @@
import { z } from 'zod'
// API-laag werkt met lowercase folder-namen en lowercase statuses.
// DB-mapping (UPPER_SNAKE) leeft in `lib/product-doc-folder.ts`.
export const PRODUCT_DOC_FOLDERS = [
'adr',
'architecture',
'patterns',
'plans',
'runbooks',
'specs',
'manual',
'api',
] as const
export const PRODUCT_DOC_STATUSES = [
'draft',
'active',
'deprecated',
'archived',
] as const
export const productDocFolderSchema = z.enum(PRODUCT_DOC_FOLDERS)
export const productDocStatusSchema = z.enum(PRODUCT_DOC_STATUSES)
// Slugs zijn URL-safe: kleine letters, cijfers, koppeltekens; begint met
// letter/cijfer, max 80 tekens (matcht @db.VarChar(80) in schema.prisma).
export const productDocSlugSchema = z
.string()
.regex(
/^[a-z0-9][a-z0-9-]{0,79}$/,
'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten (1-80 tekens, niet starten met streepje)',
)
// Maximum body-grootte gelijk aan `Idea.plan_md` (lib/schemas/idea.ts).
export const MAX_PRODUCT_DOC_CONTENT_LEN = 100_000
// Frontmatter dat in `content_md` als YAML-block staat. `last_updated` is
// optional omdat de server hem bij elke save normaliseert (P2-review-fix);
// `title` en `status` worden bij save naar de gerepliceerde kolommen
// gepushed (zie createProductDocAction in actions/product-docs.ts).
export const productDocFrontmatterSchema = z.object({
title: z
.string()
.min(1, 'Titel is verplicht')
.max(200, 'Maximaal 200 tekens'),
status: productDocStatusSchema,
audience: z.union([z.string(), z.array(z.string())]).optional(),
applies_to: z.union([z.string(), z.array(z.string())]).optional(),
last_updated: z.string().optional(),
})
export const productDocCreateSchema = z.object({
product_id: z.string().cuid('Ongeldig product'),
folder: productDocFolderSchema,
slug: productDocSlugSchema,
content_md: z
.string()
.min(1, 'Inhoud is verplicht')
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
})
export const productDocUpdateSchema = z.object({
content_md: z
.string()
.min(1, 'Inhoud is verplicht')
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
})
export const productDocFolderToggleSchema = z.object({
product_id: z.string().cuid('Ongeldig product'),
folder: productDocFolderSchema,
enabled: z.boolean(),
})
export type ProductDocFolderApi = z.infer<typeof productDocFolderSchema>
export type ProductDocStatusApi = z.infer<typeof productDocStatusSchema>
export type ProductDocFrontmatter = z.infer<typeof productDocFrontmatterSchema>
export type ProductDocCreateInput = z.infer<typeof productDocCreateSchema>
export type ProductDocUpdateInput = z.infer<typeof productDocUpdateSchema>
export type ProductDocFolderToggleInput = z.infer<typeof productDocFolderToggleSchema>

View file

@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "ProductDocFolder" AS ENUM ('ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API');
-- CreateEnum
CREATE TYPE "ProductDocLogType" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'FOLDER_ENABLED', 'FOLDER_DISABLED');
-- AlterTable
ALTER TABLE "products" ADD COLUMN "enabled_doc_folders" "ProductDocFolder"[] NOT NULL DEFAULT ARRAY['ADR', 'ARCHITECTURE', 'PATTERNS', 'PLANS', 'RUNBOOKS', 'SPECS', 'MANUAL', 'API']::"ProductDocFolder"[];
-- CreateTable
CREATE TABLE "product_docs" (
"id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"folder" "ProductDocFolder" NOT NULL,
"slug" VARCHAR(80) NOT NULL,
"title" VARCHAR(200) NOT NULL,
"content_md" TEXT NOT NULL,
"status" VARCHAR(20) NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "product_docs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_doc_logs" (
"id" TEXT NOT NULL,
"product_id" TEXT NOT NULL,
"doc_id" TEXT,
"actor_user_id" TEXT NOT NULL,
"type" "ProductDocLogType" NOT NULL,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "product_doc_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "product_docs_product_id_folder_slug_key" ON "product_docs"("product_id", "folder", "slug");
-- CreateIndex
CREATE INDEX "product_docs_product_id_folder_updated_at_idx" ON "product_docs"("product_id", "folder", "updated_at");
-- CreateIndex
CREATE INDEX "product_docs_product_id_status_idx" ON "product_docs"("product_id", "status");
-- CreateIndex
CREATE INDEX "product_doc_logs_product_id_created_at_idx" ON "product_doc_logs"("product_id", "created_at");
-- CreateIndex
CREATE INDEX "product_doc_logs_doc_id_created_at_idx" ON "product_doc_logs"("doc_id", "created_at");
-- CreateIndex
CREATE INDEX "product_doc_logs_actor_user_id_created_at_idx" ON "product_doc_logs"("actor_user_id", "created_at");
-- AddForeignKey
ALTER TABLE "product_docs" ADD CONSTRAINT "product_docs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_doc_logs" ADD CONSTRAINT "product_doc_logs_doc_id_fkey" FOREIGN KEY ("doc_id") REFERENCES "product_docs"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -138,35 +138,54 @@ enum UserQuestionStatus {
answered
}
enum ProductDocFolder {
ADR
ARCHITECTURE
PATTERNS
PLANS
RUNBOOKS
SPECS
MANUAL
API
}
enum ProductDocLogType {
CREATED
UPDATED
DELETED
FOLDER_ENABLED
FOLDER_DISABLED
}
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
settings Json @default("{}")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
settings Json @default("{}")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
ideas Idea[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
push_subscriptions PushSubscription[]
@@index([active_product_id])
@ -199,32 +218,35 @@ model ApiToken {
}
model Product {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
idea_products IdeaProduct[]
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
enabled_doc_folders ProductDocFolder[] @default([ADR, ARCHITECTURE, PATTERNS, PLANS, RUNBOOKS, SPECS, MANUAL, API])
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
idea_products IdeaProduct[]
docs ProductDoc[]
doc_logs ProductDocLog[]
@@unique([user_id, name])
@@unique([user_id, code])
@ -388,47 +410,47 @@ model Task {
}
model ClaudeJob {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
sprint_run_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
lease_until DateTime?
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
sprint_run_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
lease_until DateTime?
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, status])
@@index([task_id, status])
@ -515,6 +537,43 @@ model ProductMember {
@@map("product_members")
}
model ProductDoc {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
folder ProductDocFolder
slug String @db.VarChar(80)
title String @db.VarChar(200)
content_md String @db.Text
status String @db.VarChar(20)
created_by String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
logs ProductDocLog[]
@@unique([product_id, folder, slug])
@@index([product_id, folder, updated_at])
@@index([product_id, status])
@@map("product_docs")
}
model ProductDocLog {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
doc ProductDoc? @relation(fields: [doc_id], references: [id], onDelete: SetNull)
doc_id String?
actor_user_id String
type ProductDocLogType
metadata Json?
created_at DateTime @default(now())
@@index([product_id, created_at])
@@index([doc_id, created_at])
@@index([actor_user_id, created_at])
@@map("product_doc_logs")
}
model Idea {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@ -526,8 +585,8 @@ model Idea {
description String? @db.VarChar(4000)
grill_md String? @db.Text
plan_md String? @db.Text
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
reviewed_at DateTime? // When last reviewed
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
reviewed_at DateTime? // When last reviewed
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
pbi_id String? @unique
status IdeaStatus @default(DRAFT)