feat(product-dialog): conform aan dialog-pattern + entity-profile

Story 2 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- lib/schemas/product.ts — gedeeld zod-schema (Dialog API)
- actions/products.ts — createProductAction/updateProductAction returnen
  nu code+fieldErrors voor 422-validatie en code: 403 voor demo/auth
- ProductDialog adopt useDirtyCloseGuard, useDialogSubmitShortcut,
  entityDialog* layout-classes; 422-fieldErrors mappen naar form.setError
- docs/specs/dialogs/product.md — entity-profile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:18:39 +02:00
parent b05c4d241b
commit 03a248b0fb
6 changed files with 310 additions and 181 deletions

View file

@ -10,8 +10,10 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { Role } from '@prisma/client'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
import { productAccessFilter } from '@/lib/product-access'
import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product'
const productSchema = z.object({
// Legacy FormData schema for ProductForm components (other constraints than dialog)
const productFormDataSchema = z.object({
name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'),
code: z
.string()
@ -29,25 +31,13 @@ const productSchema = z.object({
.max(500, 'Definition of Done mag maximaal 500 tekens bevatten'),
})
// Dialog-based schema (data-object API)
const productInput = z.object({
name: z.string().min(1).max(200),
code: z.string().max(20).optional(),
description: z.string().max(4000).optional(),
repo_url: z
.string()
.url()
.regex(/^https:\/\/github\.com\//)
.optional()
.nullable(),
definition_of_done: z.string().max(4000).optional(),
auto_pr: z.boolean().default(false),
})
export type ProductInput = z.infer<typeof productInput>
type ProductActionResult = { success: true; productId: string } | { error: string }
type UpdateProductResult = { success: true } | { error: string }
type ProductFieldErrors = Partial<Record<keyof ProductInput, string[]>>
type ProductActionResult =
| { success: true; productId: string }
| { error: string; code?: number; fieldErrors?: ProductFieldErrors }
type UpdateProductResult =
| { success: true }
| { error: string; code?: number; fieldErrors?: ProductFieldErrors }
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -56,20 +46,30 @@ async function getSession() {
// Data-object API used by ProductDialog
export async function createProductAction(data: ProductInput): Promise<ProductActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = productInput.safeParse(data)
if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' }
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors,
}
}
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' }
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] },
}
}
if (code) {
const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } })
if (dup) return { error: 'Code is al in gebruik' }
if (dup) return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Code is al in gebruik'] } }
}
const userId = session.userId
@ -97,21 +97,31 @@ export async function createProductAction(data: ProductInput): Promise<ProductAc
// Data-object API used by ProductDialog
export async function updateProductAction(id: string, data: ProductInput): Promise<UpdateProductResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = productInput.safeParse(data)
if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' }
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors,
}
}
const product = await prisma.product.findFirst({
where: { id, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (!product) return { error: 'Product niet gevonden of geen toegang' }
if (!product) return { error: 'Product niet gevonden of geen toegang', code: 403 }
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' }
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] },
}
}
const userId = session.userId
@ -149,7 +159,7 @@ export async function createProductFormAction(_prevState: unknown, formData: For
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = productSchema.safeParse({
const parsed = productFormDataSchema.safeParse({
name: formData.get('name'),
code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined,
@ -198,7 +208,7 @@ export async function updateProductFormAction(_prevState: unknown, formData: For
const id = formData.get('id') as string
if (!id) return { error: 'Product niet gevonden' }
const parsed = productSchema.safeParse({
const parsed = productFormDataSchema.safeParse({
name: formData.get('name'),
code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined,