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
|
||||
|
||||
### [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
|
||||
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
|
||||
- **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".
|
||||
> 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,
|
||||
pero requiere gate de auth antes de exponer.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -138,4 +144,5 @@ por inadecuación técnica y normativa, no por desconocimiento.
|
||||
## 8. Historial de revisiones de este documento
|
||||
| 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
|
||||
> (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
|
||||
> minimizar re-identificación.
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use server'
|
||||
import { headers } from 'next/headers'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createHash } from 'crypto'
|
||||
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 =
|
||||
| { success: true }
|
||||
@@ -9,103 +12,83 @@ export type SubmitResult =
|
||||
|
||||
export async function submitSurvey(payload: {
|
||||
surveyId: string
|
||||
answers: Record<string, unknown>
|
||||
questionMap: Record<string, string> // código → question_id UUID
|
||||
answers: SubmitAnswerEntry[]
|
||||
consentOperativo: boolean
|
||||
consentMacroN1: boolean
|
||||
}): 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 rawIp =
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const salt = process.env.IP_HASH_SALT ?? ''
|
||||
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||
|
||||
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon)
|
||||
// NUNCA usar service_role — el anon key respeta RLS
|
||||
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
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
|
||||
.from('surveys')
|
||||
.select('org_id, consent_version_id')
|
||||
.select('id, org_id, status')
|
||||
.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.
|
||||
//
|
||||
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que
|
||||
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe
|
||||
// (correcto — anon no debe leer consent_records de otros respondentes).
|
||||
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT.
|
||||
const consentId = randomUUID()
|
||||
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)
|
||||
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||
// nunca contra lo que el cliente afirma que vio.
|
||||
const { data: questionRows, error: qErr } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', payload.surveyId)
|
||||
|
||||
if (answerRows.length > 0) {
|
||||
const { error: aErr } = await supabase.from('answers').insert(answerRows)
|
||||
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` }
|
||||
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'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 { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
||||
import {
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
back,
|
||||
resolveScreens,
|
||||
} 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 { ProgressBar } from './progress-bar'
|
||||
import { QuestionRenderer } from './question-renderer'
|
||||
|
||||
type Stage = 'consent' | 'form'
|
||||
type Stage = 'consent' | 'form' | 'success' | 'error'
|
||||
|
||||
interface WizardFormProps {
|
||||
survey: Survey
|
||||
@@ -55,6 +56,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||
const answerAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
const currentValue = valueFor(screen, state.answers)
|
||||
@@ -159,6 +199,33 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -219,8 +286,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
<div className="wizard-section-screen">
|
||||
<h2 className="wizard-section-title">Llegaste al final</h2>
|
||||
<p className="wizard-section-desc">
|
||||
El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus
|
||||
respuestas con "Anterior".
|
||||
Revisá tus respuestas con "Anterior" si querés corregir algo, o enviá la
|
||||
encuesta cuando estés listo/a.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -231,8 +298,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleBack}
|
||||
disabled={idx === 0}
|
||||
aria-disabled={idx === 0}
|
||||
disabled={idx === 0 || isPending}
|
||||
aria-disabled={idx === 0 || isPending}
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
@@ -244,8 +311,14 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
Siguiente
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary btn-disabled" disabled aria-disabled="true">
|
||||
Enviar (próximamente)
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
||||
</button>
|
||||
)}
|
||||
</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)
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
|
||||
Reference in New Issue
Block a user