258 lines
16 KiB
Markdown
258 lines
16 KiB
Markdown
# 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. |