Validaciones mandatorias — Sección 8 — IDs concretos
This commit is contained in:
109
app/actions/submit-survey.ts
Normal file
109
app/actions/submit-survey.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
'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<string, unknown>
|
||||
questionMap: Record<string, string> // código → question_id UUID
|
||||
consentOperativo: boolean
|
||||
consentMacroN1: boolean
|
||||
}): Promise<SubmitResult> {
|
||||
// 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
|
||||
|
||||
// 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: 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 }
|
||||
}
|
||||
@@ -398,6 +398,61 @@ body {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* ── Geo select widget ── */
|
||||
.geo-select-wrapper { display: flex; flex-direction: column; gap: 16px; }
|
||||
.geo-select-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.geo-select {
|
||||
display: block; width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid var(--color-border); border-radius: var(--radius);
|
||||
font-size: 1rem; font-family: inherit; background: var(--color-surface);
|
||||
color: var(--color-text); cursor: pointer; appearance: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.geo-select:focus { outline: none; border-color: var(--color-border-focus); box-shadow: 0 0 0 3px rgba(37,99,235,0.15); }
|
||||
.geo-select-disabled { opacity: 0.5; cursor: not-allowed; background: #f3f4f6; }
|
||||
.geo-label-disabled { color: var(--color-text-muted); }
|
||||
.geo-disabled-hint { font-size: 0.8125rem; font-weight: 400; color: var(--color-text-muted); }
|
||||
|
||||
/* ── Brand header ── */
|
||||
.brand-header {
|
||||
background: var(--color-primary-light); border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 10px 14px; margin: 16px 0 0;
|
||||
}
|
||||
.brand-text { margin: 0; font-size: 0.875rem; color: var(--color-primary-hover); line-height: 1.5; }
|
||||
|
||||
/* ── Consent screen ── */
|
||||
.consent-container { max-width: 720px; margin: 0 auto; padding: 32px 16px 80px; }
|
||||
.consent-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 24px; }
|
||||
.consent-section { margin-bottom: 24px; }
|
||||
.consent-section-title { font-size: 1.0625rem; font-weight: 700; margin: 0 0 10px; }
|
||||
.consent-list { margin: 8px 0 0; padding-left: 20px; line-height: 1.7; display: flex; flex-direction: column; gap: 8px; }
|
||||
.consent-anon-box {
|
||||
background: var(--color-sensitive); border: 1px solid var(--color-sensitive-border);
|
||||
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px;
|
||||
font-size: 0.9375rem; line-height: 1.6;
|
||||
}
|
||||
.consent-checkboxes { margin-bottom: 28px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.consent-checkbox-label {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 14px 16px; border: 1.5px solid var(--color-border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s; line-height: 1.5;
|
||||
}
|
||||
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||
.consent-checkbox-text { flex: 1; }
|
||||
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
||||
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
|
||||
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||
.btn-disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Thank you / error screens ── */
|
||||
.thank-you-container, .error-container { max-width: 560px; margin: 80px auto; padding: 0 16px; }
|
||||
.thank-you-title, .error-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; }
|
||||
.thank-you-body { color: var(--color-text-muted); line-height: 1.7; margin: 0 0 12px; }
|
||||
|
||||
/* ── No survey state ── */
|
||||
.no-survey {
|
||||
max-width: 480px;
|
||||
|
||||
50
app/page.tsx
50
app/page.tsx
@@ -1,21 +1,27 @@
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import type { Question, Survey } from '@/lib/types'
|
||||
import type { BrandConfig, Question, Survey } from '@/lib/types'
|
||||
import { SurveyForm } from '@/components/survey-form'
|
||||
|
||||
// Force dynamic rendering — page fetches from Supabase at request time.
|
||||
// Never statically generated: credentials live in .env.local (runtime only).
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Server Component — fetches survey with anon key; RLS ensures only status='live' is returned.
|
||||
export default async function Page() {
|
||||
// In Next.js 15, searchParams is a Promise and must be awaited.
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ s?: string }>
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const { s: surveyId } = await searchParams
|
||||
const supabase = createClient()
|
||||
|
||||
// 1. Fetch the live survey
|
||||
const { data: surveyRow, error: surveyError } = await supabase
|
||||
// 1. Fetch survey — by ?s=UUID if provided, otherwise first live survey
|
||||
const surveyQuery = supabase
|
||||
.from('surveys')
|
||||
.select('id, title, status, is_anonymous, k_threshold')
|
||||
.eq('status', 'live')
|
||||
.maybeSingle()
|
||||
.select('id, org_id, title, status, is_anonymous, k_threshold, consent_version_id, brand_config')
|
||||
|
||||
const { data: surveyRow, error: surveyError } = surveyId
|
||||
? await surveyQuery.eq('id', surveyId).maybeSingle()
|
||||
: await surveyQuery.eq('status', 'live').maybeSingle()
|
||||
|
||||
if (surveyError) {
|
||||
return (
|
||||
@@ -32,10 +38,11 @@ export default async function Page() {
|
||||
<div className="no-survey" role="main">
|
||||
<h1 className="no-survey-title">No hay encuesta activa</h1>
|
||||
<p>
|
||||
Para ver el formulario, la encuesta debe estar en estado <code>live</code>.
|
||||
Para ver el formulario, la encuesta debe estar en estado <code>live</code>,
|
||||
o pasá el UUID por parámetro: <code>?s=UUID</code>.
|
||||
</p>
|
||||
<pre className="no-survey-hint">
|
||||
{`-- Activar la encuesta en Supabase Cloud:\nUPDATE surveys\nSET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
|
||||
{`-- Activar en Supabase Cloud:\nUPDATE surveys SET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
@@ -61,20 +68,19 @@ export default async function Page() {
|
||||
|
||||
const questions = questionRows as Question[]
|
||||
|
||||
// 3. Log question count for validation (visible in server logs and browser Network tab)
|
||||
console.log(
|
||||
`[2A] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`
|
||||
)
|
||||
console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`)
|
||||
|
||||
const survey: Survey = {
|
||||
...(surveyRow as Omit<Survey, 'questions'>),
|
||||
id: surveyRow.id,
|
||||
org_id: surveyRow.org_id,
|
||||
title: surveyRow.title,
|
||||
status: surveyRow.status,
|
||||
is_anonymous: surveyRow.is_anonymous,
|
||||
k_threshold: surveyRow.k_threshold,
|
||||
consent_version_id: surveyRow.consent_version_id,
|
||||
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
||||
questions,
|
||||
}
|
||||
|
||||
return (
|
||||
<SurveyForm
|
||||
survey={survey}
|
||||
questions={questions}
|
||||
/>
|
||||
)
|
||||
return <SurveyForm survey={survey} questions={questions} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user