# 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=<survey_id>` — 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<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 }
}
```
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<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`:
```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.