- 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>
427 lines
13 KiB
TypeScript
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,
|
|
})),
|
|
}
|
|
}
|