Files
motor-de-encuestas/lib/form-engine.ts
markosbenitez 2e6fbd7801 feat(submit): submit v3 con validación de integridad server-side [4G]
RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino
de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde
anon podía insertar cualquier cosa. Validación server-side (7 checks)
reusando el motor. Persistencia atómica con instance_id por instancia.
Verificado contra Postgres real: instancias separadas, atomicidad, RLS.
Riesgo residual de bypass por REST documentado (SEC-004).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:27:49 -03:00

245 lines
9.0 KiB
TypeScript

// Motor de resolución de flujo v3 (ADR-005) — módulo puro, sin React ni DOM.
// Reemplaza conceptualmente a lib/conditional.ts (v2); el componente (4D) decide
// cuándo migrar. Entrada→salida determinista: dado un FormSchema y un FormAnswers,
// siempre resuelve la misma lista de pantallas.
import type {
AnswerValue,
ConditionalRule,
FormAnswers,
FormSchema,
Question,
Screen,
WizardState,
} from './form-engine.types'
const DEFAULT_INSTANCE = 'default'
export function answerKey(code: string, instanceId: string = DEFAULT_INSTANCE): string {
return `${code}:${instanceId}`
}
function getAnswer(answers: FormAnswers, code: string, instanceId = DEFAULT_INSTANCE): AnswerValue {
const v = answers[answerKey(code, instanceId)]
return v === undefined ? null : v
}
/**
* Evalúa una regla individual. `if_question` siempre se resuelve contra
* instance_id='default': en el vocabulario v3 los disparadores (3.1, 4.1)
* nunca son preguntas de instancia. Si en el futuro una regla necesitara
* compararse contra la respuesta de la PROPIA instancia, este es el punto
* a extender (hoy no hace falta — no hay caso de uso en el seed v3).
*/
export function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
switch (rule.op) {
case 'eq':
return getAnswer(answers, rule.if_question) === rule.value
case 'eq_any':
return rule.if_question.some((code) => getAnswer(answers, code) === rule.value)
case 'not_in': {
const v = getAnswer(answers, rule.if_question)
if (typeof v !== 'string') return false
return !rule.value.includes(v)
}
case 'contains': {
const v = getAnswer(answers, rule.if_question)
return Array.isArray(v) && v.includes(rule.value)
}
case 'eq_sole': {
const v = getAnswer(answers, rule.if_question)
return Array.isArray(v) && v.length === 1 && v[0] === rule.value
}
default:
return false
}
}
/**
* Semántica de combinación show/hide (ver reporte en la conversación):
* 1. hide es veto absoluto — si CUALQUIER regla hide coincide, se oculta.
* 2. Si ninguna hide coincide y hay reglas show, se muestra solo si
* CUALQUIERA show coincide (OR); si hay show pero ninguna coincide, se oculta.
* 3. Si no hay ninguna regla show (solo hide, o array vacío/null), se
* muestra por defecto.
*/
export function resolveVisibility(question: Question, answers: FormAnswers): boolean {
const rules = question.conditional_rules
if (!rules || rules.length === 0) return true
const hideRules = rules.filter((r) => r.action === 'hide')
const showRules = rules.filter((r) => r.action === 'show')
if (hideRules.some((r) => evaluateRule(r, answers))) return false
if (showRules.length > 0) return showRules.some((r) => evaluateRule(r, answers))
return true
}
export interface InstanceRef {
instanceId: string
optionValue: string
}
/**
* Deriva las instancias activas desde la respuesta del generador.
* El orden e id de cada instancia se basan en la POSICIÓN FIJA de la opción
* dentro de `generator.options` (definida por el esquema), nunca en el orden
* de selección del usuario ni en un índice del array de respuesta — así una
* instancia existente conserva su id estable aunque se agregue o quite otro
* vínculo (lección del mockup: el índice se desincroniza con las instancias).
*/
export function expandInstances(generator: Question, answers: FormAnswers): InstanceRef[] {
const selected = getAnswer(answers, generator.code)
if (!Array.isArray(selected) || !generator.options) return []
const refs: InstanceRef[] = []
generator.options.forEach((opt, idx) => {
if (selected.includes(opt.value)) {
refs.push({ instanceId: `familiar_${idx + 1}`, optionValue: opt.value })
}
})
return refs
}
export function buildFormSchema(rows: Question[]): FormSchema {
return { questions: [...rows].sort((a, b) => a.position - b.position) }
}
/**
* Resuelve la lista ordenada de pantallas visibles dado el esquema y el
* estado de respuestas actual. Pura: mismo input → mismo output siempre.
* Las preguntas instance_of se expanden agrupadas POR INSTANCIA (no por
* pregunta), para reproducir el orden familiar1: 4.2→4.3, familiar2: 4.2→4.3...
*/
export function resolveScreens(schema: FormSchema, answers: FormAnswers): Screen[] {
const screens: Screen[] = []
const consumedGroups = new Set<string>()
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
for (const q of schema.questions) {
if (q.instance_of) {
if (consumedGroups.has(q.instance_of)) continue
consumedGroups.add(q.instance_of)
const generator = byCode.get(q.instance_of)
if (!generator) continue
const instances = expandInstances(generator, answers)
const groupRows = schema.questions
.filter((r) => r.instance_of === q.instance_of)
.sort((a, b) => a.position - b.position)
for (const instance of instances) {
for (const row of groupRows) {
if (resolveVisibility(row, answers)) {
screens.push({
id: `question:${row.code}:${instance.instanceId}`,
kind: 'question',
question: row,
instanceId: instance.instanceId,
})
}
}
}
continue
}
if (q.type === 'section') {
if (resolveVisibility(q, answers)) {
screens.push({ id: `section:${q.code}`, kind: 'section', question: q, instanceId: null })
}
continue
}
if (resolveVisibility(q, answers)) {
screens.push({ id: `question:${q.code}`, kind: 'question', question: q, instanceId: null })
}
}
screens.push({ id: 'end', kind: 'end', question: null, instanceId: null })
return screens
}
export function initialState(schema: FormSchema): WizardState {
const screens = resolveScreens(schema, {})
return { answers: {}, currentScreenId: screens[0]?.id ?? 'end' }
}
export function setAnswer(
state: WizardState,
code: string,
value: AnswerValue,
instanceId: string = DEFAULT_INSTANCE
): WizardState {
return {
...state,
answers: { ...state.answers, [answerKey(code, instanceId)]: value },
}
}
/** Exportada porque también la usa el validador de submit server-side (4G). */
export function isAnswerEmpty(v: AnswerValue | undefined): boolean {
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
}
/**
* Aplica una respuesta y limpia las respuestas de pantallas que dejaron de
* ser visibles como CONSECUENCIA de ese cambio (ej.: sacar un vínculo de 4.1
* borra las respuestas de 4.2/4.3 de esa instancia puntual; las de las demás
* instancias quedan intactas porque conservan su propio answerKey). Esto es
* consecuencia de flujo (ADR-005), no presentación — por eso vive en el
* motor y no en el componente.
*/
export function applyAnswer(
state: WizardState,
schema: FormSchema,
code: string,
value: AnswerValue,
instanceId: string = DEFAULT_INSTANCE
): WizardState {
const updated = setAnswer(state, code, value, instanceId)
const before = resolveScreens(schema, state.answers)
const after = resolveScreens(schema, updated.answers)
const afterIds = new Set(after.map((s) => s.id))
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
if (disappeared.length === 0) return updated
const clearedAnswers = { ...updated.answers }
for (const s of disappeared) {
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
}
return { ...updated, answers: clearedAnswers }
}
/**
* true si la pantalla es una pregunta `required` y todavía no tiene
* respuesta — incluye preguntas de instancia (cada instancia se evalúa por
* su propio answerKey, independiente de las demás). El seed v3 actual no
* marca ninguna pregunta de instancia como required, pero el camino queda
* cubierto para cuando exista una.
*/
export function isRequiredAndUnanswered(screen: Screen, answers: FormAnswers): boolean {
if (!screen.question?.required) return false
return isAnswerEmpty(answers[answerKey(screen.question.code, screen.instanceId ?? undefined)])
}
/**
* Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
* respuestas actuales y busca la pantalla actual por ID (nunca por índice
* guardado de antes) — así, si el cambio de respuesta ocultó/agregó
* pantallas, next() sigue aterrizando en la pantalla correcta.
*/
export function next(state: WizardState, schema: FormSchema): WizardState {
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const nextIdx = Math.max(Math.min(idx + 1, screens.length - 1), 0)
return { ...state, currentScreenId: screens[nextIdx].id }
}
export function back(state: WizardState, schema: FormSchema): WizardState {
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const prevIdx = idx <= 0 ? 0 : idx - 1
return { ...state, currentScreenId: screens[prevIdx]?.id ?? state.currentScreenId }
}