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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user