# 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.md` y este prompt. La app Next.js ya existe (Etapa 2A). ## 1. Objetivo Cuatro cosas en una etapa: 1. **Geo dropdowns:** A1=Departamento, A2=Ciudad (dependiente de A1), A3=Barrio (texto, opcional). 2. **Header contextual:** mostrar organización y sucursal pre-configurados en la encuesta. 3. **URL por parámetro:** `?s=UUID` para cargar una encuesta específica. 4. **Persistencia real (2B):** pantalla de consentimiento + Server Action que escribe `consent_record → response → answers` en 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_choice` en el DB, renderizados con un widget especial `GeoSelectWidget` (dos dropdowns enlazados). A3=Barrio es `text_short`, opcional. Los datos geográficos viven en una constante TypeScript en el app (ver sección 5). - **Header:** `survey.brand_config` tiene `{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=` — la página lee `searchParams.s`. Si no hay parámetro, carga la primera encuesta `live` (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, **nunca** `NEXT_PUBLIC_`). Usar `x-forwarded-for` como 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_role` NUNCA en el cliente.** Los inserts usan el cliente Supabase inicializado con la `anon` key server-side (en el Server Action) — las RLS policies de INSERT lo permiten. ## 4. Tareas ### 4A — Seed: actualizar questions y brand_config 1. 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.) 2. Aplicar a Cloud: `supabase db push --include-seed` o 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 3. Crear `components/widgets/geo-select-widget.tsx`: - Primer dropdown: Departamento (opciones de A1). - Segundo dropdown: Ciudad — opciones filtradas del `GEO_DATA` const 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). 4. En `question-renderer.tsx`: detectar `code === 'A1'` → renderizar `GeoSelectWidget` (que maneja A1 y A2 juntos). Detectar `code === 'A2'` → skip (ya lo maneja el widget de A1). 5. En `app/page.tsx`: - Leer `searchParams.s` como `surveyId`. Si existe, cargar esa encuesta por ID; si no, cargar la primera `live`. - Leer `survey.brand_config` y pasarlo al formulario. 6. Crear el **header contextual** (component o inline en `survey-form.tsx`): si `brand_config` tiene `org_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) 7. 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 8. Crear `app/actions/submit-survey.ts` (Server Action): ```typescript '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 // código → valor questionMap: Record // 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 } } ``` 9. En `survey-form.tsx`: al enviar, llamar `submitSurvey(...)`. Si success → mostrar pantalla de agradecimiento/confirmación (reemplaza el resumen actual). Si error → mostrar mensaje de error con opción de reintentar. 10. Agregar `IP_HASH_SALT` a `.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: ```typescript export const GEO_DATA: Record = { "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`: ```typescript 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=UUID` carga 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_hash` no 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. - [ ] `answers` tiene una fila por cada pregunta respondida. - [ ] `npm run build` limpio. - [ ] Sin `service_role` ni `IP_HASH_SALT` en código cliente ni en `NEXT_PUBLIC_*`. - [ ] `git diff --name-only HEAD` solo 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_hash` es 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_SALT` ni `service_role` al 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.