Primer commit - Sprint 1

This commit is contained in:
markosbenitez
2026-05-31 04:33:07 -03:00
commit ef9e4bf998
22 changed files with 2515 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -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

View File

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

View File

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

View File

@@ -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`).

99
ARCHITECTURE.md Normal file
View File

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

54
BRIEF.md Normal file
View File

@@ -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.)

117
CLAUDE.md Normal file
View File

@@ -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 -- <archivo>`), 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).

152
DATA_MODEL.md Normal file
View File

@@ -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`.

79
ETAPA_1_PROMPT.md Normal file
View File

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

126
GUIA_PRIMEROS_PASOS.md Normal file
View File

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

10
package.json Normal file
View File

@@ -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"
}
}

26
settings.json Normal file
View File

@@ -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"
}
}

8
supabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

413
supabase/config.toml Normal file
View File

@@ -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://<database>/<schema>/<hook_name>"
# 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. <bucket_name>.s3-<region>.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\"}"

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
)
);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

307
supabase/seed.sql Normal file
View File

@@ -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;

View File

@@ -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;