'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 { // 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 } }