Validaciones mandatorias — Sección 8 — IDs concretos

This commit is contained in:
markosbenitez
2026-06-04 21:52:39 -03:00
parent d91ea6b2ff
commit b1ca0e0795
32 changed files with 1325 additions and 121 deletions

View 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 }
}

View File

@@ -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;

View File

@@ -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} />
}