feat(PBI-96/T-1063): deleteProductDocAction with P1-fix (no FK race)

- actions/product-docs.ts: deleteProductDocAction haalt eerst metadata
  op (folder/slug/title), schrijft dan log met doc_id:null + delete in
  één $transaction. Geen SetNull-race, geen interactieve tx nodig.
- __tests__: 4 nieuwe tests (auth-paden + P1-coverage met expliciete
  check op doc_id:null, type:'DELETED' en metadata-velden gevuld).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 11:47:09 +02:00
parent ca301b5792
commit 1d116c44ff
2 changed files with 120 additions and 0 deletions

View file

@ -36,6 +36,7 @@ import { prisma } from '@/lib/prisma'
import { _resetRateLimit } from '@/lib/rate-limit'
import {
createProductDocAction,
deleteProductDocAction,
updateProductDocAction,
} from '@/actions/product-docs'
@ -289,3 +290,66 @@ new body
)
})
})
// ---------------------------------------------------------------------------
// 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' } })
})
})

View file

@ -247,3 +247,59 @@ export async function updateProductDocAction(
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 }
}