'use server' import { headers } from 'next/headers' import { createHash, randomUUID } from 'crypto' import { createClient as createSupabaseClient } from '@supabase/supabase-js' export type SubmitResult = | { success: true } | { success: false; error: string } export async function submitSurvey(payload: { surveyId: string answers: Record questionMap: Record // código → question_id UUID consentOperativo: boolean consentMacroN1: boolean }): Promise { // 1. ip_hash: server-only, nunca en texto plano 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') // 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon) // NUNCA usar service_role — el anon key respeta RLS 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 const { data: survey, error: sErr } = await supabase .from('surveys') .select('org_id, consent_version_id') .eq('id', payload.surveyId) .single() if (sErr || !survey) return { success: false, error: 'survey_not_found' } // 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) ) if (answerRows.length > 0) { const { error: aErr } = await supabase.from('answers').insert(answerRows) if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` } } return { success: true } }