Primer commit - Sprint 1
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
|
||||
110
ADR-001-privacidad-y-gobierno-de-datos.md
Normal file
110
ADR-001-privacidad-y-gobierno-de-datos.md
Normal 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.
|
||||
52
ADR-002-stack-tecnologico.md
Normal file
52
ADR-002-stack-tecnologico.md
Normal 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.
|
||||
54
ADR-003-consentimiento-versionado.md
Normal file
54
ADR-003-consentimiento-versionado.md
Normal 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
99
ARCHITECTURE.md
Normal 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
54
BRIEF.md
Normal 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
117
CLAUDE.md
Normal 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
152
DATA_MODEL.md
Normal 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
79
ETAPA_1_PROMPT.md
Normal 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
126
GUIA_PRIMEROS_PASOS.md
Normal 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
10
package.json
Normal 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
26
settings.json
Normal 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
8
supabase/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
413
supabase/config.toml
Normal file
413
supabase/config.toml
Normal 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\"}"
|
||||
135
supabase/migrations/20260531000001_create_tables.sql
Normal file
135
supabase/migrations/20260531000001_create_tables.sql
Normal 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);
|
||||
13
supabase/migrations/20260531000002_enable_rls.sql
Normal file
13
supabase/migrations/20260531000002_enable_rls.sql
Normal 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;
|
||||
148
supabase/migrations/20260531000003_rls_policies.sql
Normal file
148
supabase/migrations/20260531000003_rls_policies.sql
Normal 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
|
||||
)
|
||||
);
|
||||
87
supabase/migrations/20260531000004_constraints_triggers.sql
Normal file
87
supabase/migrations/20260531000004_constraints_triggers.sql
Normal 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();
|
||||
124
supabase/migrations/20260531000005_agg_segment.sql
Normal file
124
supabase/migrations/20260531000005_agg_segment.sql
Normal 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;
|
||||
78
supabase/migrations/20260531000006_grants.sql
Normal file
78
supabase/migrations/20260531000006_grants.sql
Normal 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
307
supabase/seed.sql
Normal 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;
|
||||
309
supabase/tests/01_schema_and_logic.test.sql
Normal file
309
supabase/tests/01_schema_and_logic.test.sql
Normal 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;
|
||||
Reference in New Issue
Block a user