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>
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
'use server'
|
|
import { headers } from 'next/headers'
|
|
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 }
|
|
| { success: false; error: string }
|
|
|
|
export async function submitSurvey(payload: {
|
|
surveyId: string
|
|
answers: SubmitAnswerEntry[]
|
|
consentOperativo: boolean
|
|
consentMacroN1: boolean
|
|
}): Promise<SubmitResult> {
|
|
// 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 salt = process.env.IP_HASH_SALT ?? ''
|
|
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
|
|
|
// 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)
|
|
|
|
// 1. Validación #1: survey existe y está activa
|
|
const { data: survey, error: sErr } = await supabase
|
|
.from('surveys')
|
|
.select('id, org_id, status')
|
|
.eq('id', payload.surveyId)
|
|
.eq('status', 'live')
|
|
.maybeSingle()
|
|
|
|
if (sErr) return { success: false, error: 'survey_lookup_failed' }
|
|
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
|
|
|
|
// 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 (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 }
|
|
}
|