Files
motor-de-encuestas/sprints/sprint-2/ETAPA_2B_PROMPT.md

258 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 001007 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.