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:
markosbenitez
2026-06-24 21:27:49 -03:00
parent 61676e1423
commit 2e6fbd7801
10 changed files with 701 additions and 91 deletions

View File

@@ -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 &quot;Anterior&quot;.
Revisá tus respuestas con &quot;Anterior&quot; 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>