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
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:
commit
9fc15f279a
42 changed files with 5043 additions and 234 deletions
524
__tests__/actions/product-docs.test.ts
Normal file
524
__tests__/actions/product-docs.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal file
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal file
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
141
__tests__/lib/product-doc-parser.test.ts
Normal file
141
__tests__/lib/product-doc-parser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
111
__tests__/lib/product-doc-slug.test.ts
Normal file
111
__tests__/lib/product-doc-slug.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
160
__tests__/lib/schemas/product-doc.test.ts
Normal file
160
__tests__/lib/schemas/product-doc.test.ts
Normal 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
427
actions/product-docs.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
158
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal file
158
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal 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 ‘Bewerken’ 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>
|
||||
)
|
||||
}
|
||||
100
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal file
100
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
app/(app)/products/[id]/docs/page.tsx
Normal file
73
app/(app)/products/[id]/docs/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
app/(app)/products/[id]/docs/settings/page.tsx
Normal file
56
app/(app)/products/[id]/docs/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
104
components/product-docs/delete-product-doc-button.tsx
Normal file
104
components/product-docs/delete-product-doc-button.tsx
Normal 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>
|
||||
“{docTitle}” 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>
|
||||
)
|
||||
}
|
||||
44
components/product-docs/disabled-folder-banner.tsx
Normal file
44
components/product-docs/disabled-folder-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
285
components/product-docs/new-product-doc-dialog.tsx
Normal file
285
components/product-docs/new-product-doc-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
44
components/product-docs/product-doc-editor.tsx
Normal file
44
components/product-docs/product-doc-editor.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
142
components/product-docs/product-doc-folder-toggle.tsx
Normal file
142
components/product-docs/product-doc-folder-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
components/product-docs/product-doc-status-badge.tsx
Normal file
43
components/product-docs/product-doc-status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
components/product-docs/product-doc-viewer.tsx
Normal file
78
components/product-docs/product-doc-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
components/product-docs/product-docs-folder-card.tsx
Normal file
86
components/product-docs/product-docs-folder-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
components/product-docs/product-docs-folder-list.tsx
Normal file
94
components/product-docs/product-docs-folder-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
components/product-docs/product-docs-index.tsx
Normal file
114
components/product-docs/product-docs-index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
components/products/product-subnav.tsx
Normal file
98
components/products/product-subnav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
222
components/shared/markdown-doc-editor.tsx
Normal file
222
components/shared/markdown-doc-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
75
docs/architecture/product-docs.md
Normal file
75
docs/architecture/product-docs.md
Normal 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.
|
||||
584
docs/plans/PBI-96-product-docs.md
Normal file
584
docs/plans/PBI-96-product-docs.md
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
---
|
||||
title: "Per-product documentatiestructuur (Product Docs)"
|
||||
status: planned
|
||||
audience: implementation
|
||||
language: nl
|
||||
last_updated: 2026-05-16
|
||||
version: 2
|
||||
applies_to: scrum4me-app
|
||||
pbi: PBI-96
|
||||
source_review: docs/recommendations/product-docs-storage-system-review-2026-05-16.md
|
||||
---
|
||||
|
||||
# Per-product documentatie — Implementatieplan (v2, post-review)
|
||||
|
||||
## Context
|
||||
|
||||
Scrum4Me onderhoudt zelf een rijke `docs/`-tree met 8 kern-folders (`adr/`, `architecture/`, `patterns/`, `plans/`, `runbooks/`, `specs/`, `manual/`, `api/`), YAML-frontmatter-conventies en een auto-gegenereerde `INDEX.md`. Producten **in** de app hebben momenteel alléén `Product.description` (single-string) en `definition_of_done` — geen plek voor structurele product-documentatie.
|
||||
|
||||
Doel: elk product in de app krijgt dezelfde 8-folder-structuur als sjabloon, **per product configureerbaar** (folders aan/uit) en beheerd via een in-app markdown-editor, gemodelleerd naar het bestaande Ideas-pattern (`Idea.grill_md`/`plan_md` + `IdeaLog`).
|
||||
|
||||
**Vastgelegde keuzes (gebruiker):**
|
||||
1. Storage: DB-tabel `product_docs` (geen filesystem/git)
|
||||
2. Sjabloon: Kern-8 default, **per product configureerbaar** via folder-toggles
|
||||
3. Beheer: in-app markdown-editor (hergebruik van `idea-md-editor` patroon)
|
||||
|
||||
**Out of scope (v1):** filesystem/git-koppeling, auto-commit naar `repo_url`, WYSIWYG, full-text-search, comments/threads, page-versies (alleen audit-log), file-uploads/attachments, MCP-tools, REST-API.
|
||||
|
||||
## Wijzigingen v1 → v2 (na review)
|
||||
|
||||
Verwerkt vanuit [docs/recommendations/product-docs-storage-system-review-2026-05-16.md](docs/recommendations/product-docs-storage-system-review-2026-05-16.md):
|
||||
|
||||
| # | Review | Verwerking |
|
||||
|---|---|---|
|
||||
| P1 | Delete-audit FK-probleem | `deleteProductDocAction` pipeline volledig herschreven (§B.2): eerst metadata ophalen, dan `$transaction` met log-rij die `doc_id: null` schrijft + delete. Geen FK-probleem, geen interactieve transaction nodig. |
|
||||
| P2 | Create vult `title`/`status` niet expliciet | `createProductDocAction` (§B.2) maakt expliciet: `parsed = parseProductDocMd(content_md)`; `title` en `status` worden uit `parsed.frontmatter` naar de kolommen geschreven; ontbrekende frontmatter-velden → 422. |
|
||||
| P2 | `last_updated` zonder serializer | Nieuwe helper `lib/product-doc-frontmatter.ts` met `setProductDocFrontmatterFields(md, patch)`. Server overschrijft `last_updated` in de **opgeslagen** `content_md` zelf. Documented in §B.1 + §B.2. |
|
||||
| P2 | Disabled folder-semantiek onduidelijk | **Expliciete keuze** (§C.4): "verborgen in INDEX/nav, maar directe URL leesbaar (read-only-banner)". Geen 404. Anti-data-loss + anti-frustratie. |
|
||||
| P3 | Audit-actor relationeel contract | Expliciete keuze (§A.3): **denormalized string-id** voor v1 + `@@index([actor_user_id, created_at])` voor toekomstige actor-timelines. Geen FK naar User. |
|
||||
|
||||
---
|
||||
|
||||
## Architectuur
|
||||
|
||||
### A. Datamodel (Prisma)
|
||||
|
||||
#### A.1 Nieuwe enums
|
||||
|
||||
```prisma
|
||||
enum ProductDocFolder {
|
||||
ADR
|
||||
ARCHITECTURE
|
||||
PATTERNS
|
||||
PLANS
|
||||
RUNBOOKS
|
||||
SPECS
|
||||
MANUAL
|
||||
API
|
||||
}
|
||||
|
||||
enum ProductDocLogType {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
FOLDER_ENABLED
|
||||
FOLDER_DISABLED
|
||||
}
|
||||
```
|
||||
|
||||
UPPER_SNAKE volgens CLAUDE.md hardstop ("Enum: DB UPPER_SNAKE ↔ API lowercase"). API-mapping in `lib/product-doc-folder.ts` (spiegelt `lib/task-status.ts`).
|
||||
|
||||
#### A.2 `Product`-uitbreiding ([prisma/schema.prisma:201](prisma/schema.prisma:201))
|
||||
|
||||
Toevoegen in het `Product`-model (rond regel 215):
|
||||
|
||||
```prisma
|
||||
enabled_doc_folders ProductDocFolder[] @default([ADR, ARCHITECTURE, PATTERNS, PLANS, RUNBOOKS, SPECS, MANUAL, API])
|
||||
docs ProductDoc[]
|
||||
doc_logs ProductDocLog[]
|
||||
```
|
||||
|
||||
Postgres-array van het enum-type — geen aparte join-tabel voor config die zelden muteert.
|
||||
|
||||
#### A.3 Nieuwe modellen
|
||||
|
||||
```prisma
|
||||
model ProductDoc {
|
||||
id String @id @default(cuid())
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
folder ProductDocFolder
|
||||
slug String @db.VarChar(80)
|
||||
title String @db.VarChar(200) // gesynct uit frontmatter bij elke save
|
||||
content_md String @db.Text
|
||||
status String @db.VarChar(20) // uit frontmatter; VARCHAR i.p.v. enum (user-input)
|
||||
created_by String // denormalized user-id (zie §P3-keuze)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
logs ProductDocLog[]
|
||||
|
||||
@@unique([product_id, folder, slug])
|
||||
@@index([product_id, folder, updated_at])
|
||||
@@index([product_id, status])
|
||||
@@map("product_docs")
|
||||
}
|
||||
|
||||
model ProductDocLog {
|
||||
id String @id @default(cuid())
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
doc ProductDoc? @relation(fields: [doc_id], references: [id], onDelete: SetNull)
|
||||
doc_id String?
|
||||
actor_user_id String // denormalized — geen FK (zie P3-keuze)
|
||||
type ProductDocLogType
|
||||
metadata Json? // { folder, slug, title, length, prev_status, ... }
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@index([product_id, created_at])
|
||||
@@index([doc_id, created_at])
|
||||
@@index([actor_user_id, created_at]) // voor toekomstige per-actor-timeline-queries
|
||||
@@map("product_doc_logs")
|
||||
}
|
||||
```
|
||||
|
||||
**Designkeuzes (incl. P3-resolutie):**
|
||||
- `slug` uniek per `(product_id, folder)` — zelfde slug mag in twee folders (bv. `runbooks/deploy` + `manual/deploy`).
|
||||
- `title` + `status` als gerepliceerde kolommen — list-queries hoeven MD niet te parsen.
|
||||
- `status` als VARCHAR i.p.v. enum: frontmatter is user-input. Kanonieke set leeft in Zod.
|
||||
- **Actor-velden zijn denormalized strings, geen FK** (P3-keuze). Reden: minder schema-coupling voor v1; ProductDocLog hoeft niet te joinen voor de meeste views; bij user-deletion blijven audit-rijen leesbaar zonder cascade-config. Migratie naar FK is reversible (kolom blijft string).
|
||||
- `@@index([actor_user_id, created_at])` reserveert query-pad voor v1.1 (audit-timeline per actor).
|
||||
- `doc_id` nullable + `SetNull` → ProductDocLog overleeft delete (zie P1-pipeline §B.2).
|
||||
|
||||
#### A.4 Migratie
|
||||
|
||||
Eén migration `prisma/migrations/<ts>_add_product_docs/migration.sql`:
|
||||
1. `CREATE TYPE "ProductDocFolder"` (8 values)
|
||||
2. `CREATE TYPE "ProductDocLogType"` (5 values)
|
||||
3. `ALTER TABLE products ADD COLUMN enabled_doc_folders "ProductDocFolder"[] NOT NULL DEFAULT ARRAY[...]::"ProductDocFolder"[]`
|
||||
4. `CREATE TABLE product_docs` + 3 indexes
|
||||
5. `CREATE TABLE product_doc_logs` + 3 indexes (incl. actor-index)
|
||||
|
||||
**Geen backfill** nodig — DB-DEFAULT vult bestaande rijen automatisch.
|
||||
|
||||
---
|
||||
|
||||
### B. Server-laag
|
||||
|
||||
#### B.1 Schemas + helpers (nieuw)
|
||||
|
||||
| Bestand | Verantwoordelijkheid |
|
||||
|---|---|
|
||||
| `lib/schemas/product-doc.ts` | `productDocCreateSchema`, `productDocUpdateSchema`, `productDocFolderToggleSchema`, `productDocFrontmatterSchema` (Zod) |
|
||||
| `lib/product-doc-folder.ts` | DB-enum ↔ API-string mapping; spiegelt [lib/task-status.ts](lib/task-status.ts) |
|
||||
| `lib/product-doc-parser.ts` | `parseProductDocMd(md): { ok: true, frontmatter, body } \| { ok: false, errors }`; hergebruikt yaml-parser + error-shape uit [lib/idea-plan-parser.ts](lib/idea-plan-parser.ts) |
|
||||
| `lib/product-doc-frontmatter.ts` | **`setProductDocFrontmatterFields(md, patch): string`** — parseert YAML-frontmatter, merget `patch`-keys (bv. `{last_updated: '2026-05-16'}`), serialiseert terug naar markdown. Whitespace + ordering best-effort behouden. Throws bij parse-fout (caller heeft al gevalideerd). |
|
||||
| `lib/product-doc-slug.ts` | `slugify(title)` + `suggestSlug(title, folder, existing)` met dedupe-suffix `-2`/`-3` en ADR-sequence-helper |
|
||||
| `lib/schemas/product-doc-frontmatter-defaults.ts` | Per-folder template-strings voor "Nieuwe doc"-dialog (geen server-gebruik) |
|
||||
|
||||
Kern-zod (samengevat):
|
||||
```ts
|
||||
export const PRODUCT_DOC_FOLDERS = ['adr','architecture','patterns','plans','runbooks','specs','manual','api'] as const
|
||||
export const PRODUCT_DOC_STATUSES = ['draft','active','deprecated','archived'] as const
|
||||
|
||||
productDocFrontmatterSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(PRODUCT_DOC_STATUSES),
|
||||
audience: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
applies_to: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
last_updated: z.string().optional(), // server overschrijft via setProductDocFrontmatterFields
|
||||
})
|
||||
|
||||
productDocCreateSchema = z.object({
|
||||
product_id: z.string().cuid(),
|
||||
folder: z.enum(PRODUCT_DOC_FOLDERS),
|
||||
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/),
|
||||
content_md: z.string().min(1).max(100_000),
|
||||
})
|
||||
```
|
||||
|
||||
#### B.2 Server-actions — `actions/product-docs.ts`
|
||||
|
||||
Strikt volgens [docs/patterns/server-action.md](docs/patterns/server-action.md) en het patroon uit [actions/ideas.ts:232-313](actions/ideas.ts:232). **Pipelines hieronder zijn definitief en verwerken alle review-punten (P1, P2-create, P2-last_updated).**
|
||||
|
||||
##### `createProductDocAction(input)` — verwerkt P2-create + P2-last_updated
|
||||
|
||||
```
|
||||
1. session.userId → 401
|
||||
2. session.isDemo → 403
|
||||
3. rate-limit `create-product-doc`
|
||||
4. Zod parse(productDocCreateSchema, input) → 422
|
||||
5. parsed = parseProductDocMd(input.content_md) → 422 met line-info indien fail
|
||||
6. loadAccessibleProduct(input.product_id, session.userId) → 404
|
||||
7. product.enabled_doc_folders.includes(folder) → 422 'folder uitgeschakeld'
|
||||
8. content_md_normalized = setProductDocFrontmatterFields(
|
||||
input.content_md,
|
||||
{ last_updated: today() } // ISO yyyy-mm-dd, server-side
|
||||
)
|
||||
9. $transaction([
|
||||
prisma.productDoc.create({
|
||||
data: {
|
||||
product_id, folder, slug,
|
||||
title: parsed.frontmatter.title, // expliciet uit frontmatter (P2)
|
||||
status: parsed.frontmatter.status, // idem
|
||||
content_md: content_md_normalized, // server-genormaliseerd (P2)
|
||||
created_by: session.userId,
|
||||
},
|
||||
}),
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id, doc_id: <result of create>, actor_user_id: session.userId,
|
||||
type: 'CREATED', metadata: { folder, slug, title, length: content_md_normalized.length },
|
||||
},
|
||||
}),
|
||||
])
|
||||
── op P2002 (slug-uniciteit) → 422 'slug bestaat al in folder'
|
||||
10. revalidatePath /products/[id]/docs + /products/[id]/docs/[folder]
|
||||
```
|
||||
|
||||
> Implementatienoot: stap 9's tweede statement heeft de id van de eerste nodig. Gebruik `prisma.$transaction(async tx => { const doc = await tx.productDoc.create(...); await tx.productDocLog.create({ data: { doc_id: doc.id, ...} }) })` (interactieve transaction) i.p.v. array — bekend Prisma-idiom.
|
||||
|
||||
##### `updateProductDocAction(id, content_md)` — verwerkt P2-create-equiv + P2-last_updated
|
||||
|
||||
```
|
||||
1. session.userId → 401
|
||||
2. session.isDemo → 403
|
||||
3. rate-limit `edit-product-doc`
|
||||
4. Zod parse(productDocUpdateSchema, {content_md}) → 422
|
||||
5. existing = prisma.productDoc.findFirst({
|
||||
where: { id, product: productAccessFilter(session.userId) },
|
||||
select: { id, product_id, status }
|
||||
}) → 404
|
||||
6. parsed = parseProductDocMd(content_md) → 422
|
||||
7. content_md_normalized = setProductDocFrontmatterFields(
|
||||
content_md, { last_updated: today() }
|
||||
)
|
||||
8. $transaction([
|
||||
prisma.productDoc.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: parsed.frontmatter.title, // sync uit frontmatter
|
||||
status: parsed.frontmatter.status, // sync uit frontmatter
|
||||
content_md: content_md_normalized,
|
||||
},
|
||||
}),
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: existing.product_id, doc_id: id, actor_user_id: session.userId,
|
||||
type: 'UPDATED', metadata: { length: content_md_normalized.length, prev_status: existing.status, new_status: parsed.frontmatter.status },
|
||||
},
|
||||
}),
|
||||
])
|
||||
9. revalidatePath index + folder + detail-route
|
||||
```
|
||||
|
||||
##### `deleteProductDocAction(id)` — **verwerkt P1 (delete-audit FK)**
|
||||
|
||||
```
|
||||
1. session.userId → 401
|
||||
2. session.isDemo → 403
|
||||
3. existing = prisma.productDoc.findFirst({
|
||||
where: { id, product: productAccessFilter(session.userId) },
|
||||
select: { id, product_id, folder, slug, title } // metadata vóór delete (P1)
|
||||
}) → 404
|
||||
4. $transaction([
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: existing.product_id,
|
||||
doc_id: null, // null vanaf het begin (P1)
|
||||
actor_user_id: session.userId,
|
||||
type: 'DELETED',
|
||||
metadata: { folder: existing.folder, slug: existing.slug, title: existing.title },
|
||||
},
|
||||
}),
|
||||
prisma.productDoc.delete({ where: { id } }),
|
||||
])
|
||||
5. revalidatePath index + folder
|
||||
```
|
||||
|
||||
> **P1-rationale:** door `doc_id: null` direct te schrijven, omzeilen we het FK-volgorde-probleem volledig — geen `SetNull`-race, geen interactieve transaction nodig. Metadata bewaart `folder`/`slug`/`title` voor traceability.
|
||||
|
||||
##### `toggleProductDocFolderAction(input)`
|
||||
|
||||
```
|
||||
1. session.userId → 401
|
||||
2. session.isDemo → 403
|
||||
3. Zod parse(productDocFolderToggleSchema, input) → 422
|
||||
4. product = prisma.product.findFirst({
|
||||
where: { id: input.product_id, user_id: session.userId } // owner-only (folder = product-setting)
|
||||
}) → 404 als geen toegang of niet-owner
|
||||
5. next = enabled ? [...current, folder] : current.filter(f => f !== folder)
|
||||
6. $transaction([
|
||||
prisma.product.update({ where: {id}, data: { enabled_doc_folders: dedupe(next) } }),
|
||||
prisma.productDocLog.create({
|
||||
data: { product_id, doc_id: null, actor_user_id: session.userId,
|
||||
type: enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED',
|
||||
metadata: { folder } },
|
||||
}),
|
||||
])
|
||||
7. revalidatePath index + settings
|
||||
```
|
||||
|
||||
**Verwijdert geen docs** in disabled folder (anti-data-loss).
|
||||
|
||||
##### `listProductDocsAction({product_id, folder?})` — read-only
|
||||
|
||||
```
|
||||
1. session.userId → 401
|
||||
2. scope-check via productAccessFilter
|
||||
3. prisma.productDoc.findMany({
|
||||
where: { product_id, ...(folder ? { folder } : {}) },
|
||||
select: { id, folder, slug, title, status, updated_at }, // geen content_md
|
||||
orderBy: [{ folder: 'asc' }, { slug: 'asc' }],
|
||||
})
|
||||
```
|
||||
|
||||
#### B.3 Rate-limit-keys ([lib/rate-limit.ts:36](lib/rate-limit.ts:36))
|
||||
|
||||
Sectie toevoegen na de M12-Ideas-keys:
|
||||
```ts
|
||||
// Per-product Product Docs (PBI-XXX)
|
||||
'create-product-doc': { windowMs: 60_000, max: 30 },
|
||||
'edit-product-doc': { windowMs: 60_000, max: 60 },
|
||||
```
|
||||
|
||||
#### B.4 Demo-policy — drie lagen (CLAUDE.md hardstop)
|
||||
|
||||
1. **proxy.ts** — geen wijziging (geen REST-routes in v1).
|
||||
2. **Server-action** — `if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }` aan top van elke write-action (zie [actions/ideas.ts:238](actions/ideas.ts:238)).
|
||||
3. **UI** — elke write-knop (`New doc`, `Edit`, `Delete`, folder-toggle) `<DemoTooltip>`-wrapped + disabled in demo.
|
||||
|
||||
Demo MAG lezen.
|
||||
|
||||
---
|
||||
|
||||
### C. UI-laag
|
||||
|
||||
#### C.1 Routes onder `app/(app)/products/[id]/docs/`
|
||||
|
||||
| Route | Bestand | Doel |
|
||||
|---|---|---|
|
||||
| `/products/[id]/docs` | `docs/page.tsx` | INDEX — grid van **enabled** folders met preview (laatste 3 docs); lege folders tonen CTA |
|
||||
| `/products/[id]/docs/settings` | `docs/settings/page.tsx` | Folder-toggle-grid (8 checkboxes); owner-only schrijfbaar |
|
||||
| `/products/[id]/docs/[folder]` | `docs/[folder]/page.tsx` | Folder-listing tabel; 404 bij invalide folder-enum; bij disabled folder zie §C.4 |
|
||||
| `/products/[id]/docs/[folder]/[slug]` | `docs/[folder]/[slug]/page.tsx` | Doc-viewer; Edit-knop togglet inline-editor |
|
||||
|
||||
#### C.2 Layout-integratie ([app/(app)/products/[id]/layout.tsx:19-25](app/(app)/products/[id]/layout.tsx:19))
|
||||
|
||||
Nieuwe component `components/products/product-subnav.tsx` (client, `usePathname()`):
|
||||
|
||||
```tsx
|
||||
export default async function ProductLayout({ children, params }: Props) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetCurrentProduct id={id} name={product.name} />
|
||||
<ProductSubNav productId={id} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Tabs: Backlog · Sprint · Solo · **Docs** · Instellingen. Active-state-patroon kopiëren uit [components/shared/nav-bar.tsx](components/shared/nav-bar.tsx) (`navLink`-helper).
|
||||
|
||||
#### C.3 Componenten — `components/product-docs/` (nieuw)
|
||||
|
||||
| Component | Type | Beschrijving |
|
||||
|---|---|---|
|
||||
| `product-docs-index.tsx` | server | Grid van enabled folders + preview-lijst |
|
||||
| `product-docs-folder-card.tsx` | server | Card per folder met laatste 3 docs |
|
||||
| `product-docs-folder-list.tsx` | client | Tabel per folder; kolommen + sort |
|
||||
| `product-doc-viewer.tsx` | server | `<Markdown>` ([components/markdown.tsx](components/markdown.tsx)) + frontmatter-kop |
|
||||
| `product-doc-editor.tsx` | client | Wrapper rond `MarkdownDocEditor` (§D) met `updateProductDocAction` |
|
||||
| `new-product-doc-dialog.tsx` | client | Dialog volgens [docs/patterns/dialog.md](docs/patterns/dialog.md); folder-select (alleen enabled), title, auto-slug, starter-template |
|
||||
| `delete-product-doc-button.tsx` | client | Confirm + `deleteProductDocAction`; `<DemoTooltip>` |
|
||||
| `product-doc-folder-toggle.tsx` | client | 8 checkboxes; owner-only |
|
||||
| `product-doc-status-badge.tsx` | server | MD3-tokens: `draft→bg-muted`, `active→bg-status-done`, `deprecated→bg-status-blocked/20`, `archived→bg-muted/40` (**géén** `bg-blue-500`) |
|
||||
| `disabled-folder-banner.tsx` | server | Banner-component voor disabled-folder pages (zie §C.4) |
|
||||
|
||||
**Dialog-spec verplicht:** `docs/specs/dialogs/product-doc.md` ([docs/patterns/dialog.md](docs/patterns/dialog.md) §3.2).
|
||||
|
||||
#### C.4 Disabled-folder semantiek — **verwerkt P2-disabled**
|
||||
|
||||
**Definitieve keuze: "verborgen maar leesbaar"**. Een folder die is uitgeschakeld via `Product.enabled_doc_folders`:
|
||||
|
||||
| Locatie | Gedrag |
|
||||
|---|---|
|
||||
| INDEX-page (`docs/page.tsx`) | Folder is **niet zichtbaar** in de grid (filter op `enabled_doc_folders`) |
|
||||
| ProductSubNav | "Docs"-tab blijft zichtbaar zolang er ≥1 enabled folder is; anders verborgen |
|
||||
| Folder-page (`docs/[folder]/page.tsx`) | **Wel** bereikbaar via directe URL; toont `<DisabledFolderBanner>` bovenaan + read-only tabel ("Folder uitgeschakeld in instellingen — bestaande docs blijven leesbaar; nieuwe docs kunnen niet worden aangemaakt"). "Nieuwe doc"-knop is verborgen. |
|
||||
| Detail-page (`docs/[folder]/[slug]/page.tsx`) | **Wel** bereikbaar; toont dezelfde banner + read-only viewer. Edit-knop is verborgen (niet alleen disabled — er valt niets te wijzigen aan een doc in een gefroze folder; alternatief is muteren-via-frontmatter wat verwarrend wordt). |
|
||||
| `createProductDocAction` | Already 422 'folder uitgeschakeld' (zie §B.2 stap 7) |
|
||||
| `updateProductDocAction` | **Géén folder-check** — bestaande docs in disabled folder blijven editbaar **als** de gebruiker er actief naartoe navigeert? **Nee** — UI verbergt Edit-knop. Server-action zelf doet géén extra folder-check (data-laag blijft consistent; de UI-layer dwingt het af). Dit is bewust: wie via een bookmarked directe action-URL probeert, kan zijn eigen oude content terugzetten — geen risico, geen data-loss. |
|
||||
|
||||
**Rationale:** anti-data-loss (docs verdwijnen niet uit DB), anti-frustratie (oude URLs blijven werken), anti-confusion (banner maakt staat duidelijk). Tests dekken: directe folder-URL na toggle-off → 200 met banner; directe detail-URL → 200 met banner + viewer.
|
||||
|
||||
#### C.5 Frontmatter-UX
|
||||
|
||||
Editor toont raw `content_md` — gebruiker bewerkt frontmatter zelf (zoals `idea-md-editor.tsx` met `kind='plan'`). Live yaml-validatie via memoized `parseProductDocMd`. Submit geblokkeerd bij parse-fout; error-box toont line-info. **Niet** wijzigen door user: `last_updated` (wordt server-overschreven, eventuele user-wijziging genegeerd na save — geen feedback nodig, gewone tooltip in editor "wordt automatisch ververst").
|
||||
|
||||
#### C.6 Geen realtime in v1
|
||||
|
||||
Edits triggeren `router.refresh()` (zoals [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx)). SSE/Realtime is v1.1.
|
||||
|
||||
---
|
||||
|
||||
### D. Refactor: `MarkdownDocEditor`-extractie
|
||||
|
||||
`components/ideas/idea-md-editor.tsx` bevat de complete editor-stack (Cmd+S, localStorage drafts, dirty-check, live-validatie). Twee gebruikers (Ideas + Product Docs) = direct extracten — volgens CLAUDE.md dialog-discipline.
|
||||
|
||||
#### D.1 Nieuwe shared component
|
||||
|
||||
`components/shared/markdown-doc-editor.tsx`:
|
||||
```ts
|
||||
interface MarkdownDocEditorProps {
|
||||
storageKey: string
|
||||
initialValue: string
|
||||
validate?: (md: string) => ValidationError[]
|
||||
onSave: (md: string) => Promise<ActionResult>
|
||||
onCancel: () => void
|
||||
rows?: number
|
||||
placeholder?: string
|
||||
}
|
||||
```
|
||||
|
||||
#### D.2 Wrappers
|
||||
|
||||
- [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) → wrapper rond `MarkdownDocEditor` met `parsePlanMd`/geen-validator afhankelijk van `kind`. Bestaande tests blijven groen.
|
||||
- `components/product-docs/product-doc-editor.tsx` → wrapper met `parseProductDocMd`.
|
||||
|
||||
---
|
||||
|
||||
## Bestanden om aan te raken / aan te maken
|
||||
|
||||
| Laag | Bestand | Status |
|
||||
|---|---|---|
|
||||
| Schema | [prisma/schema.prisma:201](prisma/schema.prisma:201) (Product) + enums-blok | Wijzigen |
|
||||
| Migratie | `prisma/migrations/<ts>_add_product_docs/migration.sql` | Nieuw |
|
||||
| Schemas | `lib/schemas/product-doc.ts`, `lib/schemas/product-doc-frontmatter-defaults.ts` | Nieuw |
|
||||
| Helpers | `lib/product-doc-folder.ts`, `lib/product-doc-parser.ts`, `lib/product-doc-frontmatter.ts`, `lib/product-doc-slug.ts` | Nieuw (4) |
|
||||
| Rate-limit | [lib/rate-limit.ts:36](lib/rate-limit.ts:36) | Wijzigen — 2 keys |
|
||||
| Actions | `actions/product-docs.ts` | Nieuw |
|
||||
| Pages | `app/(app)/products/[id]/docs/{page,settings/page,[folder]/page,[folder]/[slug]/page}.tsx` | Nieuw (4) |
|
||||
| Layout | [app/(app)/products/[id]/layout.tsx:19-25](app/(app)/products/[id]/layout.tsx:19) | Wijzigen — `<ProductSubNav>` |
|
||||
| Sub-nav | `components/products/product-subnav.tsx` | Nieuw |
|
||||
| UI | `components/product-docs/*.tsx` (10 files, §C.3 incl. `disabled-folder-banner.tsx`) | Nieuw |
|
||||
| Shared editor | `components/shared/markdown-doc-editor.tsx` | Nieuw (extractie) |
|
||||
| Refactor | [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Refactor → wrapper |
|
||||
| Dialog-spec | `docs/specs/dialogs/product-doc.md` | Nieuw (repo-docs) |
|
||||
| Tests | `__tests__/lib/{product-doc-parser,product-doc-frontmatter,product-doc-slug,schemas/product-doc}.test.ts`, `__tests__/actions/product-docs.test.ts`, `__tests__/components/product-docs/*.test.tsx` | Nieuw |
|
||||
| Docs-update | [docs/architecture.md](docs/architecture.md), [docs/specs/functional.md](docs/specs/functional.md) | Wijzigen |
|
||||
|
||||
---
|
||||
|
||||
## Hergebruik (bestaande utilities)
|
||||
|
||||
| Bestaand | Hergebruik in |
|
||||
|---|---|
|
||||
| `lib/product-access.ts` (`getAccessibleProduct`, `productAccessFilter`) | Alle page-loaders en server-actions |
|
||||
| `lib/idea-plan-parser.ts` (`parsePlanMd`-shape) | Referentie voor `parseProductDocMd` en `setProductDocFrontmatterFields` |
|
||||
| [actions/ideas.ts:232-313](actions/ideas.ts:232) | Patroon voor server-actions |
|
||||
| [components/markdown.tsx](components/markdown.tsx) | Doc-viewer rendering (react-markdown + remark-gfm + XSS-safe) |
|
||||
| [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Bron voor `MarkdownDocEditor`-extractie |
|
||||
| `lib/rate-limit.ts` (`enforceUserRateLimit`) | Twee nieuwe keys |
|
||||
| `<DemoTooltip>`-wrapper (uit `components/dialogs/product-dialog.tsx`) | Alle write-knoppen |
|
||||
|
||||
---
|
||||
|
||||
## PBI / Story-breakdown (voor MCP-flow)
|
||||
|
||||
Volgt [docs/runbooks/plan-to-pbi-flow.md](docs/runbooks/plan-to-pbi-flow.md). Eén PBI met N stories, sequentieel (ST-C kan parallel met ST-B):
|
||||
|
||||
| Story | Scope |
|
||||
|---|---|
|
||||
| **ST-A** DB & helpers | Schema-edits + migration.sql + Zod-schemas + parser + **frontmatter-serializer** + slug-helpers. Tests voor parser, frontmatter-set, slug, schemas. |
|
||||
| **ST-B** Server-actions | `actions/product-docs.ts` (5 actions met definitieve pipelines uit §B.2) + rate-limit-keys. Tests dekken P1 (delete-audit), P2 (title/status sync, last_updated normalisatie). |
|
||||
| **ST-C** MarkdownDocEditor-extractie | Refactor `idea-md-editor.tsx` → `MarkdownDocEditor` + idea-wrapper. Bestaande idea-tests groen. |
|
||||
| **ST-D** UI routes + viewer + dialog | INDEX, folder-list, viewer, "Nieuwe doc"-dialog, viewer-edit-toggle. **Disabled-folder banner** (§C.4). Specs-doc `docs/specs/dialogs/product-doc.md`. |
|
||||
| **ST-E** Folder-config + ProductSubNav | `docs/settings`-page + `ProductSubNav`-component + layout-integration. Tests voor directe URL na toggle-off. |
|
||||
| **ST-F** Demo-policy + e2e + docs-update | `<DemoTooltip>`-wrappers, demo-smoke, manuele e2e, breadcrumb-update in `docs/architecture.md`, optionele ADR voor folder-set-keuze. |
|
||||
|
||||
---
|
||||
|
||||
## Verificatie
|
||||
|
||||
### Unit-tests (vitest) — review-coverage expliciet
|
||||
|
||||
| Bestand | Wat (incl. review-punten) |
|
||||
|---|---|
|
||||
| `__tests__/lib/product-doc-parser.test.ts` | parse-success, parse-fail (missing fm, bad yaml, missing required, status-enum-fail) |
|
||||
| `__tests__/lib/product-doc-frontmatter.test.ts` | **P2-last_updated**: `setProductDocFrontmatterFields` vervangt bestaand `last_updated`, voegt toe als afwezig, behoudt overige velden + body |
|
||||
| `__tests__/lib/product-doc-slug.test.ts` | slugify, dedupe, ADR-sequence |
|
||||
| `__tests__/lib/schemas/product-doc.test.ts` | Zod positive/negative |
|
||||
| `__tests__/actions/product-docs.test.ts` | Per action: 401 / 403-demo / 422-parse / 404-geen-toegang / success + log-rij. Specifiek: |
|
||||
| | • **P1-delete**: na `deleteProductDocAction` → 0 product_docs voor id; 1 product_docs_log met `type=DELETED`, `doc_id IS NULL`, metadata bevat `folder/slug/title` |
|
||||
| | • **P2-create**: success-test vergelijkt opgeslagen `title`/`status` met frontmatter-input |
|
||||
| | • **P2-create-fail**: frontmatter zonder `title` of `status` → 422, géén DB-write (`product_docs.count() === 0`) |
|
||||
| | • **P2-last_updated**: user-supplied `last_updated: 2020-01-01` wordt in opgeslagen `content_md` vervangen door today |
|
||||
| | • **P2-disabled-create**: folder uitgeschakeld → 422 'folder uitgeschakeld' |
|
||||
| `__tests__/components/product-docs/new-product-doc-dialog.test.tsx` | velden per folder, submit-blokkering yaml-fout, DemoTooltip |
|
||||
| `__tests__/components/shared/markdown-doc-editor.test.tsx` | Cmd+S triggert save, localStorage draft |
|
||||
| `__tests__/app/products-docs-disabled-folder.test.tsx` (smoke) | **P2-disabled-routes**: directe folder-URL na toggle-off → render met `<DisabledFolderBanner>`, geen "Nieuwe doc"-knop; directe detail-URL → viewer + banner, geen Edit-knop |
|
||||
|
||||
### End-to-end smoke (handmatig)
|
||||
1. `npm run dev` → login als seed-user
|
||||
2. Open product → tab "Docs" → INDEX toont 8 lege folders
|
||||
3. "Nieuwe doc" → folder `runbooks`, titel "Deploy stappen" → save → viewer-redirect; check dat `last_updated` op vandaag staat
|
||||
4. Edit → status → `active` in frontmatter, zet `last_updated: 2020-01-01` → save → badge muteert + `last_updated` is vandaag (server-normalisatie)
|
||||
5. `docs/settings` → toggle `api` uit → INDEX toont 7 folders; directe URL `/products/[id]/docs/api` → 200 met banner; flip terug → folder weer in INDEX
|
||||
6. Yaml-fout introduceren → save geblokkeerd, error-box met line-info
|
||||
7. Delete → confirm → doc weg uit folder-listing; `product_doc_logs` heeft rij met `type=DELETED`, `doc_id=null`, metadata `{folder, slug, title}`
|
||||
8. Andere user **zonder** ProductMember → directe URL → 404 (anti-enum)
|
||||
9. Demo-user → docs lezen werkt, write-knoppen disabled; directe action-call → 403
|
||||
10. ProductMember (niet-owner) → lezen + schrijven docs OK; `docs/settings` read-only (geen toggle-permissie)
|
||||
|
||||
### Build + lint
|
||||
```bash
|
||||
npm run verify && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open punten (v1.1+, niet-blokkerend)
|
||||
|
||||
- Import van bestaande `.md`-files (drag-and-drop, Obsidian-paste)
|
||||
- Postgres full-text-search op `content_md`
|
||||
- Audit-timeline-view per doc + per actor (`ProductDocLog` met de `actor_user_id`-index uit §A.3)
|
||||
- Auto-link naar PBI/Story-codes in MD-body
|
||||
- MCP-tools (`read_product_doc`, `list_product_docs`)
|
||||
- Optionele git-export naar `repo_url/docs/`
|
||||
- Realtime/SSE updates op doc-mutaties
|
||||
- Migratie van denormalized actor-strings naar FK (zie P3-keuze)
|
||||
|
||||
---
|
||||
|
||||
## Procedure na goedkeuring — uitgevoerd 2026-05-16
|
||||
|
||||
PBI, stories en taken zijn aangemaakt via Scrum4Me MCP. Implementatie wacht op expliciete gebruikersopdracht.
|
||||
|
||||
### PBI
|
||||
|
||||
- **PBI-96** — Per-product documentatiestructuur (Product Docs) · priority 2 · product Scrum4Me (`cmohrysyj0000rd17clnjy4tc`)
|
||||
|
||||
### Stories (uit te voeren in sort_order; ST-C kan parallel met ST-B)
|
||||
|
||||
| Story | Titel | ID |
|
||||
|---|---|---|
|
||||
| ST-1379 | A — DB-schema, migratie en helpers | `cmp84tcca0002q017gc5hsw88` |
|
||||
| ST-1380 | B — Server-actions (5 actions) + rate-limit-keys | `cmp84tgtz0003q0179dpeywqg` |
|
||||
| ST-1381 | C — MarkdownDocEditor-extractie (refactor) | `cmp84tkhf0004q017zzrka4wq` |
|
||||
| ST-1382 | D — UI routes (INDEX, folder, viewer, editor) + dialog | `cmp84tp4a0005q0179qa7we7d` |
|
||||
| ST-1383 | E — Folder-config-page + ProductSubNav | `cmp84tsz50006q017q9l9o6u0` |
|
||||
| ST-1384 | F — Demo-policy, e2e-smoke en docs-update | `cmp84twp40007q017l4dmva5o` |
|
||||
|
||||
### Taken (18 totaal)
|
||||
|
||||
| Task | Titel | Story |
|
||||
|---|---|---|
|
||||
| T-1058 | A1: Prisma-schema + migratie | ST-1379 |
|
||||
| T-1059 | A2: Zod-schemas + folder-mapping + slug-helper + frontmatter-defaults | ST-1379 |
|
||||
| T-1060 | A3: Frontmatter parser + serializer (P2-fix) + tests | ST-1379 |
|
||||
| T-1061 | B1: Rate-limit-keys + loadAccessibleProduct-helper | ST-1380 |
|
||||
| T-1062 | B2: createProductDocAction + updateProductDocAction + tests (P2) | ST-1380 |
|
||||
| T-1063 | B3: deleteProductDocAction + tests (P1-fix) | ST-1380 |
|
||||
| T-1064 | B4: toggleProductDocFolderAction + listProductDocsAction + tests | ST-1380 |
|
||||
| T-1065 | C1: components/shared/markdown-doc-editor.tsx + unit-tests | ST-1381 |
|
||||
| T-1066 | C2: Refactor idea-md-editor naar wrapper | ST-1381 |
|
||||
| T-1067 | D1: INDEX-page + index-grid + folder-card | ST-1382 |
|
||||
| T-1068 | D2: Folder-listing-page + folder-list + status-badge | ST-1382 |
|
||||
| T-1069 | D3: Viewer/editor-page + viewer + editor-wrapper + delete-button | ST-1382 |
|
||||
| T-1070 | D4: NewProductDocDialog + dialog-spec | ST-1382 |
|
||||
| T-1071 | D5: DisabledFolderBanner + integratie in folder/detail pages | ST-1382 |
|
||||
| T-1072 | E1: /docs/settings-page + product-doc-folder-toggle | ST-1383 |
|
||||
| T-1074 | E2: ProductSubNav + layout-integratie + disabled-folder smoke-test | ST-1383 |
|
||||
| T-1075 | F1: Demo-policy audit + 10-stappen manual e2e | ST-1384 |
|
||||
| T-1076 | F2: Docs-update (architecture, functional, optionele ADRs, INDEX) | ST-1384 |
|
||||
|
||||
### Volgende stap
|
||||
|
||||
Wacht op gebruikersopdracht. Bij start van uitvoering: pak T-1058 op (volgt uit `mcp__scrum4me__get_claude_context`), zet status op `IN_PROGRESS`, werk per taak in sort_order, commit per laag conform [docs/runbooks/branch-and-commit.md](../runbooks/branch-and-commit.md), zet story-status op `DONE` als alle taken klaar zijn.
|
||||
63
docs/recommendations/PBI-96-demo-audit-2026-05-16.md
Normal file
63
docs/recommendations/PBI-96-demo-audit-2026-05-16.md
Normal 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.
|
||||
88
docs/specs/dialogs/product-doc.md
Normal file
88
docs/specs/dialogs/product-doc.md
Normal 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.
|
||||
|
|
@ -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
41
lib/product-doc-folder.ts
Normal 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
|
||||
59
lib/product-doc-frontmatter.ts
Normal file
59
lib/product-doc-frontmatter.ts
Normal 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
74
lib/product-doc-parser.ts
Normal 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
78
lib/product-doc-slug.ts
Normal 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(/-+$/, '')}`
|
||||
}
|
||||
46
lib/product-docs-server.ts
Normal file
46
lib/product-docs-server.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
70
lib/schemas/product-doc-frontmatter-defaults.ts
Normal file
70
lib/schemas/product-doc-frontmatter-defaults.ts
Normal 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`,
|
||||
),
|
||||
},
|
||||
}
|
||||
82
lib/schemas/product-doc.ts
Normal file
82
lib/schemas/product-doc.ts
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue