16 KiB
ETAPA 2B — Consentimiento + persistencia real + dropdowns geográficos
Leé:
CLAUDE.md,DATA_MODEL.md,SEED_SPEC.md,docs/adr/ADR-001,docs/adr/ADR-003,sprints/sprint-2/BRIEF.mdy este prompt. La app Next.js ya existe (Etapa 2A).
1. Objetivo
Cuatro cosas en una etapa:
- Geo dropdowns: A1=Departamento, A2=Ciudad (dependiente de A1), A3=Barrio (texto, opcional).
- Header contextual: mostrar organización y sucursal pre-configurados en la encuesta.
- URL por parámetro:
?s=UUIDpara cargar una encuesta específica. - Persistencia real (2B): pantalla de consentimiento + Server Action que escribe
consent_record → response → answersen Supabase Cloud.
2. Contexto previo
La app renderiza el formulario desde Supabase (anon key), tiene lógica condicional data-driven,
widget text_short, resiliencia lite. El esquema Sprint 1 tiene RLS, k-anonimato, consent inmutable.
responses.consent_record_id es NOT NULL → la escritura requiere consentimiento primero.
3. Decisiones ya tomadas
- Geo: A1=Departamento y A2=Ciudad son
single_choiceen el DB, renderizados con un widget especialGeoSelectWidget(dos dropdowns enlazados). A3=Barrio estext_short, opcional. Los datos geográficos viven en una constante TypeScript en el app (ver sección 5). - Header:
survey.brand_configtiene{org_name, branch_name, branch_city}. El formulario lo muestra arriba: "Estás respondiendo esto porque pertenecés a [org_name] y participás en las oficinas de [branch_name] en [branch_city]." — solo-lectura, no editable por el respondente. - URL:
?s=<survey_id>— la página leesearchParams.s. Si no hay parámetro, carga la primera encuestalive(backwards compat para desarrollo). - Consentimiento: pantalla ANTES del formulario. Checkbox 1 (requerido): acepta uso operativo.
Checkbox 2 (opcional): acepta macro anónimo. Sin checkbox 1, no puede avanzar. El texto debe ser
honesto sobre el anonimato y la NO revocación (ADR-003 — la encuesta es anónima; no hay forma de
revocar después). Los valores de ambos checkboxes se almacenan en
consent_record.granted_scopes. - ip_hash: se computa SERVER-SIDE en el Server Action con SHA-256 +
process.env.IP_HASH_SALT(variable server-only, nuncaNEXT_PUBLIC_). Usarx-forwarded-forcomo IP (Traefik/Dokploy). - Orden de inserts: consentimiento → response → answers, dentro del Server Action. Si algo falla, el usuario ve un error y puede reintentar.
service_roleNUNCA en el cliente. Los inserts usan el cliente Supabase inicializado con laanonkey server-side (en el Server Action) — las RLS policies de INSERT lo permiten.
4. Tareas
4A — Seed: actualizar questions y brand_config
- En
supabase/seed.sql, actualizar:- A1 (Departamento):
type='single_choice', opciones = los 18 valores del listado geo (sección 5). - A2 (Ciudad):
type='single_choice', opciones = TODAS las ciudades del listado geo concatenadas con el nombre de su departamento como prefijo para que la DB las almacene correctamente (e.g.{label:"Asunción", value:"Asunción"}). El filtrado por departamento es client-side en el widget. - A3 (Barrio):
type='text_short', prompt="¿En qué barrio vivís? (opcional)", required=false. - A4 (Área): agregar a las opciones existentes:
"Directorio"y"Gerencia". - 1.3 (Elementos de apoyo): agregar opción
"Tecnología asistiva". - survey.brand_config: actualizar el registro semilla con
{"org_name": "Empresa Demo", "branch_name": "Oficina Central", "branch_city": "Asunción"}. (El director actualizará esto con los datos reales de la primera empresa directamente en Supabase Studio antes de enviar el link.)
- A1 (Departamento):
- Aplicar a Cloud:
supabase db push --include-seedo actualizar las questions directamente via script SQL si el seed ya no es idempotente (usar ON CONFLICT UPDATE para questions).
4B — App: GeoSelectWidget + header + URL
- Crear
components/widgets/geo-select-widget.tsx:- Primer dropdown: Departamento (opciones de A1).
- Segundo dropdown: Ciudad — opciones filtradas del
GEO_DATAconst según el departamento seleccionado. Deshabilitado hasta que se seleccione departamento. - Cuando cambia el departamento, limpiar la ciudad seleccionada.
- Ambos dropdowns son accesibles (label, aria, teclado).
- En
question-renderer.tsx: detectarcode === 'A1'→ renderizarGeoSelectWidget(que maneja A1 y A2 juntos). Detectarcode === 'A2'→ skip (ya lo maneja el widget de A1). - En
app/page.tsx:- Leer
searchParams.scomosurveyId. Si existe, cargar esa encuesta por ID; si no, cargar la primeralive. - Leer
survey.brand_configy pasarlo al formulario.
- Leer
- Crear el header contextual (component o inline en
survey-form.tsx): sibrand_configtieneorg_name, mostrar el banner: "Estás respondiendo esto porque pertenecés a [org_name] y participás en las oficinas de [branch_name] en [branch_city]." — estilo discreto, no editable.
4C — Consentimiento (pantalla previa al formulario)
- Crear
components/consent-screen.tsx:- Sección de explicación del tratamiento (qué se recolecta, para qué, que es anónimo).
- Párrafo OBLIGATORIO sobre anonimato: "Esta encuesta es completamente anónima. No guardamos ningún dato que te identifique. Por ser anónima, no es posible revocar ni eliminar tu respuesta una vez enviada."
- Checkbox 1 (required): "Acepto que mis respuestas se usen para el diagnóstico interno de [org_name] sobre cuidado y discapacidad."
- Checkbox 2 (optional): "También acepto que mis respuestas anonimizadas contribuyan al estudio país sobre economía del cuidado (sin identificarme en ningún caso)."
- Botón "Comenzar la encuesta" — deshabilitado hasta que checkbox 1 esté marcado.
- Al hacer clic en "Comenzar", guarda los valores de los checkboxes en el estado del formulario y muestra el formulario (NO escribe en la base todavía — el insert va al enviar).
4D — Server Action: persistencia real
- Crear
app/actions/submit-survey.ts(Server Action):
'use server'
import { headers } from 'next/headers'
import { createHash } from 'crypto'
import { createClient } from '@supabase/supabase-js'
export async function submitSurvey(payload: {
surveyId: string
answers: Record<string, unknown> // código → valor
questionMap: Record<string, string> // código → question_id (UUID)
consentOperativo: boolean
consentMacroN1: boolean
consentVersionId: string
}) {
// 1. IP hash (server-only)
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 (las RLS policies permiten INSERT a anon/authenticated)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// 3. INSERT consent_record
const { data: consent, error: cErr } = await supabase
.from('consent_records')
.insert({
consent_version_id: payload.consentVersionId,
granted_scopes: {
operativo: payload.consentOperativo,
macro_n1: payload.consentMacroN1
},
granted_at: new Date().toISOString()
})
.select('id')
.single()
if (cErr || !consent) return { success: false, error: 'consent_insert_failed' }
// 4. Extraer qi_* de las respuestas
const qi_age_bucket = String(payload.answers['A5'] ?? '')
const qi_sector = String(payload.answers['A4'] ?? '')
// 5. INSERT response
const { data: response, error: rErr } = await supabase
.from('responses')
.insert({
survey_id: payload.surveyId,
company_id: null, // se setea automáticamente por la survey.org_id en el trigger o app
respondent_id: null,
ip_hash: ipHash,
consent_record_id: consent.id,
qi_age_bucket: qi_age_bucket || null,
qi_sector: qi_sector || null,
qi_zone: null
})
.select('id')
.single()
if (rErr || !response) return { success: false, error: 'response_insert_failed' }
// 6. INSERT answers (bulk, excluir text_long/A2 si están vacíos, siempre excluir A2 separado
// ya que va incluido en A1 via GeoWidget)
const answerRows = Object.entries(payload.answers)
.filter(([code]) => code !== '__consent__') // filtrar meta-campos
.map(([code, value]) => ({
response_id: response.id,
question_id: payload.questionMap[code],
value: JSON.stringify(value)
}))
.filter(row => row.question_id && row.value !== 'null' && row.value !== '""' && row.value !== '[]')
const { error: aErr } = await supabase.from('answers').insert(answerRows)
if (aErr) return { success: false, error: 'answers_insert_failed' }
return { success: true }
}
- En
survey-form.tsx: al enviar, llamarsubmitSurvey(...). Si success → mostrar pantalla de agradecimiento/confirmación (reemplaza el resumen actual). Si error → mostrar mensaje de error con opción de reintentar. - Agregar
IP_HASH_SALTa.env.example(sin valor, solo el nombre).
5. Datos geográficos de Paraguay — constante TypeScript
Crear lib/paraguay-geo.ts con esta estructura y datos:
export const GEO_DATA: Record<string, string[]> = {
"Asunción (Capital)": ["Asunción"],
"Alto Paraguay": ["Fuerte Olimpo","Bahía Negra","Carmelo Peralta","La Victoria"],
"Alto Paraná": ["Ciudad del Este","Hernandarias","Minga Guazú","Presidente Franco","Minga Porã","Naranjal","Ñacunday","San Alberto","Santa Rita","Tavapy","Yguazú","Juan E. O'Leary","San Cristóbal","Santa Fe del Paraná","Dr. Juan León Mallorquín","Itakyry","Domingo Martínez de Irala"],
"Amambay": ["Pedro Juan Caballero","Bella Vista Norte","Capitán Bado","Zanja Pytã"],
"Boquerón": ["Filadelfia","Loma Plata","Mariscal Estigarribia","Neuland"],
"Caaguazú": ["Coronel Oviedo","Caaguazú","Carayaó","Dr. Juan Manuel Frutos","Mbutuy","Nueva Londres","Raúl Arsenio Oviedo","Repatriación","San Joaquín","San José de los Arroyos","Simón Bolívar","Vaquería","Yhú","José Domingo Ocampos","3 de Febrero","Mcal. Francisco Solano López","Los Cedrales"],
"Caazapá": ["Caazapá","Abai","Buena Vista","General Higinio Morínigo","Huber Duré","Maciel","San Juan Nepomuceno","Tavaí","Yegros","Yuty"],
"Canindeyú": ["Salto del Guairá","Corpus Christi","Curuguaty","General Francisco Caballero Álvarez","Itanará","Katueté","La Paloma","Nueva Esperanza","Yasy Kañy","Ypejhú"],
"Central": ["Areguá","Capiatá","Fernando de la Mora","Guarambaré","Itá","Itauguá","Juan Augusto Saldívar","Lambaré","Limpio","Luque","Mariano Roque Alonso","Nueva Italia","Ñemby","San Antonio","San Lorenzo","Villa Elisa","Villeta","Ypané","Ypacaraí"],
"Concepción": ["Concepción","Belén","Horqueta","Loreto","San Lázaro","San Carlos del Apa","Yby Yaú","Azote'y","Sgto. José Félix López"],
"Cordillera": ["Caacupé","Altos","Arroyos y Esteros","Caraguatay","Emboscada","Eusebio Ayala","Isla Pucú","Itacurubí de la Cordillera","Juan de Mena","Loma Grande","Mbocayaty del Yhaguy","Nueva Colombia","Pirayú","Primero de Marzo","Tobatí","Valenzuela"],
"Guairá": ["Villarrica","Borja","Capitán Mauricio José Troche","Coronel Martínez","Félix Pérez Cardozo","General Eugenio A. Garay","Independencia","Itapé","Iturbe","José Fassardi","Mbocayaty","Natalicio Talavera","Ñumí","San Salvador","Yataity"],
"Itapúa": ["Encarnación","Alto Verá","Bella Vista","Cambyretá","Capitán Meza","Capitán Miranda","Carlos A. López","Carmen del Paraná","Coronel Bogado","Cosme","Edelira","Fram","General Artigas","Hohenau","Itapúa Poty","Jesús","La Paz","Leandro Oviedo","Mayor Otaño","Natalio","Nueva Alborada","Obligado","Pirapó","San Cosme y Damián","San Juan del Paraná","San Pedro del Paraná","San Rafael del Paraná","Trinidad","Tomás Romero Pereira"],
"Misiones": ["San Juan Bautista","Ayolas","San Ignacio","San Miguel","San Patricio","Santa María","Santa Rosa","Santiago","Villa Florida","Yabebyry"],
"Ñeembucú": ["Pilar","Alberdi","Cerrito","Desmochados","General José Eduvigis Díaz","Guazú Cuá","Humaitá","Isla Umbú","Laureles","Mayor José de J. Martínez","Paso de Patria","San Juan Bautista del Ñeembucú","Tacuaras","Villa Franca","Villa Oliva","Villalba"],
"Paraguarí": ["Paraguarí","Acahay","Carapeguá","Escobar","General Bernardino Caballero","La Colmena","Mbuyapey","Quiindy","Quyquyhó","San Roque González de Santa Cruz","Sapucaí","Tebicuarymí","Ybycuí","Ybytymí","Yaguarón"],
"Presidente Hayes": ["Villa Hayes","Benjamín Aceval","José Falcón","Nanawa","Nueva Asunción","Pozo Colorado","Puerto Pinasco","Teniente Primero Manuel Irala Fernández","Villa del Río"],
"San Pedro": ["San Pedro del Ycuamandiyú","Antequera","Choré","General Elizardo Aquino","Guayaibí","Itacurubí del Rosario","Lima","Nueva Germania","Río Verde","San Estanislao (Santaní)","Tacuatí","Unión","Villa del Rosario","Yataity del Norte"]
}
export const DEPARTMENTS = Object.keys(GEO_DATA).sort()
export const getCities = (department: string): string[] =>
(GEO_DATA[department] ?? []).sort()
Notas sobre los datos: lista basada en fuentes oficiales (INE Paraguay / Wikipedia). Si el director
identifica municipios faltantes, se agregan al objeto GEO_DATA sin cambio de esquema.
6. company_id en la response
El seed tiene org_id en la survey. En el Server Action, obtener el org_id de la survey para
setearlo como company_id en el responses:
const { data: survey } = await supabase
.from('surveys')
.select('org_id, consent_version_id, brand_config')
.eq('id', payload.surveyId)
.single()
// Usar survey.org_id como company_id en el INSERT de responses
7. Definition of Done
- Dropdowns A1→A2 funcionan en el browser: seleccionar departamento filtra las ciudades.
- A3 (barrio) renderiza como campo de texto.
- A4 tiene opciones Directorio y Gerencia.
- 1.3 tiene opción Tecnología asistiva.
- Header muestra org_name/branch_name/branch_city de brand_config cuando existe.
?s=UUIDcarga la encuesta correcta; sin parámetro carga la primera live.- Pantalla de consentimiento aparece antes del formulario con texto honesto (anonimato + no revocación).
- Completar y enviar el formulario escribe en Supabase: 1 consent_record + 1 response + N answers.
- Verificar en Supabase Studio que los registros aparecen.
response.ip_hashno es null ni la IP en texto plano (es el hash).response.company_id= el org_id de la survey.response.consent_record_id= el id del consent_record recién creado.answerstiene una fila por cada pregunta respondida.npm run buildlimpio.- Sin
service_roleniIP_HASH_SALTen código cliente ni enNEXT_PUBLIC_*. git diff --name-only HEADsolo con los archivos autorizados.
8. Validaciones mandatorias
- Enviar el formulario → verificar en Supabase Studio (Table Editor) que aparecen filas en
consent_records,responses,answers. Si alguna tabla queda vacía → error, no declarar lista. - Enviar dos veces desde el mismo browser → verificar que
ip_hashes el mismo (deduplicación visible). - El texto del consentimiento menciona explícitamente que la encuesta es anónima y no se puede revocar.
- Si CUALQUIER validación falla: NO declarar lista. Reportar y esperar.
9. Qué reportar
- Verificado: captura de las filas escritas en consent_records + responses + answers (IDs concretos).
- Verificado: ip_hash no es null y es un hash (64 chars hex).
- Verificado: company_id del response = org_id de la survey.
- Asumidos, hallazgos, decisiones por iniciativa (con nivel).
git diff --name-only HEAD.
10. Qué NO hacer
- NO exponer
IP_HASH_SALTniservice_roleal cliente. - NO poner la IP en texto plano en la base.
- NO saltar la pantalla de consentimiento.
- NO escribir en la base si el checkbox 1 no fue marcado.
- NO modificar las migraciones 001–007 ni las policies de RLS.
- NO hardcodear el survey_id en el código (siempre leer del parámetro URL).
- Ante conflicto entre restricción y "que funcione": detenerse y reportar.