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>
This commit is contained in:
Janpeter Visser 2026-05-16 11:48:24 +02:00
parent 1d116c44ff
commit 4539de1fff
2 changed files with 292 additions and 1 deletions

View file

@ -20,9 +20,10 @@ vi.mock('@/lib/session', () => ({
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
product: { findFirst: vi.fn(), update: vi.fn() },
productDoc: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@ -37,6 +38,8 @@ import { _resetRateLimit } from '@/lib/rate-limit'
import {
createProductDocAction,
deleteProductDocAction,
listProductDocsAction,
toggleProductDocFolderAction,
updateProductDocAction,
} from '@/actions/product-docs'
@ -353,3 +356,169 @@ describe('deleteProductDocAction', () => {
expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } })
})
})
// ---------------------------------------------------------------------------
// toggleProductDocFolderAction — owner-only check
describe('toggleProductDocFolderAction', () => {
const baseInput = {
product_id: VALID_PRODUCT_ID,
folder: 'api' as const,
enabled: false,
}
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 404 als de user geen owner is (ook niet als ProductMember)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
// Check dat de scope owner-only is: where bevat user_id (geen OR met members)
const call = (prisma.product.findFirst as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.user_id).toBe('user-1')
})
it('idempotent: target-staat == huidige staat → success zonder DB-write', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
// enabled:false maar API zit al uit (niet in array) → no-op
const r = await toggleProductDocFolderAction({ ...baseInput, folder: 'manual', enabled: false })
expect('success' in r && r.success).toBe(true)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('disable folder: update + FOLDER_DISABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: false,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.product.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: VALID_PRODUCT_ID },
data: { enabled_doc_folders: ['ADR'] },
}),
)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'FOLDER_DISABLED',
doc_id: null,
metadata: { folder: 'api' },
}),
}),
)
})
it('enable folder: update + FOLDER_ENABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: true,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'FOLDER_ENABLED' }),
}),
)
})
})
// ---------------------------------------------------------------------------
// listProductDocsAction — read-only; demo MAG lezen
describe('listProductDocsAction', () => {
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('demo MAG lezen (geen 403)', async () => {
setSession({ isDemo: true })
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'other',
enabled_doc_folders: [],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect('success' in r && r.success).toBe(true)
})
it('returnt 404 als product niet toegankelijk', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
})
it('returnt gemapte items zonder content_md', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
id: 'd1',
folder: 'RUNBOOKS',
slug: 'deploy',
title: 'Deploy',
status: 'active',
updated_at: new Date('2026-05-16'),
},
])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID, folder: 'runbooks' })
expect('success' in r && r.success).toBe(true)
if ('data' in r && r.data) {
expect(r.data).toHaveLength(1)
expect(r.data[0]).toMatchObject({
id: 'd1',
folder: 'runbooks', // lowercase mapping
slug: 'deploy',
title: 'Deploy',
status: 'active',
})
}
// Check dat folder-filter in de query zit
const call = (prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.folder).toBe('RUNBOOKS')
// En dat content_md niet geselecteerd is
expect(call.select.content_md).toBeUndefined()
})
})

View file

@ -30,8 +30,10 @@ import {
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 {
@ -303,3 +305,123 @@ export async function deleteProductDocAction(id: string): Promise<ActionResult>
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,
})),
}
}