/* --------------------------------------------------------------------------- * Form validation derived from a Resource's field definitions. * * Returns a map of field.key → error message. Empty object = valid. * Server-side authoritative; the client may show hints, but every POST * must call this before writing to the DB. * ------------------------------------------------------------------------- */ import type { Field, FieldContext, Resource } from './resource-types'; export type ValidationErrors = Record; export interface ValidateArgs { resource: Resource; data: Record; /** Existing item being edited, or null on create. */ item: Record | null; actingUserId: number; } export function validateForResource(args: ValidateArgs): ValidationErrors { const { resource, data, item, actingUserId } = args; const errors: ValidationErrors = {}; if (!resource.form) return errors; const ctx: FieldContext = { formValues: data, item, actingUserId }; for (const field of resource.form.fields) { if (field.readOnly) continue; if (field.visibleWhen && !field.visibleWhen(ctx)) continue; const value = data[field.key]; const error = validateField(field, value); if (error) errors[field.key] = error; } return errors; } function validateField(field: Field, value: unknown): string | null { const isEmpty = value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); if (field.required && isEmpty) { return `${field.label} is required`; } if (isEmpty) return null; switch (field.kind) { case 'text': case 'textarea': case 'markdown': { if (typeof value !== 'string') return `${field.label} must be text`; if ('maxLength' in field && field.maxLength && value.length > field.maxLength) { return `${field.label} must be ${field.maxLength} characters or fewer`; } if (field.kind === 'text' && field.pattern && !field.pattern.test(value)) { return `${field.label} is not in the expected format`; } return null; } case 'select': case 'select-async': { // Empty already handled. Anything else is accepted at the validate layer; // option membership is enforced by the route handler against a fresh option list. return null; } case 'multi-select-async': { if (!Array.isArray(value)) return `${field.label} must be a list`; return null; } case 'multi-text': { if (!Array.isArray(value)) return `${field.label} must be a list`; const filled = value.filter( (v) => typeof v === 'string' && v.trim() !== '', ); if (field.minItems !== undefined && filled.length < field.minItems) { return `${field.label} requires at least ${field.minItems} entries`; } if (field.maxItems !== undefined && filled.length > field.maxItems) { return `${field.label} allows at most ${field.maxItems} entries`; } return null; } case 'number': { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return `${field.label} must be a number`; if (field.min !== undefined && n < field.min) { return `${field.label} must be at least ${field.min}`; } if (field.max !== undefined && n > field.max) { return `${field.label} must be no more than ${field.max}`; } return null; } case 'date': case 'datetime': { if (typeof value !== 'string') return `${field.label} must be a date`; const t = Date.parse(value); if (Number.isNaN(t)) return `${field.label} is not a valid date`; return null; } case 'image-upload': { if (typeof value !== 'string') return `${field.label} must be a URL`; if ('maxLength' in field && field.maxLength && value.length > field.maxLength) { return `${field.label} must be ${field.maxLength} characters or fewer`; } return null; } case 'readonly': return null; } }