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>
245 lines
9.0 KiB
TypeScript
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 }
|
|
}
|