Scrum4Me/actions/product-docs.ts
Madhura68 4539de1fff feat(PBI-96/T-1064): toggleProductDocFolderAction + listProductDocsAction
- toggleProductDocFolderAction: owner-only scope (where: id + user_id,
  NIET productAccessFilter — folder-config is product-setting). Idempotent
  (no-op + success als al in target-staat). Disabled folder verwijdert
  GEEN docs uit DB; alleen flag in enabled_doc_folders. Log met doc_id:null
  + FOLDER_ENABLED/FOLDER_DISABLED.
- listProductDocsAction: read-only, scope via productAccessFilter (zonder
  demo-403 — demo MAG lezen, zie plan §B.4). Geen rate-limit. Select
  zonder content_md. OrderBy [folder, slug]. Mapt DB-enum naar API-string.
- Tests: 10 nieuwe (owner-only-check, idempotent, enable+disable-logs,
  demo-read-OK, folder-filter, ontbreken content_md in select). Totaal
  28 tests in product-docs actions; 1008 tests groen in monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:48:24 +02:00

427 lines
13 KiB
TypeScript

'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,
})),
}
}