// Validación de integridad server-side del submit (Etapa 4G). // REUSA lib/form-engine.ts (resolveVisibility, resolveScreens, expandInstances) — // no reimplementa lógica de flujo. Corre en el servidor (Server Action), nunca // en el cliente: "la validación de cliente es UX, NO seguridad" (BRIEF decisión 15). // // Lo que esta función NO hace: persistir nada. Solo decide si el payload recibido // es coherente con el esquema + el propio flujo declarado por el payload, y si es // así, devuelve las filas listas para insertar (mapeadas a question_id). import type { AnswerValue, FormAnswers, FormSchema, Question } from './form-engine.types' // Extensión .ts explícita (no solo en .mts): a diferencia de form-engine.ts — // que solo importa *tipos* de form-engine.types (se borran en runtime, Node // nunca necesita resolverlos) — este archivo importa FUNCIONES reales y // corre tanto bajo Next.js (Server Action) como bajo Node puro (tests). Node // exige extensión explícita para resolver imports de valor; tsconfig.json // habilita allowImportingTsExtensions para permitir esto sin romper tsc. import { answerKey, expandInstances, isAnswerEmpty, resolveScreens, resolveVisibility } from './form-engine.ts' export interface SubmitAnswerEntry { code: string /** 'default' para preguntas normales; 'familiar_N' para preguntas de instancia. */ instanceId: string value: AnswerValue } export interface ValidatedRow { questionId: string instanceId: string value: AnswerValue } export type ValidationResult = | { ok: true; rows: ValidatedRow[] } | { ok: false; errors: string[] } function validateValueShape(question: Question, value: AnswerValue): string | null { switch (question.type) { case 'yes_no': return value === 'true' || value === 'false' ? null : 'invalid_yes_no_value' case 'single_choice': { if (typeof value !== 'string') return 'expected_string_value' if (question.options && !question.options.some((o) => o.value === value)) { return 'value_not_in_options' } return null } case 'multiple_choice': { if (!Array.isArray(value)) return 'expected_array_value' if (!value.every((v) => typeof v === 'string')) return 'expected_string_array' if (question.max_select !== null && value.length > question.max_select) { return 'exceeds_max_select' } if (question.options) { const valid = new Set(question.options.map((o) => o.value)) if (!value.every((v) => valid.has(v as string))) return 'value_not_in_options' } return null } case 'text_short': case 'text_long': return typeof value === 'string' ? null : 'expected_string_value' // rating/nps/matrix/ranking/date/slider: no usados por el instrumento v3 hoy. // No se valida su forma específica — si en el futuro se usan, agregar el caso. default: return null } } /** * Corre las 7 validaciones del punto 4 de ETAPA_4G_PROMPT.md contra el * esquema cargado de la DB. Si CUALQUIERA falla, devuelve ok:false con la * lista completa de errores encontrados (no solo el primero) — el caller * rechaza el submit entero, sin insertar nada. */ export function validateSubmission( schema: FormSchema, entries: SubmitAnswerEntry[], consentOperativo: boolean ): ValidationResult { const errors: string[] = [] const byCode = new Map(schema.questions.map((q) => [q.code, q] as const)) // Reconstruye el mismo FormAnswers que tuvo el wizard — las funciones del // motor lo necesitan para evaluar reglas/instancias/visibilidad. const answers: FormAnswers = {} for (const e of entries) answers[answerKey(e.code, e.instanceId)] = e.value // 6. Consentimiento operativo requerido presente if (!consentOperativo) errors.push('consent_operativo_required') const nonEmpty = entries.filter((e) => !isAnswerEmpty(e.value)) // 2. Preguntas legítimas · 3. Valores válidos · 5. Instancias legítimas for (const e of nonEmpty) { const q = byCode.get(e.code) if (!q) { errors.push(`unknown_question:${e.code}`); continue } if (q.type === 'section') { errors.push(`section_is_not_answerable:${e.code}`); continue } if (q.instance_of) { const generator = byCode.get(q.instance_of) const instances = generator ? expandInstances(generator, answers) : [] if (!instances.some((i) => i.instanceId === e.instanceId)) { errors.push(`invalid_instance:${e.code}:${e.instanceId}`) continue } } else if (e.instanceId !== 'default') { errors.push(`unexpected_instance_id:${e.code}:${e.instanceId}`) continue } const shapeError = validateValueShape(q, e.value) if (shapeError) errors.push(`${shapeError}:${e.code}`) } if (errors.length > 0) return { ok: false, errors } // 4. Visibilidad coherente — cada respuesta corresponde a algo que el // flujo (con las respuestas recibidas) debía mostrar. Usa resolveVisibility // del motor directamente — ninguna lógica de show/hide se repite acá. for (const e of nonEmpty) { const q = byCode.get(e.code)! if (!resolveVisibility(q, answers)) { errors.push(`question_should_be_hidden:${e.code}:${e.instanceId}`) } } if (errors.length > 0) return { ok: false, errors } // 7. Requeridas presentes — recorre las pantallas que el motor resuelve // como visibles dadas estas respuestas, exige valor donde required=true. const screens = resolveScreens(schema, answers) for (const s of screens) { if (s.kind !== 'question' || !s.question?.required) continue const v = answers[answerKey(s.question.code, s.instanceId ?? undefined)] if (isAnswerEmpty(v)) { errors.push(`required_missing:${s.question.code}:${s.instanceId ?? 'default'}`) } } if (errors.length > 0) return { ok: false, errors } const rows: ValidatedRow[] = nonEmpty.map((e) => ({ questionId: byCode.get(e.code)!.id, instanceId: e.instanceId, value: e.value, })) return { ok: true, rows } }