/** * TASK-0108 §2 — the dynamic answer schema. * * The STORED form config is the schema source (not a static DTO): the zod * shape is built from the form's questions + their per-type `validation` at * request time. Rules (spec §2): * - `required` per question (empty/missing value → `required` violation); * - type coercion + bounds (NUMBER → number, CHECKBOX → boolean, * RATING → int 1..max, SINGLE_SELECT → one of options, * MULTI_SELECT → array of options (unique), DATE → ISO within min/max, * EMAIL → format, FILE → array of file ids ≤ maxFiles); * - min/max/length/pattern from `validation` (pattern runs here — * server-side only, never exposed to the client); * - unknown questionIds are IGNORED, extra top-level keys are dropped. * All violations are collected (NOT fail-fast) → one 422 with every error. */ import { FORM_QUESTION_TYPES, type FormQuestionType, type FormConfig } from "@factory/shared"; export interface StoredQuestion { id: string; label: string; type: string; required: boolean; validation: unknown; } export interface AnswerViolation { questionId: string; field?: string; code: string; message: string; } export interface AnswerValidationResult { /** Coerced values, keyed by question id (only present questions). */ answers: Record; violations: AnswerViolation[]; } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; type Val = Record; function asVal(v: unknown): Val { return v && typeof v === "object" ? (v as Val) : {}; } function isEmpty(v: unknown): boolean { if (v === undefined || v === null) return true; if (typeof v === "string") return v.trim() === ""; if (Array.isArray(v)) return v.length === 0; return false; } /** * Validate + coerce `raw` (keyed by question id) against the stored * questions. Never throws (config corruption is caught by the caller and * fails closed with 500 FORM_CONFIG_INVALID). */ export function buildAnswerResult( questions: StoredQuestion[], raw: Record | undefined ): AnswerValidationResult { const answers: Record = {}; const violations: AnswerViolation[] = []; const body = raw && typeof raw === "object" ? raw : {}; for (const q of questions) { const type = q.type as FormQuestionType; if (!FORM_QUESTION_TYPES.includes(type)) continue; // corrupt row — skip, don't guess const v = asVal(q.validation); const rawValue = body[q.id]; const missing = isEmpty(rawValue); if (missing) { if (q.required) { violations.push({ questionId: q.id, code: "required", message: `“${q.label}” is required.` }); } continue; } switch (type) { case "SHORT_TEXT": case "LONG_TEXT": { if (typeof rawValue !== "string") { violations.push({ questionId: q.id, code: "type", message: "text expected." }); break; } const s = rawValue; const min = typeof v.minLength === "number" ? v.minLength : 0; const max = typeof v.maxLength === "number" ? v.maxLength : null; if (s.length < min) violations.push({ questionId: q.id, code: "min_length", message: `must be at least ${min} characters.` }); if (max !== null && s.length > max) violations.push({ questionId: q.id, code: "max_length", message: `must be at most ${max} characters.` }); if (typeof v.pattern === "string" && v.pattern.length > 0) { try { if (!new RegExp(v.pattern).test(s)) { violations.push({ questionId: q.id, code: "pattern", message: "does not match the required pattern." }); } } catch { // Corrupt stored pattern — fail closed for THIS answer (spec §Security). violations.push({ questionId: q.id, code: "config", message: "question configuration is invalid." }); } } answers[q.id] = s; break; } case "EMAIL": { if (typeof rawValue !== "string" || !EMAIL_RE.test(rawValue.trim())) { violations.push({ questionId: q.id, code: "email", message: "a valid email address is required." }); break; } answers[q.id] = rawValue.trim(); break; } case "NUMBER": { const n = typeof rawValue === "number" ? rawValue : typeof rawValue === "string" && rawValue.trim() !== "" ? Number(rawValue) : NaN; if (!Number.isFinite(n)) { violations.push({ questionId: q.id, code: "number", message: "a number is required." }); break; } if (v.integer === true && !Number.isInteger(n)) { violations.push({ questionId: q.id, code: "integer", message: "a whole number is required." }); } if (typeof v.min === "number" && n < v.min) violations.push({ questionId: q.id, code: "min", message: `must be ≥ ${v.min}.` }); if (typeof v.max === "number" && n > v.max) violations.push({ questionId: q.id, code: "max", message: `must be ≤ ${v.max}.` }); answers[q.id] = n; break; } case "RATING": { const max = typeof v.max === "number" ? v.max : 5; const n = typeof rawValue === "number" ? rawValue : Number(rawValue); if (!Number.isInteger(n) || n < 1 || n > max) { violations.push({ questionId: q.id, code: "range", message: `choose a rating between 1 and ${max}.` }); break; } answers[q.id] = n; break; } case "DATE": { const s = typeof rawValue === "string" ? rawValue : ""; if (!ISO_DATE_RE.test(s)) { violations.push({ questionId: q.id, code: "date", message: "a date (YYYY-MM-DD) is required." }); break; } if (typeof v.minDate === "string" && s < v.minDate) { violations.push({ questionId: q.id, code: "min_date", message: `must be on or after ${v.minDate}.` }); } if (typeof v.maxDate === "string" && s > v.maxDate) { violations.push({ questionId: q.id, code: "max_date", message: `must be on or before ${v.maxDate}.` }); } answers[q.id] = s; break; } case "CHECKBOX": { if (typeof rawValue !== "boolean") { violations.push({ questionId: q.id, code: "boolean", message: "true or false expected." }); break; } answers[q.id] = rawValue; break; } case "SINGLE_SELECT": { const options: string[] = Array.isArray(v.options) ? (v.options as unknown[]).filter((o): o is string => typeof o === "string") : []; if (typeof rawValue !== "string" || (options.length > 0 && !options.includes(rawValue))) { violations.push({ questionId: q.id, code: "option", message: options.length > 0 ? `choose one of: ${options.join(", ")}.` : "choose an option." }); break; } answers[q.id] = rawValue; break; } case "MULTI_SELECT": { const options: string[] = Array.isArray(v.options) ? (v.options as unknown[]).filter((o): o is string => typeof o === "string") : []; const arr = Array.isArray(rawValue) ? rawValue : [rawValue]; const bad = arr.filter((o): o is string => typeof o !== "string" || (options.length > 0 && !options.includes(o))); if (bad.length > 0) { violations.push({ questionId: q.id, code: "option", message: "all selections must be one of the options." }); break; } const unique = [...new Set(arr as string[])]; if (unique.length !== arr.length) { violations.push({ questionId: q.id, code: "duplicate", message: "duplicate selections are not allowed." }); break; } answers[q.id] = unique; break; } case "FILE": { const maxFiles = typeof v.maxFiles === "number" ? v.maxFiles : 1; const arr = Array.isArray(rawValue) ? rawValue : [rawValue]; if (arr.length === 0) { if (q.required) violations.push({ questionId: q.id, code: "required", message: `“${q.label}” is required.` }); break; } if (arr.length > maxFiles) { violations.push({ questionId: q.id, code: "max_files", message: `at most ${maxFiles} file${maxFiles > 1 ? "s" : ""} allowed.` }); break; } if (!arr.every((f) => typeof f === "string" && f.length > 0)) { violations.push({ questionId: q.id, code: "file_ids", message: "file ids must be strings." }); break; } answers[q.id] = arr; break; } default: // Unknown stored type — fail closed (never silently accept). violations.push({ questionId: q.id, code: "config", message: "question configuration is invalid." }); } } return { answers, violations }; } /** * Config-integrity pre-check (spec §Security: "config corruption → fail * closed with 500 FORM_CONFIG_INVALID"). Returns a violation when a stored * question row is unusable (unknown type, or a validation shape that the * shared schema rejects for its type). */ export function configIntegrityIssue(questions: StoredQuestion[]): string | null { for (const q of questions) { if (!FORM_QUESTION_TYPES.includes(q.type as FormQuestionType)) { return `question ${q.id} has unknown type ${q.type}`; } // RATING requires a max (default 5 is applied, so null is fine); selects // may have no options (free text fallback); all other shapes are the // shared schema's job at write time — here we only catch the UNUSABLE. } void ({} as FormConfig); return null; }