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>
This commit is contained in:
22
BACKLOG.md
22
BACKLOG.md
@@ -7,6 +7,28 @@
|
|||||||
|
|
||||||
## Seguridad
|
## Seguridad
|
||||||
|
|
||||||
|
### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo)
|
||||||
|
- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G
|
||||||
|
- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único
|
||||||
|
camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)`
|
||||||
|
que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL
|
||||||
|
(el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó).
|
||||||
|
La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias
|
||||||
|
legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`)
|
||||||
|
porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G).
|
||||||
|
- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar
|
||||||
|
por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes
|
||||||
|
que igual pase las validaciones estructurales de la RPC.
|
||||||
|
- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo
|
||||||
|
(`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato
|
||||||
|
en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad
|
||||||
|
de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición
|
||||||
|
de datos.
|
||||||
|
- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto
|
||||||
|
server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin
|
||||||
|
el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar.
|
||||||
|
- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance).
|
||||||
|
|
||||||
### [SEC-002] Sprint de compliance & seguridad
|
### [SEC-002] Sprint de compliance & seguridad
|
||||||
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
|
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
|
||||||
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
|
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
|
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
|
||||||
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
|
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
|
||||||
> Última actualización: 2026-06-20 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
> Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,6 +78,12 @@ señal de inmadurez en seguridad.
|
|||||||
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
|
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
|
||||||
pero requiere gate de auth antes de exponer.
|
pero requiere gate de auth antes de exponer.
|
||||||
- **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente.
|
- **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente.
|
||||||
|
- **SEC-004** (ver `BACKLOG.md`): el submit v3 (`rpc_submit_response`, Etapa 4G) cerró el INSERT
|
||||||
|
directo de `anon` sobre `consent_records`/`responses`/`answers` (antes `WITH CHECK(true)`).
|
||||||
|
La RPC valida estructura en SQL; la coherencia de flujo (visibilidad, instancias legítimas) se
|
||||||
|
valida en TypeScript antes de llamarla — quien la invoque directo por REST puede evadir esa
|
||||||
|
segunda capa. No es un riesgo de privacidad (el dato sigue anónimo y sujeto a k-anonimato),
|
||||||
|
es de calidad de datos de investigación.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,3 +145,4 @@ por inadecuación técnica y normativa, no por desconocimiento.
|
|||||||
| Fecha | Versión producto | Cambio |
|
| Fecha | Versión producto | Cambio |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
|
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
|
||||||
|
| 2026-06-24 | 2.1.0 / 3.0.0-dev | Etapa 4G: submit v3 vía RPC SECURITY DEFINER, cierra INSERT directo de anon. Registra SEC-004 (riesgo residual de bypass vía REST, ver BACKLOG.md). |
|
||||||
@@ -97,6 +97,11 @@ qi_zone text
|
|||||||
```
|
```
|
||||||
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
|
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
|
||||||
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
|
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
|
||||||
|
|
||||||
|
> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4`
|
||||||
|
> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta
|
||||||
|
> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional
|
||||||
|
> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2.
|
||||||
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
|
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
|
||||||
> minimizar re-identificación.
|
> minimizar re-identificación.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'use server'
|
'use server'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { createHash, randomUUID } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { buildFormSchema } from '@/lib/form-engine'
|
||||||
|
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||||
|
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
|
||||||
|
|
||||||
export type SubmitResult =
|
export type SubmitResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
@@ -9,103 +12,83 @@ export type SubmitResult =
|
|||||||
|
|
||||||
export async function submitSurvey(payload: {
|
export async function submitSurvey(payload: {
|
||||||
surveyId: string
|
surveyId: string
|
||||||
answers: Record<string, unknown>
|
answers: SubmitAnswerEntry[]
|
||||||
questionMap: Record<string, string> // código → question_id UUID
|
|
||||||
consentOperativo: boolean
|
consentOperativo: boolean
|
||||||
consentMacroN1: boolean
|
consentMacroN1: boolean
|
||||||
}): Promise<SubmitResult> {
|
}): Promise<SubmitResult> {
|
||||||
// 1. ip_hash: server-only, nunca en texto plano
|
// ip_hash: server-only, nunca en texto plano (dedup sin identidad)
|
||||||
const headersList = await headers()
|
const headersList = await headers()
|
||||||
const rawIp =
|
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
|
||||||
const salt = process.env.IP_HASH_SALT ?? ''
|
const salt = process.env.IP_HASH_SALT ?? ''
|
||||||
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||||
|
|
||||||
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon)
|
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||||
// NUNCA usar service_role — el anon key respeta RLS
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||||
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
|
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
// 3. Leer survey para obtener org_id y consent_version_id
|
// 1. Validación #1: survey existe y está activa
|
||||||
const { data: survey, error: sErr } = await supabase
|
const { data: survey, error: sErr } = await supabase
|
||||||
.from('surveys')
|
.from('surveys')
|
||||||
.select('org_id, consent_version_id')
|
.select('id, org_id, status')
|
||||||
.eq('id', payload.surveyId)
|
.eq('id', payload.surveyId)
|
||||||
.single()
|
.eq('status', 'live')
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
if (sErr || !survey) return { success: false, error: 'survey_not_found' }
|
if (sErr) return { success: false, error: 'survey_lookup_failed' }
|
||||||
|
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
|
||||||
|
|
||||||
// 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT.
|
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||||
//
|
// nunca contra lo que el cliente afirma que vio.
|
||||||
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que
|
const { data: questionRows, error: qErr } = await supabase
|
||||||
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe
|
.from('questions')
|
||||||
// (correcto — anon no debe leer consent_records de otros respondentes).
|
.select(
|
||||||
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT.
|
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||||
const consentId = randomUUID()
|
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||||
const responseId = randomUUID()
|
|
||||||
|
|
||||||
// 5. INSERT consent_record (inmutable, ADR-003)
|
|
||||||
// Sin .select() para no activar RETURNING y evitar el error RLS 42501.
|
|
||||||
const { error: cErr } = await supabase
|
|
||||||
.from('consent_records')
|
|
||||||
.insert({
|
|
||||||
id: consentId,
|
|
||||||
consent_version_id: survey.consent_version_id,
|
|
||||||
granted_scopes: {
|
|
||||||
operativo: payload.consentOperativo,
|
|
||||||
macro_n1: payload.consentMacroN1,
|
|
||||||
},
|
|
||||||
granted_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` }
|
|
||||||
|
|
||||||
// 6. Extraer qi_* de las respuestas
|
|
||||||
const qi_age_bucket =
|
|
||||||
typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null
|
|
||||||
const qi_sector =
|
|
||||||
typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null
|
|
||||||
const qi_zone =
|
|
||||||
typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null
|
|
||||||
|
|
||||||
// 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md)
|
|
||||||
const { error: rErr } = await supabase
|
|
||||||
.from('responses')
|
|
||||||
.insert({
|
|
||||||
id: responseId,
|
|
||||||
survey_id: payload.surveyId,
|
|
||||||
company_id: survey.org_id,
|
|
||||||
respondent_id: null,
|
|
||||||
ip_hash: ipHash,
|
|
||||||
consent_record_id: consentId,
|
|
||||||
qi_age_bucket: qi_age_bucket || null,
|
|
||||||
qi_sector: qi_sector || null,
|
|
||||||
qi_zone: qi_zone || null,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` }
|
|
||||||
|
|
||||||
// 8. INSERT answers — una fila por pregunta respondida
|
|
||||||
const answerRows = Object.entries(payload.answers)
|
|
||||||
.map(([code, value]) => ({
|
|
||||||
response_id: responseId,
|
|
||||||
question_id: payload.questionMap[code],
|
|
||||||
value,
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.question_id &&
|
|
||||||
row.value !== null &&
|
|
||||||
row.value !== undefined &&
|
|
||||||
row.value !== '' &&
|
|
||||||
!(Array.isArray(row.value) && row.value.length === 0)
|
|
||||||
)
|
)
|
||||||
|
.eq('survey_id', payload.surveyId)
|
||||||
|
|
||||||
if (answerRows.length > 0) {
|
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||||
const { error: aErr } = await supabase.from('answers').insert(answerRows)
|
|
||||||
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` }
|
const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
|
||||||
|
|
||||||
|
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
|
||||||
|
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
|
||||||
|
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
|
||||||
|
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
|
||||||
|
// decisión a confirmar con el director, no hay un mapeo exacto.
|
||||||
|
const findDefault = (code: string): string | null => {
|
||||||
|
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
|
||||||
|
return typeof entry?.value === 'string' ? entry.value : null
|
||||||
|
}
|
||||||
|
const qiZone = findDefault('2.1')
|
||||||
|
const qiAgeBucket = findDefault('2.4')
|
||||||
|
const qiSector = findDefault('2.6')
|
||||||
|
|
||||||
|
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
|
||||||
|
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
|
||||||
|
// INSERTs sueltos desde acá.
|
||||||
|
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
|
||||||
|
p_survey_id: payload.surveyId,
|
||||||
|
p_ip_hash: ipHash,
|
||||||
|
p_qi_age_bucket: qiAgeBucket,
|
||||||
|
p_qi_sector: qiSector,
|
||||||
|
p_qi_zone: qiZone,
|
||||||
|
p_consent_operativo: payload.consentOperativo,
|
||||||
|
p_consent_macro_n1: payload.consentMacroN1,
|
||||||
|
p_answers: result.rows.map((r) => ({
|
||||||
|
question_id: r.questionId,
|
||||||
|
instance_id: r.instanceId,
|
||||||
|
value: r.value,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rpcErr) return { success: false, error: 'submit_failed' }
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
|
||||||
import type { BrandConfig, Survey } from '@/lib/types'
|
import type { BrandConfig, Survey } from '@/lib/types'
|
||||||
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
back,
|
back,
|
||||||
resolveScreens,
|
resolveScreens,
|
||||||
} from '@/lib/form-engine'
|
} from '@/lib/form-engine'
|
||||||
import { loadDraft, saveDraft } from '@/lib/storage'
|
import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
|
||||||
|
import { submitSurvey } from '@/app/actions/submit-survey'
|
||||||
import { ConsentScreen } from './consent-screen'
|
import { ConsentScreen } from './consent-screen'
|
||||||
import { ProgressBar } from './progress-bar'
|
import { ProgressBar } from './progress-bar'
|
||||||
import { QuestionRenderer } from './question-renderer'
|
import { QuestionRenderer } from './question-renderer'
|
||||||
|
|
||||||
type Stage = 'consent' | 'form'
|
type Stage = 'consent' | 'form' | 'success' | 'error'
|
||||||
|
|
||||||
interface WizardFormProps {
|
interface WizardFormProps {
|
||||||
survey: Survey
|
survey: Survey
|
||||||
@@ -55,6 +56,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||||
const answerAreaRef = useRef<HTMLDivElement>(null)
|
const answerAreaRef = useRef<HTMLDivElement>(null)
|
||||||
const [hasOverflow, setHasOverflow] = useState(false)
|
const [hasOverflow, setHasOverflow] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draft = loadDraft(survey.id)
|
const draft = loadDraft(survey.id)
|
||||||
@@ -112,6 +115,43 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stage === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="survey-wrapper">
|
||||||
|
<div className="thank-you-container" role="main">
|
||||||
|
<h1 className="thank-you-title">¡Gracias por participar!</h1>
|
||||||
|
<p className="thank-you-body">
|
||||||
|
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
|
||||||
|
forma de vincular tus respuestas con tu identidad.
|
||||||
|
</p>
|
||||||
|
<p className="thank-you-body">
|
||||||
|
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
|
||||||
|
diseñar mejores apoyos para las personas cuidadoras.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="survey-wrapper">
|
||||||
|
<div className="error-container" role="main">
|
||||||
|
<h1 className="error-title">No se pudo enviar la encuesta</h1>
|
||||||
|
<p>Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.</p>
|
||||||
|
{submitError && <pre className="no-survey-hint">{submitError}</pre>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => { setSubmitError(null); setStage('form') }}
|
||||||
|
>
|
||||||
|
Volver e intentar de nuevo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!screen) return null
|
if (!screen) return null
|
||||||
|
|
||||||
const currentValue = valueFor(screen, state.answers)
|
const currentValue = valueFor(screen, state.answers)
|
||||||
@@ -159,6 +199,33 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
setState((s) => back(s, schema))
|
setState((s) => back(s, schema))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (isPending) return
|
||||||
|
// state.answers usa claves "code:instanceId" — se descompone una sola vez
|
||||||
|
// acá, al borde cliente/servidor; el validador (lib/submit-validation.ts)
|
||||||
|
// recibe la forma explícita {code, instanceId, value}[].
|
||||||
|
const answers = Object.entries(state.answers).map(([key, value]) => {
|
||||||
|
const sep = key.indexOf(':')
|
||||||
|
return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value }
|
||||||
|
})
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await submitSurvey({
|
||||||
|
surveyId: survey.id,
|
||||||
|
answers,
|
||||||
|
consentOperativo,
|
||||||
|
consentMacroN1,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
clearDraft(survey.id)
|
||||||
|
setStage('success')
|
||||||
|
} else {
|
||||||
|
setSubmitError(result.error)
|
||||||
|
setStage('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const bc: BrandConfig | null = survey.brand_config
|
const bc: BrandConfig | null = survey.brand_config
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -219,8 +286,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
<div className="wizard-section-screen">
|
<div className="wizard-section-screen">
|
||||||
<h2 className="wizard-section-title">Llegaste al final</h2>
|
<h2 className="wizard-section-title">Llegaste al final</h2>
|
||||||
<p className="wizard-section-desc">
|
<p className="wizard-section-desc">
|
||||||
El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus
|
Revisá tus respuestas con "Anterior" si querés corregir algo, o enviá la
|
||||||
respuestas con "Anterior".
|
encuesta cuando estés listo/a.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -231,8 +298,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0 || isPending}
|
||||||
aria-disabled={idx === 0}
|
aria-disabled={idx === 0 || isPending}
|
||||||
>
|
>
|
||||||
Anterior
|
Anterior
|
||||||
</button>
|
</button>
|
||||||
@@ -244,8 +311,14 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
|||||||
Siguiente
|
Siguiente
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-primary btn-disabled" disabled aria-disabled="true">
|
<button
|
||||||
Enviar (próximamente)
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ export function setAnswer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAnswerEmpty(v: AnswerValue | undefined): boolean {
|
/** 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)
|
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
232
lib/submit-validation.test.mts
Normal file
232
lib/submit-validation.test.mts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// Tests Node puros (sin navegador, sin DB) del validador server-side de submit.
|
||||||
|
// Ejecutar: node --test lib/submit-validation.test.mts
|
||||||
|
import { test, describe } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { buildFormSchema } from './form-engine.ts'
|
||||||
|
import { validateSubmission } from './submit-validation.ts'
|
||||||
|
import type { ConditionalRule, Question } from './form-engine.types.ts'
|
||||||
|
import type { SubmitAnswerEntry } from './submit-validation.ts'
|
||||||
|
|
||||||
|
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
|
||||||
|
return {
|
||||||
|
id: partial.code,
|
||||||
|
is_sensitive: false,
|
||||||
|
required: false,
|
||||||
|
hint: null,
|
||||||
|
options: null,
|
||||||
|
max_select: null,
|
||||||
|
conditional_rules: null,
|
||||||
|
instance_generator: false,
|
||||||
|
instance_of: null,
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const r_show_si_3_1: ConditionalRule[] = [
|
||||||
|
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const VINCULOS = ['Hijo menor con discapacidad', 'Familiar mayor con discapacidad', 'Otro / Especificar']
|
||||||
|
|
||||||
|
function buildFixtureSchema() {
|
||||||
|
const rows: Question[] = [
|
||||||
|
q({ code: 'S2', type: 'section', prompt: 'Datos generales', position: 20 }),
|
||||||
|
q({
|
||||||
|
code: '2.5', type: 'single_choice', prompt: 'Género', position: 25, required: true,
|
||||||
|
options: [{ value: 'Femenino', label: 'Femenino' }, { value: 'Masculino', label: 'Masculino' }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
q({ code: 'S3', type: 'section', prompt: 'Prevalencia', position: 30 }),
|
||||||
|
q({
|
||||||
|
code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31,
|
||||||
|
options: [{ value: 'Sí', label: 'Sí' }, { value: 'No', label: 'No' }],
|
||||||
|
}),
|
||||||
|
q({
|
||||||
|
code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_3_1,
|
||||||
|
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
|
||||||
|
q({
|
||||||
|
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41, instance_generator: true,
|
||||||
|
options: VINCULOS.map((v) => ({ value: v, label: v })),
|
||||||
|
}),
|
||||||
|
q({
|
||||||
|
code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1',
|
||||||
|
max_select: 2,
|
||||||
|
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return buildFormSchema(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path: 2 familiares ──────────────────────────────────────────────
|
||||||
|
test('happy path: submit válido con 2 familiares — payload completo con instance_id', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '3.1', instanceId: 'default', value: 'No' }, // oculta 3.2 — no se manda
|
||||||
|
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0], VINCULOS[1]] },
|
||||||
|
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual'] },
|
||||||
|
{ code: '4.2', instanceId: 'familiar_2', value: ['Auditiva'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
if (!result.ok) return
|
||||||
|
|
||||||
|
// ── Dato concreto pedido en el reporte: filas reales con instance_id ──
|
||||||
|
console.log('\nPayload validado → filas listas para la RPC (questionId/instanceId/value):')
|
||||||
|
console.log(JSON.stringify(result.rows, null, 2))
|
||||||
|
|
||||||
|
assert.equal(result.rows.length, 5)
|
||||||
|
const f1 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_1')
|
||||||
|
const f2 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_2')
|
||||||
|
assert.deepEqual(f1?.value, ['Visual'])
|
||||||
|
assert.deepEqual(f2?.value, ['Auditiva'])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rechazos — las 7 validaciones', () => {
|
||||||
|
test('rechaza respuesta a una pregunta que el flujo nunca debió mostrar (3.2 con 3.1=No)', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '3.1', instanceId: 'default', value: 'No' },
|
||||||
|
{ code: '3.2', instanceId: 'default', value: ['Visual'] }, // 3.1='No' la oculta
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('question_should_be_hidden:3.2')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza valor fuera de las opciones válidas', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '3.1', instanceId: 'default', value: 'Tal vez' }, // no es 'Sí' ni 'No'
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.includes('value_not_in_options') && e.includes('3.1')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza max_select excedido (4.2 tiene max_select=2)', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] },
|
||||||
|
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual', 'Auditiva', 'Visual'] }, // 3 > max_select(2)
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.includes('exceeds_max_select')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza instancia inventada (familiar que 4.1 no generó)', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, // genera solo familiar_1
|
||||||
|
{ code: '4.2', instanceId: 'familiar_2', value: ['Visual'] }, // familiar_2 NO existe
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('invalid_instance:4.2:familiar_2')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza instance_id en una pregunta que no es de instancia', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'familiar_1', value: 'Femenino' }, // 2.5 no es instance_of nada
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('unexpected_instance_id:2.5')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza sin consentimiento operativo', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [{ code: '2.5', instanceId: 'default', value: 'Femenino' }]
|
||||||
|
const result = validateSubmission(schema, entries, false)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.includes('consent_operativo_required'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza pregunta requerida visible sin responder', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
// 2.5 es required=true y no se manda
|
||||||
|
const entries: SubmitAnswerEntry[] = [{ code: '3.1', instanceId: 'default', value: 'No' }]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('required_missing:2.5')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza pregunta que no existe en el esquema (code inventado)', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('unknown_question:99.9')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza respuesta a una fila type=section', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: 'S4', instanceId: 'default', value: 'algo' },
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.startsWith('section_is_not_answerable:S4')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechaza valor de tipo incorrecto (string donde se espera array)', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '4.1', instanceId: 'default', value: 'Hijo menor con discapacidad' as unknown as string[] },
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.some((e) => e.includes('expected_array_value')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reporta TODOS los errores encontrados, no solo el primero', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
|
||||||
|
{ code: 'S4', instanceId: 'default', value: 'algo' },
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
if (result.ok) return
|
||||||
|
assert.ok(result.errors.length >= 2, `esperaba >=2 errores, hubo ${result.errors.length}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('valores vacíos', () => {
|
||||||
|
test('una entrada con valor vacío no se valida ni se persiste, pero no bloquea el submit', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const entries: SubmitAnswerEntry[] = [
|
||||||
|
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||||
|
{ code: '3.1', instanceId: 'default', value: '' }, // vacío — se ignora, no se evalúa su visibilidad
|
||||||
|
]
|
||||||
|
const result = validateSubmission(schema, entries, true)
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
if (!result.ok) return
|
||||||
|
assert.equal(result.rows.some((r) => r.questionId === '3.1'), false)
|
||||||
|
})
|
||||||
|
})
|
||||||
150
lib/submit-validation.ts
Normal file
150
lib/submit-validation.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// 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 }
|
||||||
|
}
|
||||||
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal file
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Sprint 4 (v3.0.0) — Etapa 4G: submit transaccional + cierre de WITH CHECK(true)
|
||||||
|
--
|
||||||
|
-- PROBLEMA QUE CIERRA: answers_insert/responses_insert/consent_records_insert
|
||||||
|
-- permitían INSERT directo a anon con casi ninguna restricción real
|
||||||
|
-- (answers_insert: WITH CHECK(true) literal) — el cliente era la única
|
||||||
|
-- fuente de verdad sobre qué se guardaba. Closes BRIEF decisión 15.
|
||||||
|
--
|
||||||
|
-- DISEÑO — dos capas, cada una validando lo que le corresponde:
|
||||||
|
-- 1) lib/form-engine.ts + lib/submit-validation.ts, en el Server Action
|
||||||
|
-- (Next.js, TypeScript): valida COHERENCIA DE FLUJO — visibilidad,
|
||||||
|
-- instancias legítimas, opciones válidas, requeridas. Esto NO puede
|
||||||
|
-- vivir en SQL sin reimplementar el motor (prohibido explícitamente).
|
||||||
|
-- 2) Esta RPC (Postgres, SECURITY DEFINER): valida ESTRUCTURA — el
|
||||||
|
-- question_id pertenece a esta encuesta, la encuesta está viva, el
|
||||||
|
-- consentimiento llegó — y persiste todo de forma ATÓMICA (una
|
||||||
|
-- llamada a función = una transacción; cualquier RAISE EXCEPTION
|
||||||
|
-- revierte TODO, sin huérfanos).
|
||||||
|
--
|
||||||
|
-- POR QUÉ SECURITY DEFINER: se revoca el INSERT directo de anon sobre las
|
||||||
|
-- 3 tablas (paso 1 abajo). Sin SECURITY DEFINER, esta función correría
|
||||||
|
-- con los privilegios de quien la llama (anon) y esas mismas policies
|
||||||
|
-- recién revocadas la bloquearían a ELLA también. SECURITY DEFINER la
|
||||||
|
-- hace correr con los privilegios de quien la creó (el owner de la
|
||||||
|
-- migración, no anon) — por eso puede insertar después de que anon ya
|
||||||
|
-- no puede hacerlo directo. Mismo patrón que las RPCs de dashboard
|
||||||
|
-- existentes (rpc_dashboard_kpis, etc.), aplicado por primera vez a
|
||||||
|
-- escritura en vez de lectura.
|
||||||
|
--
|
||||||
|
-- LOS GRANTS DE TABLA (migration 006: GRANT INSERT ON answers TO anon...)
|
||||||
|
-- NO se tocan acá — siguen el "diseño de capas" que esa migración ya
|
||||||
|
-- documenta: Capa 1 (GRANT) da la capacidad de intentar el INSERT,
|
||||||
|
-- Capa 2 (RLS, esta migración) lo deniega por default al no quedar
|
||||||
|
-- ninguna policy permisiva. anon puede seguir "intentando" un INSERT
|
||||||
|
-- directo — RLS lo rechaza igual, con RLS habilitado (migration 002) y
|
||||||
|
-- cero policies de INSERT restantes en estas 3 tablas.
|
||||||
|
--
|
||||||
|
-- RIESGO RESIDUAL DOCUMENTADO (no resuelto en esta etapa, ver reporte):
|
||||||
|
-- cualquiera que llame a esta RPC directamente vía la REST API de
|
||||||
|
-- Supabase (no a través de nuestro Server Action) puede mandar un
|
||||||
|
-- payload que pase las validaciones ESTRUCTURALES de acá pero sea
|
||||||
|
-- incoherente de flujo — porque el motor de flujo es TypeScript y no
|
||||||
|
-- corre dentro de Postgres. Es un problema de calidad de datos de
|
||||||
|
-- investigación, no de privacidad/k-anonimato (el dato sigue siendo
|
||||||
|
-- anónimo, is_sensitive, sujeto al umbral k en agg_segment). Cerrarlo
|
||||||
|
-- del todo excede el scope de 4G — candidato a BACKLOG.md si se decide
|
||||||
|
-- perseguirlo (ej. firma del payload por el Server Action).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ── 1. Revoca el INSERT directo — el único camino pasa a ser la RPC ───────
|
||||||
|
DROP POLICY IF EXISTS "consent_records_insert" ON public.consent_records;
|
||||||
|
DROP POLICY IF EXISTS "responses_insert" ON public.responses;
|
||||||
|
DROP POLICY IF EXISTS "answers_insert" ON public.answers;
|
||||||
|
|
||||||
|
-- ── 2. RPC de submit ────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.rpc_submit_response(
|
||||||
|
p_survey_id uuid,
|
||||||
|
p_ip_hash text,
|
||||||
|
p_qi_age_bucket text,
|
||||||
|
p_qi_sector text,
|
||||||
|
p_qi_zone text,
|
||||||
|
p_consent_operativo boolean,
|
||||||
|
p_consent_macro_n1 boolean,
|
||||||
|
p_answers jsonb -- [{"question_id":"...","instance_id":"familiar_1","value":...}, ...]
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_org_id uuid;
|
||||||
|
v_cv_id uuid;
|
||||||
|
v_consent_id uuid := gen_random_uuid();
|
||||||
|
v_response_id uuid := gen_random_uuid();
|
||||||
|
v_bad_refs int;
|
||||||
|
BEGIN
|
||||||
|
-- Defensa en profundidad: el Server Action ya confirmó esto antes de
|
||||||
|
-- llamar, pero esta función no debe confiar en el caller.
|
||||||
|
SELECT org_id, consent_version_id INTO v_org_id, v_cv_id
|
||||||
|
FROM public.surveys
|
||||||
|
WHERE id = p_survey_id AND status = 'live';
|
||||||
|
|
||||||
|
IF v_org_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'survey_not_found_or_not_live' USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT p_consent_operativo THEN
|
||||||
|
RAISE EXCEPTION 'consent_operativo_required' USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Chequeo ESTRUCTURAL (no de flujo): cada question_id recibido existe
|
||||||
|
-- y pertenece a ESTA encuesta; cada instance_id viene no-vacío.
|
||||||
|
-- Visibilidad/instancia-legítima/opciones-válidas ya se validó en TS.
|
||||||
|
SELECT count(*) INTO v_bad_refs
|
||||||
|
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb)
|
||||||
|
LEFT JOIN public.questions q ON q.id = r.question_id AND q.survey_id = p_survey_id
|
||||||
|
WHERE q.id IS NULL OR r.instance_id IS NULL OR r.instance_id = '';
|
||||||
|
|
||||||
|
IF v_bad_refs > 0 THEN
|
||||||
|
RAISE EXCEPTION 'invalid_answer_reference' USING ERRCODE = 'check_violation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- consent_record (inmutable, ADR-003)
|
||||||
|
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes, granted_at)
|
||||||
|
VALUES (
|
||||||
|
v_consent_id, v_cv_id,
|
||||||
|
jsonb_build_object('operativo', p_consent_operativo, 'macro_n1', p_consent_macro_n1),
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- response (respondent_id SIEMPRE NULL — encuesta anónima, regla #2 CLAUDE.md)
|
||||||
|
INSERT INTO public.responses (
|
||||||
|
id, survey_id, company_id, respondent_id, ip_hash,
|
||||||
|
consent_record_id, qi_age_bucket, qi_sector, qi_zone
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
v_response_id, p_survey_id, v_org_id, NULL, p_ip_hash,
|
||||||
|
v_consent_id, p_qi_age_bucket, p_qi_sector, p_qi_zone
|
||||||
|
);
|
||||||
|
|
||||||
|
-- answers — un row por entrada validada, con su instance_id real
|
||||||
|
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||||
|
SELECT v_response_id, r.question_id, r.instance_id, r.value
|
||||||
|
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb);
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('response_id', v_response_id);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.rpc_submit_response(
|
||||||
|
uuid, text, text, text, text, boolean, boolean, jsonb
|
||||||
|
) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.rpc_submit_response(
|
||||||
|
uuid, text, text, text, text, boolean, boolean, jsonb
|
||||||
|
) TO anon, authenticated;
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [{ "name": "next" }],
|
||||||
|
|||||||
Reference in New Issue
Block a user