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

16 KiB
Raw Permalink Blame History

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

  1. 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).
  2. 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).
  3. 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.
  4. 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)

  1. 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

  1. 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 }
}
  1. 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.
  2. 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:

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=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.