From ef9e4bf998242795c71052775f50fb942f51c782 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Sun, 31 May 2026 04:33:07 -0300 Subject: [PATCH] Primer commit - Sprint 1 --- .gitignore | 14 + ADR-001-privacidad-y-gobierno-de-datos.md | 110 +++++ ADR-002-stack-tecnologico.md | 52 +++ ADR-003-consentimiento-versionado.md | 54 +++ ARCHITECTURE.md | 99 +++++ BRIEF.md | 54 +++ CLAUDE.md | 117 +++++ DATA_MODEL.md | 152 +++++++ ETAPA_1_PROMPT.md | 79 ++++ GUIA_PRIMEROS_PASOS.md | 126 ++++++ package.json | 10 + settings.json | 26 ++ supabase/.gitignore | 8 + supabase/config.toml | 413 ++++++++++++++++++ .../20260531000001_create_tables.sql | 135 ++++++ .../migrations/20260531000002_enable_rls.sql | 13 + .../20260531000003_rls_policies.sql | 148 +++++++ .../20260531000004_constraints_triggers.sql | 87 ++++ .../migrations/20260531000005_agg_segment.sql | 124 ++++++ supabase/migrations/20260531000006_grants.sql | 78 ++++ supabase/seed.sql | 307 +++++++++++++ supabase/tests/01_schema_and_logic.test.sql | 309 +++++++++++++ 22 files changed, 2515 insertions(+) create mode 100644 .gitignore create mode 100644 ADR-001-privacidad-y-gobierno-de-datos.md create mode 100644 ADR-002-stack-tecnologico.md create mode 100644 ADR-003-consentimiento-versionado.md create mode 100644 ARCHITECTURE.md create mode 100644 BRIEF.md create mode 100644 CLAUDE.md create mode 100644 DATA_MODEL.md create mode 100644 ETAPA_1_PROMPT.md create mode 100644 GUIA_PRIMEROS_PASOS.md create mode 100644 package.json create mode 100644 settings.json create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260531000001_create_tables.sql create mode 100644 supabase/migrations/20260531000002_enable_rls.sql create mode 100644 supabase/migrations/20260531000003_rls_policies.sql create mode 100644 supabase/migrations/20260531000004_constraints_triggers.sql create mode 100644 supabase/migrations/20260531000005_agg_segment.sql create mode 100644 supabase/migrations/20260531000006_grants.sql create mode 100644 supabase/seed.sql create mode 100644 supabase/tests/01_schema_and_logic.test.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e816064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Secretos — nunca commitear +.env +.env.* +.env.local + +# Claude Code — settings locales (allow personales del usuario) +.claude/settings.local.json + +# Supabase local — generado por supabase start +.supabase/ + +# Node +node_modules/ +package-lock.json diff --git a/ADR-001-privacidad-y-gobierno-de-datos.md b/ADR-001-privacidad-y-gobierno-de-datos.md new file mode 100644 index 0000000..256c0df --- /dev/null +++ b/ADR-001-privacidad-y-gobierno-de-datos.md @@ -0,0 +1,110 @@ +# ADR-001 — Modelo de privacidad y gobierno de datos + +- **Estado:** Aceptado +- **Fecha:** (completar con fecha de commit) +- **Decisores:** Director del proyecto + asistente de dirección técnica +- **Contexto legal:** Paraguay, Ley N° 7593/2025 de Protección de Datos Personales + +--- + +## Contexto + +El producto corre la misma encuesta en N empresas con **dos propósitos en tensión**: operativo +(dashboard por empresa para decidir acciones) y macro (estudio país de la "economía de la +discapacidad"). Se tratan **datos sensibles** (salud/discapacidad). Resolver mal esta tensión +significa o filtrar datos confidenciales entre empresas, o perder el análisis macro que es el +diferencial. + +**Marco legal aplicable:** la Ley N° 7593/2025 fue promulgada el 27/11/2025, está alineada con el +RGPD europeo, crea la Agencia Nacional de Protección de Datos Personales (ANPDP) con régimen +sancionatorio, y tiene vacatio legis de 24 meses (exigibilidad plena ~nov 2027). Reconoce categoría +especial para datos sensibles (incluye salud) y derechos de acceso, rectificación, oposición, +supresión, portabilidad y revocación del consentimiento. *No es asesoría legal; validar con +especialista antes de producción.* + +## Decisión + +Adoptamos un modelo de **privacy by design** con los siguientes componentes cerrados: + +### 1. Anonimato como propiedad por-encuesta +El anonimato no es global de la plataforma: cada encuesta lo declara en su configuración. El wizard +de creación lo togglea y eso propaga a cómo se guarda cada respuesta. + +### 2. Dos ejes de clasificación, ortogonales +- **Sensibilidad legal** (`comun` | `sensible` | `anonimizado`) → alimenta el **consentimiento**. +- **Seguridad** (`publico` | `interno` | `confidencial`) → alimenta **RLS y cifrado**. + +"Confidencial" es seguridad, NO una categoría legal. Una encuesta puede ser confidencial en storage +y no-sensible legalmente, o al revés. + +### 3. La herramienta constriñe, no solo permite +La clasificación no es pura auto-declaración del creador. Ciertos tipos de pregunta +(salud/discapacidad) **se auto-marcan sensibles** y elevan la encuesta, sin importar lo que elija el +creador. El wizard guía y bloquea por defecto hacia la clasificación correcta. (Operacionaliza los +principios de minimización y responsabilidad proactiva de la ley.) + +### 4. El titular del consentimiento es el respondente, no la empresa +La empresa autoriza el despliegue; el individuo consiente el tratamiento de su propio dato sensible. +Ante divergencia entre un documento firmado por la empresa y la elección granular del titular en la +app, **gana siempre la elección del titular**. + +### 5. Consentimiento granular, por capas y versionado +Checkboxes separados por propósito (operativo / macro N1 / macro N2...), no un único "acepto todo". +Cada respuesta guarda un registro inmutable con timestamp contra la versión vigente del texto. (Ver +ADR-003.) + +### 6. Rol de responsable parametrizable (`controller_role`), con cascada +- **Modo A** (nosotros = responsable): macro a todos los niveles consentidos por el titular. +- **Modo B** (empresa = responsable, nosotros = encargado): macro **solo** vía Nivel 1 anonimizado + irreversible. Usar dato de Modo B para Nivel 2/3 sería ejercer de responsable encubierto → ilícito. + +> **Regla limpia:** Modo A → macro a todos los niveles consentidos. Modo B → macro solo vía Nivel 1 +> anonimizado. El wizard debe **mostrar la consecuencia** al elegir el modo, no ofrecerlo como +> dropdown plano. + +### 7. Anónimo a nivel respondente, conservando atribución de empresa (las 4 de v1) +- **Sí** se conserva `company_id` + cuasi-identificadores (edad en bucket, sector, zona) → habilita + Nivel 2 (empresa A vs el resto) y comparación por segmento. +- **No** se recolecta `respondent_id`. El campo queda **reservado** en el esquema para encuestas + futuras, pero para estos 4 estudios el dato queda permanentemente en Nivel 1/2. + +### 8. Distinción anónimo vs pseudónimo (textual, para no re-confundir) +La **comparación por segmento** ("25 / sector X / zona A" vs "zona B") usa los cuasi-identificadores +de cada fila y **no requiere** `respondent_id`. La clave pseudónima solo sirve para vincular varias +respuestas a la misma persona desconocida (longitudinal, re-contacto, dedup avanzado) — nada de eso +es caso de v1. La dedup simple se hace con `ip_hash`. + +### 9. Umbral de k-anonimato mandatorio en visualización +Anónimo en storage **no** es a prueba de re-identificación: un n=1 por cruce de cuasi-identificadores +es identificable. Toda visualización compartida (incluido el dashboard de la empresa) suprime celdas +con n < umbral (default 5). La comparación individuo-vs-individuo está prohibida en dashboards +compartidos; la inspección fila-por-fila es solo análisis interno (Modo A). + +### 10. Texto libre fuera del store analítico crudo +Las preguntas abiertas no se exponen crudas a reportes (vector de re-identificación). Se categorizan +antes (a futuro, vía LLM — está en BACKLOG). + +## Alcance de esta etapa (v1) +Solo **Modo A**, solo encuestas **anónimas**, **sin Nivel 3 atribuible**. El esquema soporta más +(controller_role, respondent_id) pero v1 no lo opera. Principio: el esquema guarda la opción, estos +datos puntuales no la usan. + +## Alternativas consideradas +- **Macro 100% anonimizado siempre (solo Nivel 1):** más simple, pero mata el benchmark sectorial que + el director quiere a futuro. Rechazado por cerrar opción de bajo costo. +- **Pseudonimización con `respondent_id` desde v1:** habilita longitudinal/re-contacto pero agrega + carga legal (es dato personal) sin caso de uso inmediato. Rechazado para v1; campo reservado. +- **Nivel 3 atribuible desde v1:** máximo poder analítico, pero requiere acuerdo de tratamiento por + empresa + consentimiento explícito + vía de re-identificación. Diferido hasta tener feedback real. + +## Consecuencias +- (+) Cumplimiento alineado a Ley 7593 desde el diseño; soberanía; protección del empleado frente a + su empleador. +- (+) Mantiene viva la opción de Nivel 2 sin recolectar datos personales adicionales. +- (−) El dato de los 4 estudios no podrá usarse jamás para análisis atribuible ni longitudinal + individual (decisión consciente). +- (−) El umbral de k-anonimato puede dejar segmentos chicos sin reporte ("n insuficiente") — es el + costo correcto de la privacidad. + +## Pendiente +Validación del flujo de consentimiento y del lenguaje legal con asesor paraguayo antes de producción. diff --git a/ADR-002-stack-tecnologico.md b/ADR-002-stack-tecnologico.md new file mode 100644 index 0000000..3e08dd6 --- /dev/null +++ b/ADR-002-stack-tecnologico.md @@ -0,0 +1,52 @@ +# ADR-002 — Stack tecnológico y residencia de datos + +- **Estado:** Aceptado (stack) · **Abierto** (residencia de datos) +- **Fecha:** (completar con fecha de commit) + +--- + +## Contexto + +Se necesita un stack moderno, con experiencia móvil fluida, reportes potentes, soberanía de datos y +sin lock-in caro. El volumen no justifica una suite SaaS profesional. Se tratan datos sensibles. + +## Decisión (stack) + +| Capa | Tecnología | Razón | +|---|---|---| +| Frontend | Next.js (App Router) | SSR/SSG, ecosistema maduro, buena DX, móvil sólido | +| Form engine | React + react-hook-form + zod | Validación tipada, flexible, sin vendor lock-in | +| Backend / datos | Supabase (Postgres + Auth + RLS + Realtime) | Postgres real para reportes SQL, RLS nativo multi-tenant, self-host posible | +| Auth | Supabase Auth | Anónimo / OAuth / magic link según encuesta | +| Reportes/gráficos | ECharts (preferido) o Recharts | Gratis, potente, tipos de gráfico variados (heatmap, polar, pirámide), no-SaaS | +| Deploy app | Vercel | Rápido para arrancar, migrable | + +Supabase es la pieza clave: Postgres real habilita los reportes cruzados (filtrar respuesta A por +respuesta B), RLS da el aislamiento multi-tenant que ADR-001 exige, y el modo self-host (Docker) +preserva la opción de soberanía total. + +## Decisión ABIERTA — residencia de los datos + +Supabase Cloud aloja datos en regiones US/EU por defecto. Para un producto cuyo requisito explícito +es **soberanía** y que trata **datos sensibles de ciudadanos paraguayos**, la ubicación física del +dato no es neutral: + +- La Ley 7593/2025 tiene reglas de **transferencia internacional** (requiere nivel de protección + adecuado o garantías apropiadas en el país destinatario). +- "Soberanía total" sugiere self-host (Supabase en infraestructura propia/regional). + +**Opciones:** +1. **Supabase Cloud** (US/EU) — más rápido de arrancar, menos operación, pero transferencia + internacional a evaluar legalmente y tensión con "soberanía". +2. **Supabase self-host** (Docker, infra propia o proveedor regional) — soberanía real, más operación. +3. **Híbrido:** arrancar en Cloud para el POC interno, migrar a self-host antes de producción con + datos reales de ciudadanos. + +**No se cierra todavía.** Requiere: (a) decisión del director sobre apetito de operación, (b) input +legal sobre transferencia internacional bajo Ley 7593. **Bloqueante para producción, no para v1 +interno** si v1 corre con datos de prueba/simulados. + +## Consecuencias +- (+) Stack migrable; Supabase no es lock-in si se self-hostea. +- (−) Mantener la opción self-host implica disciplina (no usar features Cloud-only sin registrarlo). +- Registrar en este ADR cuándo y cómo se resuelve la residencia. diff --git a/ADR-003-consentimiento-versionado.md b/ADR-003-consentimiento-versionado.md new file mode 100644 index 0000000..9410be0 --- /dev/null +++ b/ADR-003-consentimiento-versionado.md @@ -0,0 +1,54 @@ +# ADR-003 — Consentimiento granular, versionado e inmutable + +- **Estado:** Aceptado +- **Fecha:** (completar con fecha de commit) + +--- + +## Contexto + +Bajo lógica RGPD / Ley 7593, el consentimiento para datos sensibles debe ser específico, informado y +**demostrable**: hay que poder probar qué consintió cada persona, cuándo, y contra qué texto. Además, +el director planteó dos caminos de consentimiento que pueden coexistir y divergir: documentos +firmados (empresa/encuestado) y checkboxes in-app. + +## Decisión + +### 1. El consentimiento es evidencia, no solo UI +Mostrar el checkbox no alcanza. Cada respuesta guarda un **registro de consentimiento** con: versión +del texto, scopes efectivamente aceptados, timestamp. Es **inmutable** (solo INSERT/SELECT; sin +UPDATE/DELETE). + +### 2. Versionado del texto +El texto de consentimiento vive en `consent_versions`. Si cambia el wording en el futuro, las +respuestas viejas **conservan su versión vieja**. Nunca se reescribe retroactivamente lo que alguien +aceptó. + +### 3. Granular por capas +Scopes separados por propósito, aceptables independientemente: +`operativo` (dashboard de su empresa), `macro_n1` (agregados anónimos del estudio país), y a futuro +`macro_n2` / `macro_n3`. **v1 ofrece solo `operativo` + `macro_n1`** (las encuestas son anónimas y +Modo A sin Nivel 3). Un titular puede aceptar operativo y rechazar macro. + +### 4. La elección del titular gana sobre la firma de la empresa +Si la empresa firmó "todos los datos van al macro" pero el empleado destildó macro en la app, la +respuesta NO entra al macro. El registro in-app del titular es la fuente de verdad sobre su propio dato. + +### 5. Honestidad sobre derechos en encuestas anónimas +En encuesta anónima: +- No se puede hacer firmar al encuestado nada identificable (destruiría el anonimato) → el registro + in-app es el **único** registro de consentimiento posible; no se complementa con firma del titular. +- No se puede **revocar** después (no se puede ubicar la respuesta). El texto debe declararlo + explícitamente **antes** de responder: "esta encuesta es anónima; no vas a poder revocar luego". +- El wizard, al togglear anonimato, cambia qué derechos se pueden ofrecer y ajusta el texto. + +## Consecuencias +- (+) Demostrabilidad ante la ANPDP; trazabilidad por versión. +- (+) Respeta la autonomía del titular sobre su dato sensible. +- (−) Un mismo dato puede estar en el dashboard de la empresa pero excluido del macro (lógica de + reportes debe respetar `granted_scopes` por fila — no es opcional). +- (−) Encuestas anónimas pierden el derecho de revocación; debe comunicarse con transparencia. + +## Relación con otros ADR +Implementa el componente de consentimiento de ADR-001. El esquema está en `DATA_MODEL.md` +(`consent_versions`, `consent_records`). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2ab9c38 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,99 @@ +# ARCHITECTURE.md — Plataforma de Encuestas Soberanas + +> Nombre/marca: **decisión Nivel 3 pendiente** (ver `CLAUDE.md` → Decisiones abiertas). +> Codename provisional del repo: `encuestas-soberanas`. +> Última edición: ver historial de git. Este documento es el ancla; los detalles vivos van en los ADR. + +--- + +## 1. Contexto + +La organización corre **encuestas de descubrimiento** (clientes, asistentes a talleres, ciudadanos) +y hoy las herramientas disponibles (Google Forms, ClickUp Forms) son insuficientes en cuatro ejes: +flexibilidad de tipos de pregunta, lógica condicional, autenticación/anonimato, y tabulación +posterior. El volumen no justifica una suite profesional, y se requiere **soberanía total sobre los +datos** porque se tratan datos sensibles (salud/discapacidad). + +El caso fundacional es el **mapeo de cuidadores** en organizaciones: colaboradores que cuidan a +personas con discapacidad o adultos mayores. Hay 4 empresas concretas listas para correr la encuesta +en esta primera etapa. + +Sobre ese caso se monta un objetivo de mayor alcance: desarrollar en Paraguay el concepto de +**"economía de la discapacidad"** mediante un mapeo de la vinculación, agregando datos de múltiples +empresas para identificar patrones y tendencias a nivel país. + +## 2. Propuesta de valor + +Una plataforma propia de encuestas con dos niveles de lectura que conviven: + +- **Nivel empresa (operativo, confidencial):** cada empresa recibe SU dashboard para diagnosticar y + decidir acciones sobre su gente — siempre sobre agregados, nunca sobre individuos. +- **Nivel macro (estudio país):** patrones y tendencias cruzando todas las empresas, base de la + "economía de la discapacidad". + +El diferencial real no es "otro Google Forms propio", sino: **(a)** motor de lógica condicional sin +límites, **(b)** reportes cruzados (filtrar respuesta A por respuesta B), **(c)** experiencia móvil +fluida, y **(d)** soberanía + cumplimiento de la Ley 7593/2025 desde el diseño (privacy by design). + +## 3. Lo que NO es (esta etapa) + +- **NO** es el builder visual de encuestas todavía. Esta etapa corre **una encuesta fija** (mapeo de + cuidadores) desplegada multi-empresa. El builder es el núcleo a futuro, no lo que se necesita HOY. +- **NO** implementa el modelo de lectura macro **atribuible** (Nivel 3). Las 4 encuestas de esta + etapa son anónimas; el dato queda permanentemente en Nivel 1/2. +- **NO** implementa re-identificación ni re-contacto de respondentes (incompatible con anónimo). +- **NO** implementa corresponsabilidad de tratamiento (solo Modo A en v1; ver ADR-001). +- **NO** integra el LLM (generación de encuestas / análisis de texto / resumen PDF) en v1 — está en + `BACKLOG.md`, es diferencial pero no fundacional. + +## 4. Stack + +Decidido en **ADR-002**. Resumen: + +| Capa | Tecnología | Razón | +|---|---|---| +| Frontend | Next.js (App Router) | SSR/SSG, ecosistema maduro, buena DX | +| Form engine | React + react-hook-form + zod | Flexible, validación tipada, sin lock-in | +| Backend / datos | Supabase (Postgres + Auth + RLS + Realtime) | Soberanía, self-host posible, RLS nativo | +| Reportes/gráficos | ECharts (o Recharts) | Gratis, potente, no-SaaS | +| Deploy | Vercel + Supabase — **residencia de datos a confirmar** | Ver ADR-002, decisión abierta | + +> ⚠️ La **residencia de los datos** (Supabase Cloud US/EU vs self-host) NO está cerrada y es +> relevante para "soberanía" + transferencia internacional bajo Ley 7593. Ver ADR-002. + +## 5. Modelo de datos + +Especificado en **`DATA_MODEL.md`** (contrato de esquema). Claude Code implementa las migraciones y +las policies de RLS en la Etapa 1 a partir de ese contrato; no inventa tablas. + +Principios estructurales (de ADR-001): +- Anonimato **por-encuesta** (propiedad de configuración, no global de plataforma). +- Las 4 encuestas de v1: **anónimas a nivel respondente**, conservando `company_id` y + cuasi-identificadores (edad, sector, zona) → mantiene vivo el Nivel 2 y la comparación por segmento. +- `respondent_id` (clave pseudónima) **reservado en el esquema pero NO usado** en estas encuestas. +- Consentimiento granular **versionado e inmutable** por respuesta (ADR-003). +- Dos ejes ortogonales de clasificación: **sensibilidad legal** (alimenta consentimiento) y + **seguridad** (alimenta RLS/cifrado). +- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con + n < umbral). + +## 6. ADRs iniciales + +- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*. +- **ADR-002** — Stack tecnológico y residencia de datos. +- **ADR-003** — Consentimiento granular versionado e inmutable. + +## 7. Riesgos + +| # | Riesgo | Mitigación | +|---|---|---| +| R1 | Re-identificación por cruce de cuasi-identificadores (empresas chicas, n=1) | Umbral de k-anonimato mandatorio en visualización; texto libre fuera del store analítico | +| R2 | Incumplimiento Ley 7593/2025 con datos sensibles | Privacy by design; validación con asesor legal paraguayo **antes** de producción con datos reales | +| R3 | Residencia de datos contradice "soberanía" | Decidir self-host vs cloud en ADR-002 antes de cargar datos reales | +| R4 | Construir "otro Google Forms" (mediocridad) | Invertir en lógica condicional, reportes cruzados y UX móvil — no en cantidad de features | +| R5 | Confusión anónimo vs pseudónimo en el equipo/agente | Distinción documentada textual en ADR-001 + regla en CLAUDE.md | +| R6 | Scope creep al builder visual antes de validar el caso fijo | "Lo que NO es" explícito; builder en BACKLOG, no en sprint 1 | + +> **Disclaimer legal:** este proyecto trata datos sensibles. Ninguno de estos documentos es asesoría +> legal. La Ley 7593/2025 aún no tiene reglamentación publicada. Validar el flujo de consentimiento y +> tratamiento con un especialista paraguayo antes de recolectar datos reales. diff --git a/BRIEF.md b/BRIEF.md new file mode 100644 index 0000000..c7c3104 --- /dev/null +++ b/BRIEF.md @@ -0,0 +1,54 @@ +# BRIEF — Sprint 1: Fundación de datos + +> Brief versionado. Arranca antes de pedirle código al agente (Patrón A.1). El detalle ejecutable de +> cada etapa va en `ETAPA_X_PROMPT.md`. + +--- + +## Objetivo (una frase) +Tener el modelo de datos de la plataforma implementado en Supabase — esquema + RLS + seed de la +encuesta de mapeo de cuidadores — como base verificable sobre la que después se montan formulario, +dashboard y wizard. + +## Por qué este sprint primero +Todo lo demás (formulario, dashboard, wizard) escribe o lee de este modelo. Un modelo con datos +sensibles mal diseñado no se migra gratis. Empezamos por el contrato de datos (ADR-001 + DATA_MODEL). + +## Alcance (qué entra) +1. Migraciones de Supabase para las tablas de `DATA_MODEL.md`. +2. Policies de RLS según la "Intención de RLS" de `DATA_MODEL.md`. +3. Constraint/trigger que rechaza `respondent_id` no nulo cuando `is_anonymous = true`. +4. Función `agg_segment(...)` que suprime celdas con `count < k_threshold`. +5. Tablas de consentimiento (`consent_versions`, `consent_records`) inmutables. +6. Seed: la encuesta de mapeo de cuidadores (preguntas B1/C1/D1 con lógica condicional, H1 con + límite, escalas, texto libre) cargada como `survey` + `questions` + reglas condicionales. +7. Seed de 1 organización de prueba. + +## Fuera de alcance (explícito) +- Frontend del formulario (Sprint 2). +- Dashboard / reportes (Sprint 3). +- Wizard de creación de encuestas (post-validación del caso fijo). +- Integración LLM (BACKLOG). +- Modo B / `controller_role` dual (reservado en esquema, no operado). +- `respondent_id` en uso (reservado, va NULL). +- Decisión de residencia de datos (ADR-002 abierto; v1 puede correr en Cloud con datos de prueba). + +## Decisiones ya tomadas (no renegociar — ver CLAUDE.md y ADR-001) +- Encuestas anónimas a nivel respondente; se conserva `company_id` + cuasi-identificadores. +- Dedup por `ip_hash`. Cuasi-identificadores en bucket (rango etario, no fecha exacta). +- Modo A. Consentimiento granular versionado inmutable. Umbral k = 5. +- El texto libre no se expone crudo a la capa analítica. + +## Criterios de éxito (checklist) +- [ ] `supabase db reset` / migraciones aplican limpio desde cero. +- [ ] El esquema generado coincide con `DATA_MODEL.md` (todas las tablas y columnas). +- [ ] Test de RLS: una organización NO puede leer filas crudas de otra (ni de la suya). +- [ ] Test: `INSERT` con `respondent_id` no nulo en encuesta anónima → falla. +- [ ] Test: `agg_segment` devuelve "n insuficiente" para un segmento con count < 5. +- [ ] Test: `UPDATE`/`DELETE` sobre `consent_records` → falla (inmutabilidad). +- [ ] Seed carga la encuesta de cuidadores con sus reglas condicionales íntegras. +- [ ] El director revisa el esquema generado (no solo "tests verdes"). + +## Etapas del sprint +- **Etapa 1** — Esquema + RLS + constraints + seed (este prompt: `ETAPA_1_PROMPT.md`). +- (Etapa 2+ se definen tras validar la Etapa 1.) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e220fa0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md — Reglas del proyecto + +> Este archivo lo carga el agente (Claude Code) por defecto. Contiene lo que aplica a **TODO** el +> proyecto: reglas no negociables, decisiones ya tomadas, política de iniciativa y verificaciones +> pre-entrega. El agente NO puede re-decidir lo que está acá. (Patrón B.4.) + +--- + +## Identidad del proyecto + +Plataforma de encuestas con soberanía de datos. Caso fundacional: mapeo de cuidadores en empresas. +Objetivo macro: "economía de la discapacidad" en Paraguay. Trata **datos sensibles** (salud/ +discapacidad) → la privacidad no es una feature, es una restricción de diseño. + +--- + +## Reglas no negociables (datos y privacidad) + +1. **Anonimato es por-encuesta**, no global. Cada encuesta declara su modo; el esquema y la lógica + de guardado lo respetan. Nunca asumir anonimato por defecto ni quitarlo por conveniencia. +2. **En encuestas anónimas, NUNCA escribir una clave de individuo** (`respondent_id`) ni cookie de + sesión linkeable. La deduplicación se hace con `ip_hash` (hash unidireccional + salt), nunca con + identidad. +3. **El umbral de k-anonimato es mandatorio en toda visualización compartida.** Ninguna celda, + gráfico o tabla expuesta a una empresa puede mostrar un segmento con **n < UMBRAL_K** (config del + proyecto, default 5). Esto aplica AUNQUE la encuesta sea anónima. +4. **El texto libre NO entra al store analítico crudo.** Se excluye o se categoriza antes (es un + vector de re-identificación). El gráfico/reporte trabaja sobre categorías, no sobre texto crudo. +5. **El consentimiento es evidencia, no UI.** Cada respuesta guarda un registro de consentimiento + versionado, con timestamp, inmutable, contra la versión del texto vigente al momento. Cambiar el + wording del consentimiento NO altera registros viejos. (Ver ADR-003.) +6. **La elección granular del titular (el respondente) siempre gana** sobre cualquier documento + firmado por la empresa. La empresa autoriza el despliegue; el individuo consiente el tratamiento + de su propio dato. +7. **Modo de responsable (`controller_role`) define el flujo de datos:** + - **Modo A** (nosotros = responsable): el dato puede alimentar el macro a todos los niveles que el + titular consintió. + - **Modo B** (empresa = responsable, nosotros = encargado): el dato **solo** puede alimentar el + macro vía **Nivel 1 anonimizado irreversible**. Nunca Nivel 2/3. + - **v1 corre solo Modo A.** El campo existe pero la lógica dual no se construye todavía. +8. **La empresa nunca accede a dato individual crudo.** Solo recibe agregados/dashboards autorizados + por encima del umbral. (Coherente con Modo A + k-anonimato.) + +--- + +## Distinción que NO se puede volver a confundir: anónimo vs pseudónimo + +- **Comparar segmentos** ("alguien de 25, sector X, zona A" vs "alguien de 25, sector X, zona B") se + hace con los **cuasi-identificadores guardados en cada fila**. NO requiere `respondent_id`. Vive en + datos 100% anónimos. +- **`respondent_id`** (clave pseudónima) sirve para vincular varias respuestas a la **misma persona + desconocida** (longitudinal individual, re-contacto, dedup avanzado). **Nada de eso es caso de v1.** +- Por lo tanto: anónimo a nivel respondente **alcanza** para la comparación que el producto necesita. + La comparación individuo-vs-individuo (n=1) está **prohibida** en dashboards compartidos. + +--- + +## Política de iniciativa proactiva (clasificar antes de actuar) + +Ante una mejora fuera del scope, el agente clasifica el impacto y actúa según el nivel. Evaluar en +este orden; el primero que aplique decide: + +1. ¿Resuelve un ítem de `TECH_DEBT.md` / `BACKLOG.md` / planificación? → **Nivel 3**. +2. ¿Choca con restricción explícita del prompt ("NO entra", "NO tocar", "agendado para etapa Z")? → **Nivel 3**. +3. ¿Toca privacidad, anonimato, consentimiento, clasificación de datos o RLS? → **Nivel 3 SIEMPRE**. +4. ¿Modifica paginación, layout, cantidad de archivos o estructura de salida? → **Nivel 2 mínimo**. +5. ¿Es visible al usuario final (texto, color, posición, contenido)? → **Nivel 2 mínimo**. +6. ¿Afecta marca, identidad, dependencias o configuración? → **Nivel 3**. +7. Si nada aplica → **Nivel 1**. + +- **Nivel 1** (interno, invisible, reversible): ejecutar; mencionar al final si fueron varios. +- **Nivel 2** (visible reversible): ejecutar **y reportar** como "decisión proactiva — validar/revertir". +- **Nivel 3** (marca/arquitectura/datos/privacidad): **NO ejecutar. Consultar antes** con el formato: + ``` + PROPUESTA DE CAMBIO NIVEL 3 — requiere aprobación + Cambio sugerido: [descripción] Justificación: [por qué] Impacto: [qué afecta] Alternativa si se rechaza: [qué hacer] + ``` + +Argumentos que NO bajan de nivel: "es legibilidad", "es consistencia", "es trivial de revertir", +"es un bug menor", "el usuario lo va a agradecer". Si está en TECH_DEBT/BACKLOG, es Nivel 3 sin +importar el tamaño técnico. + +--- + +## Verificaciones obligatorias antes de entregar (toda etapa) + +1. `git diff --name-only HEAD` → solo archivos autorizados por el prompt. Si aparece uno no + autorizado: revertir (`git checkout HEAD -- `), listar bajo "Conflictos de scope", no incluirlo. +2. Si una restricción del prompt (`NO tocar X`) colisiona con otro requisito (build limpio): + **DETENERSE y reportar el conflicto**, no resolver unilateralmente. +3. Reporte con **datos concretos**, no descripciones ("PDF: 78 KB, 4 págs, tabla 11 filas", + no "PDF generado correctamente"). +4. Separar explícitamente **Verificado** vs **Asumido** vs **Hallazgos no esperados**. +5. Tests verdes **NO** es etapa terminada. Si hay artefacto visible, el humano lo abre (validación + visual la hace el director, no el agente). + +--- + +## Decisiones ya tomadas (no consultar de nuevo) + +- Etapa 1 = una encuesta fija (mapeo cuidadores) multi-empresa. **NO** builder visual. +- Las 4 encuestas de v1: anónimas a nivel respondente + confidenciales (seguridad). +- Se conservan `company_id` + cuasi-identificadores. NO se usa `respondent_id`. +- Modo A (nosotros responsables). Sin Nivel 3 atribuible en esta etapa. +- Empresas actúan sobre agregados, nunca sobre individuos nombrados. +- Dedup por `ip_hash`. Consentimiento granular versionado e inmutable. +- Umbral de k-anonimato default = 5 (parametrizable por proyecto). +- Separador de miles y formato de cifras: **definir en `CONVENTIONS.md`** (ver Decisiones abiertas). + +--- + +## Decisiones abiertas (requieren al director, no las cierra el agente) + +- **Nombre/marca del producto** (Nivel 3). +- **Residencia de datos**: Supabase Cloud vs self-host (ADR-002). +- **`CONVENTIONS.md`**: formato de cifras, idioma de la UI, tono de los mensajes. Apenas una + convención se pregunte 2 veces, documentarla acá (anti-patrón AP.4). diff --git a/DATA_MODEL.md b/DATA_MODEL.md new file mode 100644 index 0000000..b5d2bbd --- /dev/null +++ b/DATA_MODEL.md @@ -0,0 +1,152 @@ +# DATA_MODEL.md — Contrato del modelo de datos + +> Este es el **contrato** que Claude Code implementa como migraciones de Supabase + policies de RLS +> en la Etapa 1. El DDL de abajo es **ilustrativo del contrato** (columnas, tipos, intención), no la +> migración final: el agente escribe las migraciones reales, constraints, índices y RLS. No agregar +> tablas que no estén acá sin aprobación (Nivel 3). + +Convención: snake_case, claves `uuid` (default `gen_random_uuid()`), timestamps `timestamptz`. +`UMBRAL_K` se referencia como constante de proyecto (default 5). + +--- + +## Diagrama lógico + +``` +organizations 1──┐ + │ +surveys ─────────┤ (controller_role, is_anonymous, sensibilidad, seguridad) + │ │ + ├─ questions ─┤ (type, is_sensitive auto, conditional_rules) + │ │ +responses ───────┘ (company_id, respondent_id NULLABLE, ip_hash, consent_record_id) + │ + ├─ answers (value JSONB) + │ +consent_versions 1──< consent_records (snapshot inmutable por respuesta) +``` + +--- + +## Tablas + +### `organizations` — empresas cliente +```sql +id uuid pk +name text not null +sector text -- para agrupar/benchmark sectorial (Nivel 2) +size_bucket text -- rango de tamaño, NO headcount exacto (minimización) +created_at timestamptz default now() +``` + +### `surveys` — definición de cada estudio +```sql +id uuid pk +org_id uuid fk -> organizations(id) -- empresa para la que corre +title text not null +status text default 'draft' -- draft | live | closed +-- Eje 1: anonimato (propiedad técnica, irreversible una vez live) +is_anonymous boolean not null -- true en las 4 de v1 +-- Eje 2a: sensibilidad legal (alimenta consentimiento) +data_sensitivity text not null -- 'comun' | 'sensible' | 'anonimizado' +-- Eje 2b: seguridad (alimenta RLS/cifrado) — ORTOGONAL al anterior +security_class text not null -- 'publico' | 'interno' | 'confidencial' +-- Rol de responsable +controller_role text not null default 'A' -- 'A' = nosotros | 'B' = empresa (no usado en v1) +-- Consentimiento vigente al publicar +consent_version_id uuid fk -> consent_versions(id) +-- Marca / look & feel +brand_config jsonb -- { logo_url, palette, ... } +k_threshold int not null default 5 -- umbral de k-anonimato para esta encuesta +created_at timestamptz default now() +published_at timestamptz +``` +> El agente NO calcula `data_sensitivity` a partir de texto libre del creador: ciertos `questions.type` +> (salud/discapacidad) **fuerzan** `is_sensitive = true` a nivel pregunta y elevan la encuesta a +> `sensible`. La herramienta constriñe, no solo permite (ver ADR-001). + +### `questions` +```sql +id uuid pk +survey_id uuid fk -> surveys(id) +code text -- 'B1','C1','H1'... (del mockup) +type text not null -- text_short|text_long|single_choice|multiple_choice| + -- rating|nps|matrix|ranking|date|slider|yes_no|section +prompt text not null +position int not null +is_sensitive boolean not null default false -- auto-true para tipos de salud/discapacidad +max_select int -- p/ multiple_choice con límite (H1 = 3) +options jsonb -- [{value,label}, ...] +conditional_rules jsonb -- [{ if_question, op, value, action: show|hide|skip, target }] +required boolean default false +``` + +### `responses` — una submission (anónima en v1) +```sql +id uuid pk +survey_id uuid fk -> surveys(id) +company_id uuid fk -> organizations(id) -- SE CONSERVA (habilita Nivel 2) +respondent_id uuid -- RESERVADO, NULL en encuestas anónimas +ip_hash text -- SHA-256 + salt, dedup sin identidad +consent_record_id uuid fk -> consent_records(id) not null +submitted_at timestamptz default now() +-- cuasi-identificadores denormalizados para segmentación eficiente (también viven en answers) +qi_age_bucket text -- rango, no edad exacta (minimización) +qi_sector text +qi_zone text +``` +> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar +> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`. +> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para +> minimizar re-identificación. + +### `answers` +```sql +id uuid pk +response_id uuid fk -> responses(id) +question_id uuid fk -> questions(id) +value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc. +``` +> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee +> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4). + +### `consent_versions` — textos de consentimiento versionados +```sql +id uuid pk +version text not null -- 'v1', 'v2'... +body text not null -- texto completo presentado al titular +scopes jsonb not null -- catálogo de scopes que este texto ofrece + -- ej: ["operativo","macro_n1"] (v1 NO incluye macro_n3) +created_at timestamptz default now() +``` + +### `consent_records` — qué consintió cada respuesta (INMUTABLE) +```sql +id uuid pk +consent_version_id uuid fk -> consent_versions(id) not null +granted_scopes jsonb not null -- subconjunto efectivamente aceptado por el titular + -- ej: {"operativo":true,"macro_n1":true} +granted_at timestamptz not null default now() +-- inmutable: sin UPDATE/DELETE; se inserta una vez junto con la response +``` +> En encuesta anónima NO hay revocación posible (no se puede ubicar la respuesta). El texto de +> consentimiento debe declararlo explícitamente al titular antes de responder (ADR-003). + +--- + +## Intención de RLS (Claude Code escribe las policies) + +- Una `organization` solo puede leer **agregados** de sus propias `responses`, nunca filas crudas, y + solo por encima de `k_threshold`. No existe policy que devuelva una fila individual a una empresa. +- El rol de **análisis macro** (interno, responsable = nosotros) puede leer filas para análisis + propio, pero la **capa de reportes/exportación** aplica el umbral de k-anonimato y excluye texto + libre crudo antes de exponer cualquier cosa. +- `consent_records` y `consent_versions`: solo INSERT y SELECT. Sin UPDATE/DELETE (inmutabilidad). + +--- + +## Vistas/funciones helper esperadas (Etapa 1) + +- `agg_segment(survey_id, dimensions[])` → agregación que **suprime** automáticamente celdas con + `count < k_threshold` (devuelve "n insuficiente" en lugar del valor). +- Un trigger/constraint que rechaza `respondent_id` no nulo cuando `is_anonymous = true`. diff --git a/ETAPA_1_PROMPT.md b/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..f083432 --- /dev/null +++ b/ETAPA_1_PROMPT.md @@ -0,0 +1,79 @@ +# ETAPA 1 — Fundación de datos (esquema + RLS + seed) + +> Prompt versionado (Patrón B.1). Claude Code lo ejecuta tras leer `CLAUDE.md`, `ARCHITECTURE.md`, +> `DATA_MODEL.md`, `docs/adr/ADR-001-*` y `sprints/sprint-1/BRIEF.md`. + +## 1. Objetivo +Implementar el modelo de datos completo en Supabase: migraciones, RLS, constraints de privacidad, +función de agregación con k-anonimato, tablas de consentimiento inmutables, y seed de la encuesta de +mapeo de cuidadores. Es la base verificable del producto. + +## 2. Contexto previo +Proyecto nuevo. No hay esquema todavía. El contrato de datos está en `DATA_MODEL.md` y la +justificación de cada decisión de privacidad en `ADR-001`. Esta es la primera etapa de código del +proyecto; no hay deuda previa. + +## 3. Decisiones ya tomadas (no consultar de nuevo) +- Encuestas anónimas a nivel respondente; `company_id` + cuasi-identificadores SE conservan. +- `respondent_id` reservado pero **NULL** (las 4 encuestas de v1 son anónimas). +- Dedup por `ip_hash`. Cuasi-identificadores en **bucket** (rango etario, no fecha exacta). +- Modo A (`controller_role = 'A'`). Consentimiento granular versionado **inmutable**. +- Umbral de k-anonimato default = 5 (`surveys.k_threshold`). +- Dos ejes de clasificación: `data_sensitivity` (legal) y `security_class` (seguridad), ortogonales. +- Stack: Supabase (Postgres + RLS). Migraciones SQL versionadas. + +## 4. Tareas +1. Crear migraciones para todas las tablas de `DATA_MODEL.md` (`organizations`, `surveys`, + `questions`, `responses`, `answers`, `consent_versions`, `consent_records`) con tipos, FKs e + índices razonables. +2. Trigger/constraint: rechazar `INSERT`/`UPDATE` en `responses` con `respondent_id` no nulo cuando + la `survey` referida tiene `is_anonymous = true`. +3. Inmutabilidad de consentimiento: revocar/denegar `UPDATE` y `DELETE` sobre `consent_records` y + `consent_versions` (vía RLS o trigger). +4. Función `agg_segment(p_survey_id uuid, p_dimensions text[])`: agrega por las dimensiones pedidas y + **suprime** (devuelve marcador de "n insuficiente") toda celda con `count < k_threshold`. +5. Policies de RLS según "Intención de RLS" de `DATA_MODEL.md`: ninguna policy devuelve filas crudas + de `responses`/`answers` a un rol de organización; el acceso de organización es solo vía agregados + por encima del umbral. +6. Seed: cargar la encuesta de mapeo de cuidadores como `survey` (anónima, sensible, confidencial, + Modo A) + sus `questions` con códigos del mockup (B1/B2/B4/B5, C1, D1, G4, H1 con `max_select=3`, + H3 texto libre) + `conditional_rules` (B1/C1/D1 despliegan sus sub-preguntas). Cargar 1 + `organization` de prueba y 1 `consent_version` v1 con scopes `["operativo","macro_n1"]`. + +## 5. Entregables / Definition of Done +- [ ] Migraciones aplican limpio desde cero (`supabase db reset` o equivalente). +- [ ] Esquema coincide 1:1 con `DATA_MODEL.md`. +- [ ] Tests (ver sección 6) verdes. +- [ ] Seed cargado y consultable. +- [ ] `git diff --name-only HEAD` contiene solo archivos de migración/seed/test autorizados. + +## 6. Validaciones automatizadas mandatorias (antes de entregar) +Correr y reportar resultado de cada una: +- `supabase db reset` (o migración desde cero) sin errores. +- Test RLS: como rol de `organization`, un `SELECT *` sobre `responses` de otra org devuelve 0 filas; + sobre la propia org también 0 filas crudas (solo agregados permitidos). +- Test constraint: `INSERT` en `responses` con `respondent_id` no nulo en encuesta anónima → ERROR. +- Test k-anonimato: `agg_segment` sobre un segmento sembrado con 3 respuestas → "n insuficiente"; + con ≥5 → valor real. +- Test inmutabilidad: `UPDATE` y `DELETE` sobre `consent_records` → ERROR. +- Verificación de seed: la encuesta tiene todas las preguntas esperadas y las reglas condicionales + enlazan a las preguntas correctas (conteo concreto, no "se cargó bien"). +- Si CUALQUIERA falla: **NO entregar**. Reportar el fallo y esperar instrucciones. + +## 7. Qué reportar (formato) +- **Verificado:** resultado concreto de cada validación (nº de tablas, nº de tests, salida de + `agg_segment` para n=3 y n=5, conteo de preguntas y de reglas del seed). +- **Asumido:** cualquier supuesto tomado (ej. tipos de columna no especificados, nombres de roles). +- **Hallazgos no esperados:** lo que descubriste y merece decisión, aunque no esté en el DoD. +- **Decisiones por iniciativa:** clasificadas por nivel (ver política en `CLAUDE.md`). +- **`git diff --name-only HEAD`:** pegá la salida. + +## 8. Qué NO hacer +- NO construir frontend, formulario ni dashboard (otras etapas). +- NO usar `respondent_id` (va NULL). +- NO implementar Modo B ni lógica dual de `controller_role`. +- NO exponer texto libre crudo en ninguna vista/función analítica. +- NO tocar nada de privacidad/anonimato/consentimiento de forma distinta a ADR-001 sin consultar + (es Nivel 3 siempre). +- Ante colisión entre una restricción y "build/tests limpios": **DETENERSE y reportar el conflicto**, + no resolver unilateralmente. diff --git a/GUIA_PRIMEROS_PASOS.md b/GUIA_PRIMEROS_PASOS.md new file mode 100644 index 0000000..f6204e1 --- /dev/null +++ b/GUIA_PRIMEROS_PASOS.md @@ -0,0 +1,126 @@ +# GUÍA — Primeros pasos con Claude Code + +> Cómo arrancar la Etapa 1 con Claude Code siguiendo el método. Tu rol acá es **director**: decidís +> qué se hace, validás el output, no traducís entre el agente y el código. + +--- + +## 0. Antes de tocar Claude Code + +1. Creá el repo y copiá estos archivos respetando la estructura: + ``` + encuestas-soberanas/ + ├── CLAUDE.md + ├── ARCHITECTURE.md + ├── DATA_MODEL.md + ├── .claude/ + │ ├── settings.json ← este (commiteable: deny compartido) + │ └── settings.local.json ← lo creás vos (gitignored: tus allow personales) + ├── docs/adr/ + │ ├── ADR-001-privacidad-y-gobierno-de-datos.md + │ ├── ADR-002-stack-tecnologico.md + │ └── ADR-003-consentimiento-versionado.md + └── sprints/sprint-1/ + ├── BRIEF.md + └── ETAPA_1_PROMPT.md + ``` +2. `git init`, primer commit con estos archivos **antes** de generar código. Así cada cambio del + agente es auditable contra un punto de partida limpio. +3. Agregá un `.gitignore` con `.env`, `.env.*`, `.claude/settings.local.json`. **El `.gitignore` es la + protección real de los secretos**; las reglas `Read(...)` de `settings.json` son defensa adicional + pero han tenido fallas conocidas — no dependas solo de ellas. + +## 1. Instalar Claude Code + +Se distribuye como paquete npm: `@anthropic-ai/claude-code`. La forma recomendada de instalar cambia +seguido (npm global, instalador nativo, etc.), así que seguí la oficial actual: +**https://docs.claude.com/en/docs/claude-code/overview**. Necesita Node.js; verificá la versión +mínima en esa misma página. Lo abrís desde la carpeta del repo para que cargue tu `.claude/`. + +## 2. Entender los permisos versionados (`.claude/settings.json`) + +El método separa **protecciones (deny, compartidas)** de **preferencias (allow, personales)**: + +- `settings.json` (commiteable) → reglas **deny** críticas que hereda todo el equipo: bloquea + `rm -rf` peligrosos, `git push --force`, `git reset --hard`, y lectura de secretos. Incluye + `"disableBypassPermissionsMode": "disable"`, que impide usar `--dangerously-skip-permissions` — es + la barrera de equipo contra el anti-patrón AP.7. +- `settings.local.json` (gitignored) → tus **allow** personales para que los comandos rutinarios + (`npm run`, `supabase migration`, etc.) no te pidan aprobación a cada paso. Ejemplo: + ```json + { + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Bash(npm install)", + "Bash(supabase migration new:*)", + "Bash(supabase db reset)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } + } + ``` +- Las reglas se evalúan **deny → ask → allow**; deny siempre gana. Aceptá la fricción ocasional de un + `ask` como precio de la gobernanza; no skipees permisos de rutina. + +## 3. Arrancar la Etapa 1 — mensaje inicial corto + +No pegues el prompt entero en el chat (anti-patrón AP.6). El prompt ya vive en el repo. Tu mensaje +inicial es corto y referencia paths (Patrón B.2). Algo así: + +``` +Vamos con la Etapa 1 del Sprint 1. + +Leé en este orden antes de tocar nada: +1. CLAUDE.md (reglas no negociables — especialmente privacidad y la política de niveles) +2. ARCHITECTURE.md y docs/adr/ADR-001-privacidad-y-gobierno-de-datos.md +3. DATA_MODEL.md (el contrato de esquema que vas a implementar) +4. sprints/sprint-1/BRIEF.md y sprints/sprint-1/ETAPA_1_PROMPT.md + +Ejecutá la Etapa 1 según ETAPA_1_PROMPT.md. Respetá el alcance: solo esquema + RLS + +constraints + función de k-anonimato + tablas de consentimiento + seed. Nada de frontend. + +Cuando termines, reportá en el formato de la sección 7 del prompt, con la salida de +git diff --name-only HEAD. No declares la etapa lista solo por tests verdes. +``` + +## 4. Tu loop de validación (cuando el agente reporta) + +No confíes en "todo OK". Aplicá las 3 preguntas (auditoría adversarial, Principio 3): + +1. **¿Los números cuadran?** Pedí la salida concreta de `agg_segment` para n=3 y n=5, el conteo de + preguntas del seed, el nº de tablas. Si el reporte describe en vez de mostrar, pedí los datos. +2. **¿Es el artefacto que pedí?** Abrí el esquema generado (Supabase Studio o el SQL de las + migraciones) **con tus ojos**. ¿Están las 7 tablas? ¿`responses.respondent_id` es nullable y va + NULL? ¿Existe el constraint anti-`respondent_id`? +3. **¿Qué NO menciona el reporte?** Si el reporte está más limpio de lo esperado, sospechá. Revisá + `git diff --name-only HEAD`: ¿tocó algún archivo fuera de scope? + +Checklist mínima específica de esta etapa: +- [ ] Probá vos mismo: como rol de organización, ¿podés leer una fila cruda? (debe dar 0) +- [ ] Probá: `INSERT` con `respondent_id` en encuesta anónima → debe fallar. +- [ ] Probá: `UPDATE` sobre `consent_records` → debe fallar. +- [ ] Mirá el seed: ¿B1/C1/D1 despliegan sus sub-preguntas según las reglas condicionales? + +## 5. Qué NO hacer (anti-patrones) + +- **No** declares la etapa lista por "tests verdes" sin abrir el esquema (AP.1). +- **No** pegues briefs gigantes en el chat; viven en el repo (AP.6). +- **No** uses `--dangerously-skip-permissions` de rutina (AP.7) — ya está bloqueado en settings.json. +- **No** dejes que el agente "arregle de paso" algo de privacidad/consentimiento: es Nivel 3, consulta antes (AP.8). +- Si una convención (formato de cifras, idioma) aparece 2 veces como pregunta → documentala en + `CONVENTIONS.md` (AP.4). + +## 6. Antes de cargar datos reales (no en v1 interno) + +- Resolver la **residencia de datos** (ADR-002 abierto): self-host vs Cloud. +- Validar el flujo de consentimiento y el lenguaje legal con un **asesor paraguayo** (Ley 7593/2025). +- Hasta entonces, v1 corre con **datos de prueba/simulados**, no con respuestas reales de ciudadanos. + +--- + +### Qué sigue después de la Etapa 1 +Validada la fundación de datos, las próximas etapas (a especificar en sus prompts) son: el formulario +funcional sobre este esquema, y luego el dashboard con los reportes cruzados. El wizard de creación +de encuestas viene después de validar el caso fijo. Cada una arranca con su prompt versionado. diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd7914c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "supabase": "^2.102.0" + }, + "scripts": { + "db:reset": "supabase db reset", + "db:test": "supabase test db", + "migration:new": "supabase migration new" + } +} diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..07b360b --- /dev/null +++ b/settings.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/claude-code-settings.json", + "permissions": { + "deny": [ + "Bash(rm -rf /:*)", + "Bash(rm -rf ~:*)", + "Bash(rm -rf /*)", + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git reset --hard:*)", + "Read(./.env)", + "Read(./.env.*)", + "Read(**/.env)", + "Read(**/*service_role*)", + "Read(**/secrets/**)" + ], + "ask": [ + "Bash(git push:*)", + "Bash(supabase db push:*)", + "Bash(supabase link:*)", + "Bash(npm publish:*)" + ], + "disableBypassPermissionsMode": "disable", + "defaultMode": "default" + } +} diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..764a972 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,413 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "Motor_de_encuestas" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default +# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in +# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early. +# auto_expose_new_tables = false + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260531000001_create_tables.sql b/supabase/migrations/20260531000001_create_tables.sql new file mode 100644 index 0000000..3cb3274 --- /dev/null +++ b/supabase/migrations/20260531000001_create_tables.sql @@ -0,0 +1,135 @@ +-- ============================================================ +-- Migration 001: Create base tables +-- Contract: DATA_MODEL.md — do not add tables without approval (Nivel 3) +-- Dependency order: organizations, consent_versions → surveys → questions +-- → consent_records → responses → answers +-- ============================================================ + +-- organizations: empresas cliente +CREATE TABLE IF NOT EXISTS public.organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + sector text, + size_bucket text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- consent_versions: textos de consentimiento versionados (INMUTABLES — ver migration 004) +CREATE TABLE IF NOT EXISTS public.consent_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + version text NOT NULL UNIQUE, + body text NOT NULL, + scopes jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- surveys: definición de cada estudio +-- Dos ejes de clasificación ortogonales (ADR-001 §2): +-- data_sensitivity → consentimiento / seguridad legal +-- security_class → RLS / cifrado (ORTOGONAL al anterior) +CREATE TABLE IF NOT EXISTS public.surveys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES public.organizations(id), + title text NOT NULL, + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'live', 'closed')), + -- Eje 1: anonimato — irreversible una vez publicada + is_anonymous boolean NOT NULL, + -- Eje 2a: sensibilidad legal → alimenta consentimiento + data_sensitivity text NOT NULL + CHECK (data_sensitivity IN ('comun', 'sensible', 'anonimizado')), + -- Eje 2b: seguridad → alimenta RLS/cifrado (ORTOGONAL al anterior) + security_class text NOT NULL + CHECK (security_class IN ('publico', 'interno', 'confidencial')), + -- Rol de responsable (Modo A = nosotros; Modo B = empresa. v1 solo usa A) + controller_role text NOT NULL DEFAULT 'A' + CHECK (controller_role IN ('A', 'B')), + consent_version_id uuid REFERENCES public.consent_versions(id), + brand_config jsonb, + -- Umbral de k-anonimato mandatorio (regla no negociable #3 CLAUDE.md) + k_threshold int NOT NULL DEFAULT 5 CHECK (k_threshold > 0), + created_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz +); + +-- questions: preguntas de la encuesta +-- is_sensitive: auto-true para tipos salud/discapacidad (ver trigger en migration 004) +CREATE TABLE IF NOT EXISTS public.questions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id uuid NOT NULL REFERENCES public.surveys(id) ON DELETE CASCADE, + code text NOT NULL, + type text NOT NULL + CHECK (type IN ( + 'text_short', 'text_long', + 'single_choice', 'multiple_choice', + 'rating', 'nps', 'matrix', 'ranking', + 'date', 'slider', 'yes_no', 'section' + )), + prompt text NOT NULL, + position int NOT NULL, + is_sensitive boolean NOT NULL DEFAULT false, + max_select int, + options jsonb, + -- Formato: [{ "if_question": "CODE", "op": "eq", "value": "yes", "action": "show|hide|skip", "target": "CODE" }] + conditional_rules jsonb, + required boolean NOT NULL DEFAULT false, + UNIQUE (survey_id, code), + UNIQUE (survey_id, position) +); + +-- consent_records: qué consintió cada respuesta (INMUTABLE — ver trigger en migration 004) +-- Sin UPDATE/DELETE — se inserta una vez junto con cada response +CREATE TABLE IF NOT EXISTS public.consent_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + consent_version_id uuid NOT NULL REFERENCES public.consent_versions(id), + granted_scopes jsonb NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now() +); + +-- responses: una submission +-- respondent_id RESERVADO y NULL en encuestas anónimas (regla no negociable #2 CLAUDE.md) +-- Trigger en migration 004 rechaza respondent_id no nulo cuando is_anonymous=true +-- Cuasi-identificadores en bucket — minimización: rango etario, no fecha exacta +CREATE TABLE IF NOT EXISTS public.responses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id uuid NOT NULL REFERENCES public.surveys(id), + company_id uuid NOT NULL REFERENCES public.organizations(id), + respondent_id uuid, -- RESERVADO, NULL en v1 (encuestas anónimas) + ip_hash text, -- SHA-256 + salt, dedup sin identidad + consent_record_id uuid NOT NULL REFERENCES public.consent_records(id), + submitted_at timestamptz NOT NULL DEFAULT now(), + -- Cuasi-identificadores denormalizados para segmentación eficiente + qi_age_bucket text, + qi_sector text, + qi_zone text +); + +-- answers: valor de cada pregunta en una submission +-- El texto libre (text_long) NO se expone en agg_segment (regla no negociable #4 CLAUDE.md) +CREATE TABLE IF NOT EXISTS public.answers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + response_id uuid NOT NULL REFERENCES public.responses(id) ON DELETE CASCADE, + question_id uuid NOT NULL REFERENCES public.questions(id), + value jsonb NOT NULL, + UNIQUE (response_id, question_id) +); + +-- ============================================================ +-- Indexes +-- ============================================================ +CREATE INDEX IF NOT EXISTS idx_surveys_org_id ON public.surveys(org_id); +CREATE INDEX IF NOT EXISTS idx_surveys_status ON public.surveys(status); + +CREATE INDEX IF NOT EXISTS idx_questions_survey ON public.questions(survey_id); +CREATE INDEX IF NOT EXISTS idx_questions_position ON public.questions(survey_id, position); + +-- Índice compuesto para agg_segment: filtro por encuesta y agrupación por QIs +CREATE INDEX IF NOT EXISTS idx_responses_survey ON public.responses(survey_id); +CREATE INDEX IF NOT EXISTS idx_responses_company ON public.responses(company_id); +CREATE INDEX IF NOT EXISTS idx_responses_ip_hash ON public.responses(ip_hash); +CREATE INDEX IF NOT EXISTS idx_responses_qi_seg ON public.responses(survey_id, qi_age_bucket, qi_sector, qi_zone); + +CREATE INDEX IF NOT EXISTS idx_answers_response ON public.answers(response_id); +CREATE INDEX IF NOT EXISTS idx_answers_question ON public.answers(question_id); + +CREATE INDEX IF NOT EXISTS idx_consent_rec_cv ON public.consent_records(consent_version_id); diff --git a/supabase/migrations/20260531000002_enable_rls.sql b/supabase/migrations/20260531000002_enable_rls.sql new file mode 100644 index 0000000..0cafedc --- /dev/null +++ b/supabase/migrations/20260531000002_enable_rls.sql @@ -0,0 +1,13 @@ +-- ============================================================ +-- Migration 002: Enable Row Level Security en todas las tablas +-- Intención de RLS: DATA_MODEL.md §"Intención de RLS" +-- Policies concretas: migration 003 +-- ============================================================ + +ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.consent_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.surveys ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.consent_records ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.answers ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/20260531000003_rls_policies.sql b/supabase/migrations/20260531000003_rls_policies.sql new file mode 100644 index 0000000..37d77cb --- /dev/null +++ b/supabase/migrations/20260531000003_rls_policies.sql @@ -0,0 +1,148 @@ +-- ============================================================ +-- Migration 003: RLS helper functions + policies +-- +-- Roles de la plataforma (via JWT custom claim "app_role"): +-- 'org_admin' → empresa cliente: accede solo a agregados de su org, nunca filas crudas +-- 'analyst' → análisis interno (InQ): accede vía service_role que bypasa RLS +-- 'respondent' → rellenador de encuesta: puede insertar, no leer +-- (no claim) → anon: puede leer encuestas live y consentimientos para rellenar el form +-- +-- CRÍTICO: ninguna policy devuelve filas crudas de responses/answers a org_admin +-- El acceso de org_admin es SOLO vía la función agg_segment (migration 005) +-- ============================================================ + +-- ============================================================ +-- Funciones helper para leer JWT claims +-- ============================================================ +CREATE OR REPLACE FUNCTION public.current_app_role() +RETURNS text +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE(auth.jwt() ->> 'app_role', '') +$$; + +CREATE OR REPLACE FUNCTION public.current_org_id() +RETURNS uuid +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT NULLIF(auth.jwt() ->> 'org_id', '')::uuid +$$; + +-- ============================================================ +-- organizations +-- org_admin ve solo su propia org; analyst usa service_role +-- ============================================================ +CREATE POLICY "org_select_own" + ON public.organizations FOR SELECT + TO authenticated + USING ( + (public.current_app_role() = 'org_admin' AND id = public.current_org_id()) + OR public.current_app_role() = 'analyst' + ); + +-- ============================================================ +-- consent_versions +-- Cualquiera (incluido anon) puede leer el texto de consentimiento +-- para mostrarlo al respondente antes de que envíe. +-- INSERT: solo via service_role (sin policy de INSERT para auth/anon) +-- UPDATE/DELETE: bloqueados por trigger en migration 004 +-- ============================================================ +CREATE POLICY "consent_versions_select_all" + ON public.consent_versions FOR SELECT + USING (true); + +-- ============================================================ +-- surveys +-- anon: solo encuestas live (para rellenar el formulario) +-- org_admin: todas sus encuestas en cualquier estado +-- analyst: service_role (bypasa RLS) +-- ============================================================ +CREATE POLICY "surveys_select_live_anon" + ON public.surveys FOR SELECT + TO anon + USING (status = 'live'); + +CREATE POLICY "surveys_select_auth" + ON public.surveys FOR SELECT + TO authenticated + USING ( + public.current_app_role() = 'analyst' + OR ( + public.current_app_role() = 'org_admin' + AND org_id = public.current_org_id() + ) + ); + +-- ============================================================ +-- questions +-- anon: preguntas de encuestas live (necesario para mostrar el form) +-- org_admin: preguntas de sus encuestas +-- ============================================================ +CREATE POLICY "questions_select_live" + ON public.questions FOR SELECT + TO anon + USING ( + EXISTS ( + SELECT 1 FROM public.surveys s + WHERE s.id = survey_id AND s.status = 'live' + ) + ); + +CREATE POLICY "questions_select_auth" + ON public.questions FOR SELECT + TO authenticated + USING ( + public.current_app_role() = 'analyst' + OR ( + public.current_app_role() = 'org_admin' + AND EXISTS ( + SELECT 1 FROM public.surveys s + WHERE s.id = survey_id AND s.org_id = public.current_org_id() + ) + ) + ); + +-- ============================================================ +-- consent_records +-- anon/authenticated pueden INSERT al enviar el formulario +-- SELECT: solo service_role (analyst interno) +-- UPDATE/DELETE: bloqueados por trigger en migration 004 +-- ============================================================ +CREATE POLICY "consent_records_insert" + ON public.consent_records FOR INSERT + TO anon, authenticated + WITH CHECK (true); + +-- ============================================================ +-- responses +-- CRÍTICO: NO hay policy SELECT para org_admin ni anon. +-- Resultado: org_admin recibe 0 filas en SELECT directo → solo puede +-- acceder mediante agg_segment, que aplica k_threshold. +-- INSERT: permitido a anon/authenticated solo para encuestas live. +-- ============================================================ +CREATE POLICY "responses_insert" + ON public.responses FOR INSERT + TO anon, authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.surveys s + WHERE s.id = survey_id AND s.status = 'live' + ) + ); + +-- ============================================================ +-- answers +-- Mismo patrón que responses: INSERT permitido, 0 SELECT para org_admin. +-- El texto libre (type='text_long') no se expone en agg_segment (regla #4). +-- ============================================================ +CREATE POLICY "answers_insert" + ON public.answers FOR INSERT + TO anon, authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.responses r + WHERE r.id = response_id + ) + ); diff --git a/supabase/migrations/20260531000004_constraints_triggers.sql b/supabase/migrations/20260531000004_constraints_triggers.sql new file mode 100644 index 0000000..4b6c5db --- /dev/null +++ b/supabase/migrations/20260531000004_constraints_triggers.sql @@ -0,0 +1,87 @@ +-- ============================================================ +-- Migration 004: Constraints y triggers de privacidad +-- +-- 1. Trigger: rechazar respondent_id no nulo en encuestas anónimas +-- → Regla no negociable #2 de CLAUDE.md (barrera técnica) +-- +-- 2. Trigger: inmutabilidad de consent_records +-- → ADR-003: sin UPDATE/DELETE, evidencia permanente del consentimiento +-- +-- 3. Trigger: inmutabilidad de consent_versions +-- → Textos de consentimiento históricos son inmutables +-- ============================================================ + +-- ============================================================ +-- 1. Trigger: respondent_id prohibido en encuestas anónimas +-- ============================================================ +CREATE OR REPLACE FUNCTION public.enforce_anonymous_no_respondent_id() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_is_anonymous boolean; +BEGIN + -- Leer la propiedad de la encuesta referida + SELECT is_anonymous INTO v_is_anonymous + FROM public.surveys + WHERE id = NEW.survey_id; + + IF v_is_anonymous AND NEW.respondent_id IS NOT NULL THEN + RAISE EXCEPTION + 'Privacy violation: respondent_id must be NULL in anonymous surveys (survey_id: %). ' + 'See CLAUDE.md rule #2.', + NEW.survey_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_responses_anonymous_no_respondent_id + BEFORE INSERT OR UPDATE ON public.responses + FOR EACH ROW EXECUTE FUNCTION public.enforce_anonymous_no_respondent_id(); + +-- ============================================================ +-- 2. Trigger: inmutabilidad de consent_records (ADR-003) +-- El consentimiento es evidencia, no UI. Nunca se modifica. +-- ============================================================ +CREATE OR REPLACE FUNCTION public.deny_consent_records_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'consent_records is immutable: % is not permitted. ' + 'Each response consent is permanent evidence (ADR-003).', + TG_OP + USING ERRCODE = 'insufficient_privilege'; +END; +$$; + +CREATE TRIGGER trg_consent_records_immutable + BEFORE UPDATE OR DELETE ON public.consent_records + FOR EACH ROW EXECUTE FUNCTION public.deny_consent_records_mutation(); + +-- ============================================================ +-- 3. Trigger: inmutabilidad de consent_versions +-- Los textos históricos no se alteran. Cambiar el wording del +-- consentimiento crea una nueva versión, nunca edita la existente. +-- ============================================================ +CREATE OR REPLACE FUNCTION public.deny_consent_versions_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION + 'consent_versions is immutable: % is not permitted. ' + 'Create a new version instead of modifying an existing one.', + TG_OP + USING ERRCODE = 'insufficient_privilege'; +END; +$$; + +CREATE TRIGGER trg_consent_versions_immutable + BEFORE UPDATE OR DELETE ON public.consent_versions + FOR EACH ROW EXECUTE FUNCTION public.deny_consent_versions_mutation(); diff --git a/supabase/migrations/20260531000005_agg_segment.sql b/supabase/migrations/20260531000005_agg_segment.sql new file mode 100644 index 0000000..0082100 --- /dev/null +++ b/supabase/migrations/20260531000005_agg_segment.sql @@ -0,0 +1,124 @@ +-- ============================================================ +-- Migration 005: Función agg_segment — agregación con k-anonimato +-- +-- DISEÑO DE SEGURIDAD: +-- SECURITY DEFINER → puede leer responses directamente (bypasa RLS). +-- Pero aplica tres capas de protección propias: +-- a) Valida que el caller (org_admin) sea dueño de la encuesta. +-- b) Whitelists las dimensiones permitidas (previene SQL injection). +-- c) Suprime toda celda con count < k_threshold → devuelve n=NULL, +-- suppressed=TRUE en lugar del valor real. +-- +-- El texto libre (type='text_long') NUNCA aparece en el output: +-- la función agrega solo sobre cuasi-identificadores de responses, +-- no sobre el contenido de answers (regla no negociable #4 CLAUDE.md). +-- +-- SIGNATURE: +-- agg_segment(p_survey_id uuid, p_dimensions text[]) +-- RETURNS TABLE(segment jsonb, n bigint, suppressed boolean) +-- +-- p_dimensions: subconjunto de ['qi_age_bucket', 'qi_sector', 'qi_zone'] +-- segment: objeto JSONB con los valores de las dimensiones pedidas +-- n: count del segmento, o NULL si suppressed +-- suppressed: TRUE si count < k_threshold de la encuesta +-- ============================================================ + +CREATE OR REPLACE FUNCTION public.agg_segment( + p_survey_id uuid, + p_dimensions text[] +) +RETURNS TABLE ( + segment jsonb, + n bigint, + suppressed boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_k_threshold int; + v_valid_dims CONSTANT text[] := ARRAY['qi_age_bucket', 'qi_sector', 'qi_zone']; + v_dim text; + v_select_cols text[] := ARRAY[]::text[]; + v_json_kv text[] := ARRAY[]::text[]; + v_group_cols text; + v_inner_sql text; + v_outer_sql text; +BEGIN + -- 1. Verificar que la encuesta existe y obtener su umbral de k-anonimato + SELECT k_threshold INTO v_k_threshold + FROM public.surveys + WHERE id = p_survey_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Survey not found: %', p_survey_id; + END IF; + + -- 2. Verificar autorización del caller + -- Si es org_admin, la encuesta debe pertenecer a su organización. + -- analyst usa service_role (bypasa RLS y esta función), no llega acá con app_role='analyst'. + IF public.current_app_role() = 'org_admin' THEN + IF NOT EXISTS ( + SELECT 1 FROM public.surveys + WHERE id = p_survey_id AND org_id = public.current_org_id() + ) THEN + RAISE EXCEPTION + 'Access denied: survey % does not belong to caller organization', + p_survey_id; + END IF; + END IF; + + -- 3. Validar que se pidió al menos una dimensión + IF p_dimensions IS NULL OR array_length(p_dimensions, 1) IS NULL THEN + RAISE EXCEPTION 'At least one dimension is required'; + END IF; + + -- 4. Validar dimensiones contra whitelist (prevención de SQL injection) + FOREACH v_dim IN ARRAY p_dimensions LOOP + IF NOT (v_dim = ANY(v_valid_dims)) THEN + RAISE EXCEPTION + 'Invalid dimension: "%" — allowed: %', + v_dim, + array_to_string(v_valid_dims, ', '); + END IF; + v_select_cols := v_select_cols || quote_ident(v_dim); + v_json_kv := v_json_kv || (quote_literal(v_dim) || ', ' || quote_ident(v_dim)); + END LOOP; + + v_group_cols := array_to_string(v_select_cols, ', '); + + -- 5. SQL interno: agrupar responses por las dimensiones pedidas + v_inner_sql := format( + 'SELECT + jsonb_build_object(%s) AS segment, + count(*) AS raw_n + FROM public.responses + WHERE survey_id = %L + GROUP BY %s', + array_to_string(v_json_kv, ', '), + p_survey_id, + v_group_cols + ); + + -- 6. SQL externo: aplicar umbral de k-anonimato + -- n = NULL y suppressed = TRUE cuando raw_n < k_threshold + -- n = raw_n y suppressed = FALSE cuando raw_n >= k_threshold + v_outer_sql := format( + 'SELECT + segment, + CASE WHEN raw_n >= %s THEN raw_n ELSE NULL END AS n, + (raw_n < %s) AS suppressed + FROM (%s) sub + ORDER BY suppressed DESC, segment', + v_k_threshold, + v_k_threshold, + v_inner_sql + ); + + RETURN QUERY EXECUTE v_outer_sql; +END; +$$; + +-- org_admin puede llamar a esta función (aplica su propia autorización interna) +GRANT EXECUTE ON FUNCTION public.agg_segment(uuid, text[]) TO authenticated; diff --git a/supabase/migrations/20260531000006_grants.sql b/supabase/migrations/20260531000006_grants.sql new file mode 100644 index 0000000..d065772 --- /dev/null +++ b/supabase/migrations/20260531000006_grants.sql @@ -0,0 +1,78 @@ +-- ============================================================ +-- Migration 006: GRANTs explícitos de tabla +-- +-- CONTEXTO: A partir de 2026-05-30, Supabase cambia el default de +-- auto_expose_new_tables a false. Sin GRANTs explícitos, los roles +-- anon/authenticated no pueden acceder a las tablas aunque haya +-- políticas RLS. Las RLS policies son la capa de restricción por-fila; +-- los GRANTs son la capa de acceso al objeto. Ambas son necesarias. +-- +-- Diseño de capas para responses/answers: +-- Capa 1 (objeto): authenticated tiene SELECT → el role puede hacer queries +-- Capa 2 (RLS): sin policy SELECT para org_admin → resultado = 0 filas +-- Capa 3 (datos): para obtener datos reales, org_admin llama agg_segment, +-- que aplica k_threshold internamente (SECURITY DEFINER) +-- +-- Esta layering cumple la intención del DATA_MODEL: "0 filas crudas", +-- no "permission denied". El resultado es indistinguible para el caller +-- (tabla vacía), pero la arquitectura es auditable y testeable con RLS. +-- +-- Principio mínimo de privilegios: +-- anon → solo lo necesario para responder una encuesta +-- authenticated → lo que necesita org_admin + respondentes autenticados +-- service_role → bypasa RLS, no necesita GRANTs adicionales aquí +-- ============================================================ + +-- Acceso al schema public +GRANT USAGE ON SCHEMA public TO anon, authenticated; + +-- ============================================================ +-- organizations +-- authenticated (org_admin) lee solo su org (filtrado por RLS) +-- ============================================================ +GRANT SELECT ON public.organizations TO authenticated; + +-- ============================================================ +-- consent_versions +-- anon lee el texto de consentimiento para mostrarlo en el form +-- ============================================================ +GRANT SELECT ON public.consent_versions TO anon, authenticated; + +-- ============================================================ +-- surveys +-- anon: encuestas live (para renderizar el form) +-- authenticated: sus encuestas en cualquier estado +-- ============================================================ +GRANT SELECT ON public.surveys TO anon, authenticated; + +-- ============================================================ +-- questions +-- anon: preguntas de encuestas live +-- authenticated: preguntas de sus encuestas +-- ============================================================ +GRANT SELECT ON public.questions TO anon, authenticated; + +-- ============================================================ +-- consent_records +-- INSERT: al enviar el form +-- SELECT: authenticated puede consultar (RLS filtra; analyst usa service_role) +-- ============================================================ +GRANT INSERT ON public.consent_records TO anon, authenticated; +GRANT SELECT ON public.consent_records TO authenticated; + +-- ============================================================ +-- responses +-- INSERT: al enviar el form +-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin) +-- El acceso con datos reales es solo via agg_segment (SECURITY DEFINER) +-- ============================================================ +GRANT INSERT ON public.responses TO anon, authenticated; +GRANT SELECT ON public.responses TO authenticated; + +-- ============================================================ +-- answers +-- INSERT: al enviar el form +-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin) +-- ============================================================ +GRANT INSERT ON public.answers TO anon, authenticated; +GRANT SELECT ON public.answers TO authenticated; diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..19eff4d --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,307 @@ +-- ============================================================ +-- Seed: Etapa 1 — Encuesta de Mapeo de Cuidadores +-- +-- Carga: +-- 1. 1 organización de prueba +-- 2. 1 consent_version v1 (scopes: operativo + macro_n1) +-- 3. 1 encuesta "Mapeo de Cuidadores" (anónima, sensible, confidencial, Modo A) +-- 4. 9 preguntas con códigos del mockup: +-- B1 (yes_no), B2, B4, B5 — situación de cuidado (B5 is_sensitive=true) +-- C1 (yes_no) — impacto laboral +-- D1 (yes_no) — apoyo recibido +-- G4 (single_choice) — frecuencia de estrés [condicional a C1=yes] +-- H1 (multiple_choice, max_select=3) — apoyo preferido +-- H3 (text_long) — comentarios libres +-- 5. conditional_rules: B1 → show B2/B4/B5; C1 → show G4 +-- +-- UUIDs fijos para reproducibilidad y referencia desde tests. +-- Nota: el mockup original no estaba disponible; los textos de pregunta +-- son diseñados según el contexto "mapeo de cuidadores". Ver ETAPA_1_PROMPT.md. +-- ============================================================ + +-- UUIDs de referencia: +-- org: 10000000-0000-0000-0000-000000000001 +-- consent_v1: 20000000-0000-0000-0000-000000000001 +-- survey: 30000000-0000-0000-0000-000000000001 +-- q_B1..q_H3: 4000...0001 al 4000...0009 + +-- ============================================================ +-- 1. Organización de prueba +-- ============================================================ +INSERT INTO public.organizations (id, name, sector, size_bucket) +VALUES ( + '10000000-0000-0000-0000-000000000001', + 'Empresa Demo S.A.', + 'Servicios', + '100-500' +) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. Versión de consentimiento v1 +-- Scopes ofrecidos: operativo (dashboard empresa) + macro_n1 (estudio país anonimizado) +-- NO incluye macro_n3 (atribuible) — decisión de v1 (ADR-001) +-- ============================================================ +INSERT INTO public.consent_versions (id, version, body, scopes) +VALUES ( + '20000000-0000-0000-0000-000000000001', + 'v1', + 'Esta encuesta es completamente anónima: no registramos tu nombre, número de documento ' + 'ni ningún dato que permita identificarte personalmente. ' + 'Tus respuestas se usarán con dos propósitos: ' + '(1) Ayudar a tu empresa a comprender la situación de cuidado de sus colaboradores y ' + 'diseñar apoyos más adecuados (uso operativo, solo agregados). ' + '(2) Contribuir a un estudio de alcance nacional sobre la "economía del cuidado" en Paraguay, ' + 'utilizando únicamente datos anonimizados e irreversiblemente desvinculados de cualquier empresa ' + '(Nivel 1 anonimizado). ' + 'Una vez enviada tu respuesta, al ser anónima, no es posible retirarla ni modificarla. ' + 'Podés elegir qué propósitos autorizás marcando las casillas a continuación.', + '["operativo", "macro_n1"]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 3. Encuesta: Mapeo de Cuidadores +-- Anónima + Sensible (contiene datos de salud/discapacidad) + Confidencial +-- Modo A: nosotros como responsables del tratamiento +-- k_threshold=5: ninguna celda expuesta con n<5 +-- ============================================================ +INSERT INTO public.surveys ( + id, org_id, title, status, + is_anonymous, data_sensitivity, security_class, controller_role, + consent_version_id, k_threshold +) +VALUES ( + '30000000-0000-0000-0000-000000000001', + '10000000-0000-0000-0000-000000000001', + 'Mapeo de Cuidadores — Edición 2026', + 'draft', + true, -- anónima (regla no negociable #2: respondent_id siempre NULL) + 'sensible', -- contiene preguntas de salud/discapacidad (B5) + 'confidencial', -- acceso restringido a la empresa y analistas internos + 'A', -- Modo A: InQ es responsable del tratamiento (v1 solo Modo A) + '20000000-0000-0000-0000-000000000001', + 5 -- umbral k-anonimato por defecto +) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 4. Preguntas de la encuesta +-- Orden: position define el flujo de presentación +-- conditional_rules en el formato: [{ if_question, op, value, action, target }] +-- if_question = code de la pregunta trigger +-- target = code de la pregunta afectada +-- ============================================================ + +-- B1: Pregunta disparadora — ¿Tenés persona a cargo? +-- Si es "yes" → se despliegan B2, B4, B5 +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, conditional_rules +) +VALUES ( + '40000000-0000-0000-0000-000000000001', + '30000000-0000-0000-0000-000000000001', + 'B1', + 'yes_no', + '¿Tenés a cargo a una o más personas con discapacidad permanente o adultos mayores ' + '(60 años o más) que requieran cuidados especiales de forma regular?', + 1, + false, + true, + '[ + {"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B2"}, + {"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B4"}, + {"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B5"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- B2: ¿Cuántas personas a cargo? (condicional a B1=yes) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, options +) +VALUES ( + '40000000-0000-0000-0000-000000000002', + '30000000-0000-0000-0000-000000000001', + 'B2', + 'single_choice', + '¿Cuántas personas a tu cargo requieren cuidado especial?', + 2, + false, + false, + '[ + {"value": "1", "label": "1 persona"}, + {"value": "2", "label": "2 personas"}, + {"value": "3+", "label": "3 o más personas"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- B4: Relación con la persona a cargo (condicional a B1=yes) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, options +) +VALUES ( + '40000000-0000-0000-0000-000000000003', + '30000000-0000-0000-0000-000000000001', + 'B4', + 'single_choice', + '¿Cuál es tu relación con la principal persona que cuidás?', + 3, + false, + false, + '[ + {"value": "hijo_a", "label": "Hijo/a"}, + {"value": "padre_madre", "label": "Padre, madre o suegro/a"}, + {"value": "conyuge", "label": "Cónyuge o pareja"}, + {"value": "hermano_a", "label": "Hermano/a"}, + {"value": "otro_familiar", "label": "Otro familiar"}, + {"value": "otra", "label": "Otra relación"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- B5: Tipo de discapacidad/condición (condicional a B1=yes) +-- is_sensitive=true: contiene datos de salud (categoría especial Ley 7593/2025) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, options +) +VALUES ( + '40000000-0000-0000-0000-000000000004', + '30000000-0000-0000-0000-000000000001', + 'B5', + 'multiple_choice', + '¿Qué tipo de discapacidad o condición tiene la persona que cuidás? ' + '(Podés marcar más de una opción)', + 4, + true, -- SENSIBLE: dato de salud/discapacidad (Ley 7593, categoría especial) + false, + '[ + {"value": "fisica", "label": "Física o motriz"}, + {"value": "visual", "label": "Visual"}, + {"value": "auditiva", "label": "Auditiva"}, + {"value": "mental", "label": "Mental o psicosocial"}, + {"value": "intelectual", "label": "Intelectual o cognitiva"}, + {"value": "am_sin_cert", "label": "Adulto mayor sin discapacidad certificada"}, + {"value": "otra", "label": "Otra condición"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- C1: Impacto laboral +-- Si es "yes" → se despliega G4 +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, conditional_rules +) +VALUES ( + '40000000-0000-0000-0000-000000000005', + '30000000-0000-0000-0000-000000000001', + 'C1', + 'yes_no', + '¿Sentís que tu rol de cuidador/a afecta tu asistencia, puntualidad o rendimiento ' + 'en el trabajo?', + 5, + false, + true, + '[ + {"if_question": "C1", "op": "eq", "value": "yes", "action": "show", "target": "G4"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- D1: Apoyo recibido de la empresa +-- Declarada como trigger de sub-preguntas; sub-preguntas se agregan en iteración posterior +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, conditional_rules +) +VALUES ( + '40000000-0000-0000-0000-000000000006', + '30000000-0000-0000-0000-000000000001', + 'D1', + 'yes_no', + '¿Tu empresa te ofrece actualmente algún apoyo o beneficio pensado para personas que ' + 'cuidan a familiares con discapacidad o adultos mayores?', + 6, + false, + true, + '[]'::jsonb -- sub-preguntas a agregar en iteración posterior (mockup pendiente) +) +ON CONFLICT (id) DO NOTHING; + +-- G4: Frecuencia de estrés (condicional a C1=yes) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, options +) +VALUES ( + '40000000-0000-0000-0000-000000000007', + '30000000-0000-0000-0000-000000000001', + 'G4', + 'single_choice', + '¿Con qué frecuencia la situación de cuidado te genera preocupación o estrés ' + 'durante tu jornada laboral?', + 7, + false, + false, + '[ + {"value": "nunca", "label": "Nunca"}, + {"value": "raramente", "label": "Raramente (1-2 veces por mes)"}, + {"value": "a_veces", "label": "A veces (1-2 veces por semana)"}, + {"value": "frecuentemente","label": "Frecuentemente (casi todos los días)"}, + {"value": "siempre", "label": "Siempre (todos los días)"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- H1: Apoyo preferido (max_select=3) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required, max_select, options +) +VALUES ( + '40000000-0000-0000-0000-000000000008', + '30000000-0000-0000-0000-000000000001', + 'H1', + 'multiple_choice', + '¿Qué tipos de apoyo de parte de la empresa valorarías más? ' + 'Elegí hasta 3 opciones.', + 8, + false, + false, + 3, -- max_select=3 (mockup H1) + '[ + {"value": "horario_flex", "label": "Horarios flexibles o trabajo híbrido"}, + {"value": "teletrabajo", "label": "Teletrabajo permanente"}, + {"value": "licencias", "label": "Licencias especiales para situaciones de cuidado"}, + {"value": "apoyo_eco", "label": "Apoyo económico o subsidio"}, + {"value": "orientacion", "label": "Orientación o asesoramiento profesional"}, + {"value": "grupos_apoyo", "label": "Grupos de apoyo entre pares"}, + {"value": "recursos_ext", "label": "Conexión con servicios o recursos externos"}, + {"value": "otro", "label": "Otro (especificar en comentarios)"} + ]'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +-- H3: Texto libre — NO expuesto en el store analítico crudo (regla #4 CLAUDE.md) +INSERT INTO public.questions ( + id, survey_id, code, type, prompt, position, + is_sensitive, required +) +VALUES ( + '40000000-0000-0000-0000-000000000009', + '30000000-0000-0000-0000-000000000001', + 'H3', + 'text_long', + 'Si querés compartir algo más sobre tu situación o tenés sugerencias para la empresa, ' + 'podés hacerlo acá. (Opcional — tu respuesta es anónima)', + 9, + false, + false +) +ON CONFLICT (id) DO NOTHING; diff --git a/supabase/tests/01_schema_and_logic.test.sql b/supabase/tests/01_schema_and_logic.test.sql new file mode 100644 index 0000000..b507b4b --- /dev/null +++ b/supabase/tests/01_schema_and_logic.test.sql @@ -0,0 +1,309 @@ +-- ============================================================ +-- Tests pgTAP: Etapa 1 — Esquema, RLS, Constraints, k-anonimato +-- +-- Ejecutar con: supabase test db +-- Requiere: Docker + supabase start + extensión pgtap +-- +-- Cubre las 6 validaciones mandatorias del ETAPA_1_PROMPT.md: +-- 1. Migración desde cero sin errores (verificado al correr supabase db reset) +-- 2. Esquema coincide con DATA_MODEL.md +-- 3. RLS: org_admin no ve filas crudas de responses ni answers +-- 4. Constraint: respondent_id en encuesta anónima → error +-- 5. k-anonimato: agg_segment n=3 → suppressed; n=5 → valor real +-- 6. Inmutabilidad: UPDATE/DELETE en consent_records → error +-- ============================================================ + +BEGIN; + +SELECT plan(25); + +-- ============================================================ +-- SECCIÓN 1: Estructura del esquema (7 tablas + columnas clave) +-- ============================================================ + +SELECT has_table('public', 'organizations', '1.1 tabla organizations existe'); +SELECT has_table('public', 'consent_versions', '1.2 tabla consent_versions existe'); +SELECT has_table('public', 'surveys', '1.3 tabla surveys existe'); +SELECT has_table('public', 'questions', '1.4 tabla questions existe'); +SELECT has_table('public', 'consent_records', '1.5 tabla consent_records existe'); +SELECT has_table('public', 'responses', '1.6 tabla responses existe'); +SELECT has_table('public', 'answers', '1.7 tabla answers existe'); + +-- respondent_id debe existir y ser nullable (reservado pero NULL en v1) +SELECT has_column('public', 'responses', 'respondent_id', + '1.8 responses.respondent_id existe (reservado)'); +SELECT col_is_null('public', 'responses', 'respondent_id', + '1.9 responses.respondent_id es nullable'); + +-- función agg_segment debe existir +SELECT has_function('public', 'agg_segment', ARRAY['uuid', 'text[]'], + '1.10 función agg_segment(uuid, text[]) existe'); + +-- ============================================================ +-- SECCIÓN 2: Constraint — respondent_id en encuesta anónima +-- (Regla no negociable #2 de CLAUDE.md) +-- ============================================================ +DO $$ +DECLARE + v_org_id uuid := '10000000-ffff-0000-0000-000000000001'; + v_cv_id uuid := '20000000-ffff-0000-0000-000000000001'; + v_survey_id uuid := '30000000-ffff-0000-0000-000000000001'; + v_cr_id uuid := '50000000-ffff-0000-0000-000000000001'; +BEGIN + INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_constraint_org'); + INSERT INTO public.consent_versions (id, version, body, scopes) + VALUES (v_cv_id, '_test-cv-constraint', 'body', '["operativo"]'::jsonb); + INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class) + VALUES (v_survey_id, v_org_id, '_test_anon_survey', true, 'sensible', 'confidencial'); + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb); +END; +$$; + +SELECT throws_ok( + format( + 'INSERT INTO public.responses (survey_id, company_id, respondent_id, consent_record_id) + VALUES (%L, %L, gen_random_uuid(), %L)', + '30000000-ffff-0000-0000-000000000001', + '10000000-ffff-0000-0000-000000000001', + '50000000-ffff-0000-0000-000000000001' + ), + '23514', -- check_violation SQLSTATE + NULL, + '2.1 respondent_id en encuesta anónima → check_violation' +); + +-- Una response SIN respondent_id en encuesta anónima debe poder insertarse +SELECT lives_ok( + format( + 'INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket) + VALUES (%L, %L, %L, %L)', + '30000000-ffff-0000-0000-000000000001', + '10000000-ffff-0000-0000-000000000001', + '50000000-ffff-0000-0000-000000000001', + '25-34' + ), + '2.2 response sin respondent_id en encuesta anónima → permitido' +); + +-- ============================================================ +-- SECCIÓN 3: Inmutabilidad de consent_records +-- (ADR-003: evidencia permanente del consentimiento) +-- ============================================================ +DO $$ +DECLARE + v_cv_id uuid := '20000000-eeee-0000-0000-000000000001'; + v_cr_id uuid := '50000000-eeee-0000-0000-000000000001'; +BEGIN + INSERT INTO public.consent_versions (id, version, body, scopes) + VALUES (v_cv_id, '_test-cv-immutable', 'body', '["operativo"]'::jsonb); + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb); +END; +$$; + +SELECT throws_ok( + format( + 'UPDATE public.consent_records SET granted_at = now() WHERE id = %L', + '50000000-eeee-0000-0000-000000000001' + ), + '42501', -- insufficient_privilege SQLSTATE + NULL, + '3.1 UPDATE en consent_records → error (inmutabilidad)' +); + +SELECT throws_ok( + format( + 'DELETE FROM public.consent_records WHERE id = %L', + '50000000-eeee-0000-0000-000000000001' + ), + '42501', + NULL, + '3.2 DELETE en consent_records → error (inmutabilidad)' +); + +-- ============================================================ +-- SECCIÓN 4: k-anonimato — agg_segment +-- k_threshold=5: n=3 debe estar suprimido; n=5 debe mostrar valor +-- ============================================================ +DO $$ +DECLARE + v_org_id uuid := '10000000-dddd-0000-0000-000000000001'; + v_cv_id uuid := '20000000-dddd-0000-0000-000000000001'; + v_survey_id uuid := '30000000-dddd-0000-0000-000000000001'; + v_cr_id uuid; + i int; +BEGIN + INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_kanonimato_org'); + INSERT INTO public.consent_versions (id, version, body, scopes) + VALUES (v_cv_id, '_test-cv-kanon', 'body', '["operativo"]'::jsonb); + INSERT INTO public.surveys ( + id, org_id, title, is_anonymous, data_sensitivity, security_class, k_threshold + ) + VALUES (v_survey_id, v_org_id, '_test_kanonimato_survey', true, 'comun', 'confidencial', 5); + + -- Insertar 3 responses en el mismo segmento (25-34 / Salud) + FOR i IN 1..3 LOOP + v_cr_id := gen_random_uuid(); + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb); + INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector) + VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud'); + END LOOP; +END; +$$; + +-- n=3 → suppressed debe ser TRUE, n debe ser NULL +SELECT ok( + (SELECT suppressed + FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket']) + WHERE (segment->>'qi_age_bucket') = '25-34'), + '4.1 agg_segment n=3 → suppressed=true' +); + +SELECT is( + (SELECT n + FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket']) + WHERE (segment->>'qi_age_bucket') = '25-34'), + NULL::bigint, + '4.2 agg_segment n=3 → n=NULL (celda suprimida)' +); + +-- Agregar 2 responses más → total 5 +DO $$ +DECLARE + v_cv_id uuid := '20000000-dddd-0000-0000-000000000001'; + v_survey_id uuid := '30000000-dddd-0000-0000-000000000001'; + v_org_id uuid := '10000000-dddd-0000-0000-000000000001'; + v_cr_id uuid; + i int; +BEGIN + FOR i IN 1..2 LOOP + v_cr_id := gen_random_uuid(); + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb); + INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector) + VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud'); + END LOOP; +END; +$$; + +-- n=5 → suppressed debe ser FALSE, n debe ser 5 +SELECT ok( + NOT (SELECT suppressed + FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket']) + WHERE (segment->>'qi_age_bucket') = '25-34'), + '4.3 agg_segment n=5 → suppressed=false' +); + +SELECT is( + (SELECT n + FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket']) + WHERE (segment->>'qi_age_bucket') = '25-34'), + 5::bigint, + '4.4 agg_segment n=5 → n=5 (valor real visible)' +); + +-- ============================================================ +-- SECCIÓN 5: RLS — org_admin no puede ver filas crudas +-- Se simula el rol 'authenticated' con JWT claim app_role='org_admin' +-- ============================================================ +DO $$ +DECLARE + v_org_a uuid := '10000000-aaaa-0000-0000-000000000001'; + v_cv_id uuid := '20000000-aaaa-0000-0000-000000000001'; + v_srv_id uuid := '30000000-aaaa-0000-0000-000000000001'; + v_cr_id uuid := gen_random_uuid(); + v_resp_count bigint; + v_ans_count bigint; +BEGIN + -- Setup: crear org, encuesta y una response + INSERT INTO public.organizations (id, name) VALUES (v_org_a, '_test_rls_org_a'); + INSERT INTO public.consent_versions (id, version, body, scopes) + VALUES (v_cv_id, '_test-cv-rls', 'body', '["operativo"]'::jsonb); + INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, status) + VALUES (v_srv_id, v_org_a, '_test_rls_survey', true, 'comun', 'confidencial', 'live'); + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb); + INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket) + VALUES (v_srv_id, v_org_a, v_cr_id, '35-44'); + + -- Simular JWT de org_admin de v_org_a + PERFORM set_config('request.jwt.claims', + json_build_object('app_role', 'org_admin', 'org_id', v_org_a::text)::text, + true + ); + + -- Cambiar al rol authenticated (con RLS activo) + SET LOCAL ROLE authenticated; + + SELECT count(*) INTO v_resp_count FROM public.responses; + SELECT count(*) INTO v_ans_count FROM public.answers; + + RESET ROLE; + + -- Limpiar config + PERFORM set_config('request.jwt.claims', '', true); + + IF v_resp_count != 0 THEN + RAISE EXCEPTION + 'RLS FAIL: org_admin ve % filas crudas en responses (esperado: 0)', + v_resp_count; + END IF; + + IF v_ans_count != 0 THEN + RAISE EXCEPTION + 'RLS FAIL: org_admin ve % filas crudas en answers (esperado: 0)', + v_ans_count; + END IF; +END; +$$; + +SELECT pass('5.1 RLS: org_admin ve 0 filas crudas en responses'); +SELECT pass('5.2 RLS: org_admin ve 0 filas crudas en answers'); + +-- ============================================================ +-- SECCIÓN 6: Verificación del seed +-- ============================================================ +SELECT is( + (SELECT count(*)::int FROM public.questions + WHERE survey_id = '30000000-0000-0000-0000-000000000001'), + 9, + '6.1 Seed: encuesta tiene 9 preguntas' +); + +SELECT is( + (SELECT count(*)::int FROM public.questions + WHERE survey_id = '30000000-0000-0000-0000-000000000001' + AND jsonb_array_length(COALESCE(conditional_rules, '[]'::jsonb)) > 0), + 3, + '6.2 Seed: 3 preguntas tienen conditional_rules (B1, C1, D1 declaradas)' +); + +-- B1 tiene exactamente 3 reglas condicionales (→ B2, B4, B5) +SELECT is( + (SELECT jsonb_array_length(conditional_rules) + FROM public.questions + WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'B1'), + 3, + '6.3 Seed: B1 tiene 3 reglas condicionales (despliega B2, B4, B5)' +); + +-- H1 tiene max_select=3 +SELECT is( + (SELECT max_select FROM public.questions + WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'H1'), + 3, + '6.4 Seed: H1 max_select=3' +); + +-- B5 es la única pregunta marcada is_sensitive=true +SELECT is( + (SELECT count(*)::int FROM public.questions + WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND is_sensitive = true), + 1, + '6.5 Seed: exactamente 1 pregunta is_sensitive=true (B5)' +); + +SELECT * FROM finish(); +ROLLBACK;