Compare commits

13 Commits

Author SHA1 Message Date
markosbenitez
5c17a9846a feat(admin): filtro por empresa + contadores filtrados 2026-06-17 10:01:59 -03:00
markosbenitez
b9a3118c84 Filtro en listado de respuestas admin 2026-06-17 09:25:18 -03:00
markosbenitez
cfd2eb0624 docs: estado del proyecto 11 junio 2026 2026-06-11 16:56:17 -03:00
markosbenitez
edf82bf7fd feat(dashboard): selector de empresa + flag p_all en _dash_responses 2026-06-11 16:35:03 -03:00
markosbenitez
60c9c81224 estado del proyecto 2026-06-10 21:42:14 -03:00
markosbenitez
1bab6874a9 feat(admin): colapsar/expandir submissions + eliminar columna Tipo 2026-06-10 21:41:47 -03:00
markosbenitez
933d00ad58 Etapa 2G: C.8 promovido a estable + AP.10/AP.11 documentados con casos reales 2026-06-10 21:36:33 -03:00
markosbenitez
059dfa071e Etapa 3C parte 2: orgs y surveys piloto InQuality y Fundación Solidaridad
Agrega organizations + surveys para los dos pilotos en espacio de pruebas
real, con su propio set de 44 preguntas (mismo instrumento que el survey
demo, IDs namespaced 02xx/03xx). Survey demo (...0001) intacto.
2026-06-10 21:05:06 -03:00
markosbenitez
2d13982d92 Pin next a 15.3.8 sin caret
Mantiene el estilo de pin exacto que tenía next antes de CVE-2025-66478,
evitando que un futuro npm install resuelva versiones 15.x posteriores
sin pasar por revisión.
2026-06-10 20:49:43 -03:00
markosbenitez
92a7923a26 Con las encuestas para 3 empresas 2026-06-10 16:55:41 -03:00
markosbenitez
af0ec65be5 Documentar deuda técnica: /admin/responses sin autenticación
Registra TECH-DEBT-008 como bloqueante para ampliar la vista de
control a preguntas is_sensitive=true (salud/discapacidad).
2026-06-10 16:30:42 -03:00
markosbenitez
157e108322 Los triggers 1.1, 1.6, 1.13 muestran solo Sí · No (sin "Prefiero no decir")
Al responder "No" en las tres, el formulario salta a 6.4 (no muestra secciones 2-5 ni 6.1-6.3)
La pregunta 6.6 aparece antes del cierre
Las preguntas multiple_choice muestran el hint "Podés marcar más de una opción"
2026-06-10 15:49:05 -03:00
markosbenitez
bf963e8894 Con el listado de encuestas cargadas/respondidas 2026-06-10 08:59:50 -03:00
31 changed files with 4841 additions and 158 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -6,6 +6,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-publica>
# NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente # NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente
# La service_role bypasa RLS — solo para Edge Functions / backend server-side # La service_role bypasa RLS — solo para Edge Functions / backend server-side
# Usada por app/admin/responses (Server Component) para listar respuestas sin RLS
SUPABASE_SERVICE_ROLE_KEY=
# IP hash — variable server-only (NUNCA NEXT_PUBLIC_) # IP hash — variable server-only (NUNCA NEXT_PUBLIC_)
# Usada para deduplicación anónima. Generá un string aleatorio largo. # Usada para deduplicación anónima. Generá un string aleatorio largo.

384
INSTALL_SUPABASE.md Normal file
View File

@@ -0,0 +1,384 @@
# INSTALL_SUPABASE.md — Supabase self-hosted en Dokploy v0.29.4
> Guía operativa basada en la instalación real del proyecto "Datos País sobre el Cuidado" (junio 2026).
> Registra los pasos exactos, errores conocidos y sus soluciones.
---
## Decisiones de arquitectura
- **Supabase self-hosted completo** (Postgres + Auth + Storage + Realtime + Studio + Kong).
- **Partida limpia** — no se migran datos desde Cloud, se aplican las migraciones del repo.
- **Gestión vía Git** — el compose y los volumes viven en un repo dedicado; Dokploy clona ese repo.
Las variables de entorno viven en Dokploy (no en el repo).
- **Backups locales** diarios, retención 30 días. Remoto: TECH-DEBT-006.
- **Studio** no expuesto a internet. Acceso solo vía SSH tunnel.
---
## Requisitos previos
- VPS Ubuntu 22.04+ con Docker 29+ instalado.
- Dokploy v0.29.4 instalado y accesible.
- Repo Git disponible (Gitea o GitHub) con acceso desde el VPS y desde Dokploy.
- Acceso SSH al VPS.
- Supabase CLI en la máquina de desarrollo local.
---
## PARTE 1 — Preparar el repo de Supabase
### 1.1 Descargar el stack oficial en el VPS
```bash
curl -fsSL https://supabase.link/setup.sh | sh
```
Crea `~/supabase-project/` con esta estructura:
```
~/supabase-project/
├── docker-compose.yml
├── volumes/
│ ├── api/
│ │ ├── kong.yml <- API gateway (EDITAR: ver 1.2)
│ │ └── kong-entrypoint.sh
│ ├── db/
│ │ ├── _supabase.sql <- CRITICO: crea la base _supabase
│ │ ├── roles.sql
│ │ ├── jwt.sql
│ │ ├── realtime.sql
│ │ ├── pooler.sql
│ │ ├── webhooks.sql
│ │ ├── logs.sql
│ │ └── init/data.sql
│ └── pooler/
│ └── pooler.exs
```
> Estos archivos son los scripts de inicializacion que Postgres ejecuta la primera vez.
> Sin ellos la base _supabase no se crea y el stack no funciona.
> Por eso NO se puede usar "docker raw" en Dokploy — ese modo no tiene contexto del directorio.
### 1.2 Ajustar kong.yml
El kong.yml define las credenciales del API gateway. Verificar que use los mismos
nombres de variables que se definen en Dokploy:
```bash
grep -n "keyauth" ~/supabase-project/volumes/api/kong.yml
```
Deben quedar dos bloques con UNA sola entrada cada uno:
```yaml
keyauth_credentials:
- key: $ANON_KEY # nombre exacto de la variable en Dokploy
keyauth_credentials:
- key: $SERVICE_ROLE_KEY # nombre exacto de la variable en Dokploy
```
ERROR comun: si los dos bloques resuelven al mismo valor JWT, Kong falla con
"uniqueness violation". Verificar que ANON_KEY y SERVICE_ROLE_KEY son distintos.
### 1.3 Crear .gitignore
```bash
cat > ~/supabase-project/.gitignore << 'EOF'
.env
.env.local
volumes/db/data/
volumes/storage/
volumes/logs/
*.log
EOF
```
### 1.4 Push al repo Git
```bash
cd ~/supabase-project
git init
git add docker-compose.yml volumes/api/ volumes/db/ volumes/pooler/ .gitignore
git commit -m "chore: supabase self-hosted stack inicial"
git remote add origin https://TU_GIT/usuario/supabase-repo.git
git push -u origin master
```
El .env con los secrets NO se sube (esta en .gitignore).
---
## PARTE 2 — Generar los secrets
Usar siempre openssl rand -hex (solo hex, sin caracteres especiales que rompan el escaping de Docker).
### 2.1 Valores base (en el VPS)
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "JWT_SECRET=$(openssl rand -hex 40)"
echo "DASHBOARD_PASSWORD=$(openssl rand -hex 16)"
echo "SECRET_KEY_BASE=$(openssl rand -hex 40)"
echo "VAULT_ENC_KEY=$(openssl rand -hex 20)"
echo "PG_META_CRYPTO_KEY=$(openssl rand -hex 24)"
```
Guardar todos en un gestor de contrasenas antes de continuar.
### 2.2 Generar ANON_KEY y SERVICE_ROLE_KEY
Con el JWT_SECRET generado, usar Node.js en el VPS:
```bash
node -e "
const crypto = require('crypto');
const secret = 'PEGAR_AQUI_JWT_SECRET';
function b64url(obj) {
return Buffer.from(JSON.stringify(obj)).toString('base64url');
}
function makeJWT(role) {
const h = b64url({alg:'HS256',typ:'JWT'});
const p = b64url({role,iss:'supabase',iat:1700000000,exp:2000000000});
const sig = crypto.createHmac('sha256',secret).update(h+'.'+p).digest('base64url');
return h+'.'+p+'.'+sig;
}
console.log('ANON_KEY='+makeJWT('anon'));
console.log('SERVICE_ROLE_KEY='+makeJWT('service_role'));
"
```
---
## PARTE 3 — Configurar el servicio en Dokploy
### 3.1 Crear el servicio
1. Projects -> seleccionar proyecto -> Create Service -> Docker Compose.
2. Provider: Gitea (o GitHub) -> seleccionar el repo del paso 1.4.
3. Branch: master | Compose Path: ./docker-compose.yml
4. IMPORTANTE: desactivar Autodeploy (toggle OFF).
### 3.2 Variables de entorno (pestaña Environment)
Pegar el bloque completo reemplazando los valores:
```env
POSTGRES_PASSWORD=<openssl rand -hex 24>
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432
JWT_SECRET=<openssl rand -hex 40>
JWT_EXPIRY=3600
ANON_KEY=<JWT anon>
SERVICE_ROLE_KEY=<JWT service_role>
SUPABASE_ANON_KEY=<igual a ANON_KEY>
SUPABASE_SERVICE_KEY=<igual a SERVICE_ROLE_KEY>
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=<openssl rand -hex 16>
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
SECRET_KEY_BASE=<openssl rand -hex 40>
VAULT_ENC_KEY=<openssl rand -hex 20>
PG_META_CRYPTO_KEY=<openssl rand -hex 24>
SITE_URL=http://localhost:3000
API_EXTERNAL_URL=http://localhost:8000
SUPABASE_PUBLIC_URL=http://localhost:8000
STUDIO_DEFAULT_ORGANIZATION=NombreOrg
STUDIO_DEFAULT_PROJECT=NombreProyecto
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=
ENABLE_EMAIL_SIGNUP=false
ENABLE_EMAIL_AUTOCONFIRM=false
ENABLE_PHONE_SIGNUP=false
ENABLE_PHONE_AUTOCONFIRM=false
DISABLE_SIGNUP=true
ENABLE_ANONYMOUS_USERS=false
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false
MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify
MAILER_URLPATHS_INVITE=/auth/v1/verify
MAILER_URLPATHS_RECOVERY=/auth/v1/verify
MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify
ADDITIONAL_REDIRECT_URLS=
POOLER_TENANT_ID=pooler
POOLER_DEFAULT_POOL_SIZE=20
POOLER_MAX_CLIENT_CONN=100
POOLER_PROXY_PORT_TRANSACTION=6543
POOLER_DB_POOL_SIZE=10
STORAGE_TENANT_ID=default
GLOBAL_S3_BUCKET=
REGION=
S3_PROTOCOL_ACCESS_KEY_ID=
S3_PROTOCOL_ACCESS_KEY_SECRET=
LOGFLARE_LOGGER_BACKEND_API_KEY=logflare-local-key
LOGFLARE_API_KEY=logflare-local-key
FUNCTIONS_VERIFY_JWT=true
IMGPROXY_ENABLE_WEBP_DETECTION=true
IMGPROXY_AUTO_WEBP=true
PGRST_DB_SCHEMAS=public,storage,graphql_public
```
### 3.3 Deploy y validacion
Click en Deploy. Cuando termine verificar:
```bash
docker ps --format "table {{.Names}}\t{{.Status}}" | grep supabase
docker exec supabase-db psql -U postgres -c 'SELECT version();'
curl -s http://localhost:8000/rest/v1/ -H "apikey: <ANON_KEY>" | head -c 200
docker exec supabase-db psql -U postgres -c '\l' | grep supabase
```
Contenedores esperados healthy: supabase-db, supabase-kong, supabase-studio,
supabase-auth, supabase-rest, supabase-storage, supabase-meta, supabase-pooler,
supabase-imgproxy, realtime-dev.supabase-realtime.
supabase-edge-functions puede quedar en Restarting si no se usa Edge Functions (no critico).
---
## PARTE 4 — Aplicar las migraciones del proyecto
El puerto 5432 no esta expuesto externamente. Las migraciones se aplican desde el VPS.
### 4.1 Copiar migraciones al VPS (desde la maquina de desarrollo)
```bash
ssh administrator@<VPS_IP> "mkdir -p ~/migrations"
scp -r supabase/migrations/ administrator@<VPS_IP>:~/migrations/
```
### 4.2 Aplicar en orden
```bash
for f in $(ls ~/migrations/*.sql | sort); do
echo "Aplicando: $f"
docker exec -i supabase-db psql -U postgres -d postgres < "$f"
echo "---"
done
```
### 4.3 Verificar
```bash
docker exec supabase-db psql -U postgres -d postgres -c \
"SELECT tablename, policyname FROM pg_policies WHERE schemaname='public' ORDER BY tablename;"
```
### Nota: auth.jwt() en GoTrue v2.189+
GoTrue v2.189+ elimino auth.jwt(). Las funciones disponibles son auth.uid(), auth.role().
Las politicas que usen custom claims JWT (app_role, org_id) fallaran. Ver TECH-DEBT-007.
No es bloqueante para el piloto porque el dashboard usa service_role que bypasa RLS.
---
## PARTE 5 — Backups locales
### 5.1 Crear el script
```bash
sudo mkdir -p /var/backups/supabase-prod
sudo chmod 700 /var/backups/supabase-prod
sudo tee /usr/local/bin/supabase-backup.sh > /dev/null << 'SCRIPT'
#!/usr/bin/bash
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/var/backups/supabase-prod/supabase_$TS.sql.gz
docker exec supabase-db pg_dump -U postgres postgres | gzip > "$OUT"
chmod 600 "$OUT"
find /var/backups/supabase-prod -name "supabase_*.sql.gz" -mtime +30 -delete
echo "Backup OK: $OUT ($(du -h "$OUT" | cut -f1))"
SCRIPT
sudo chmod +x /usr/local/bin/supabase-backup.sh
```
### 5.2 Probar y configurar cron
```bash
sudo bash /usr/local/bin/supabase-backup.sh
sudo crontab -e
# Agregar: 0 3 * * * /usr/bin/bash /usr/local/bin/supabase-backup.sh >> /var/log/supabase-backup.log 2>&1
```
### 5.3 Probar restore (obligatorio)
```bash
docker exec supabase-db createdb -U postgres restore_test
gunzip < $(ls /var/backups/supabase-prod/*.sql.gz | tail -1) | \
docker exec -i supabase-db psql -U postgres restore_test
docker exec supabase-db psql -U postgres -d restore_test -c \
"SELECT count(*) FROM pg_tables WHERE schemaname='public';"
docker exec supabase-db dropdb -U postgres restore_test
```
---
## PARTE 6 — Dominio y SSL (pendiente)
En Dokploy, pestaña Domains del servicio supabase:
- Agregar dominio db.dominio.com -> apuntar a supabase-kong:8000 -> activar SSL.
- Studio NO tiene dominio publico. Acceso solo via SSH tunnel:
ssh -L 3001:localhost:3001 administrator@VPS_IP -> abrir http://localhost:3001
Actualizar variables de entorno:
```
API_EXTERNAL_URL=https://db.dominio.com
SUPABASE_PUBLIC_URL=https://db.dominio.com
```
---
## PARTE 7 — Conectar la app Next.js
Actualizar variables de entorno de la app Next.js en Dokploy:
```
NEXT_PUBLIC_SUPABASE_URL=https://db.dominio.com
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY>
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY>
```
---
## Errores conocidos y soluciones
| Error | Causa | Solucion |
|---|---|---|
| password authentication failed for supabase_storage_admin | POSTGRES_PASSWORD con caracteres especiales | Usar openssl rand -hex 24 |
| database "_supabase" does not exist | volumes/db/ no accesible | Usar modo Git en Dokploy, no docker raw |
| keyauth_credentials uniqueness violation | ANON_KEY = SERVICE_ROLE_KEY o duplicados en kong.yml | Una sola entrada por bloque en kong.yml |
| exec: kong-entrypoint.sh is a directory | Volumen corrupto | docker volume rm del volumen afectado |
| GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: converting to bool | Variable vacia | Agregar GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false |
| GOTRUE_SMTP_PORT: converting to int | SMTP_PORT vacio | Agregar SMTP_PORT=587 |
| auth.jwt() does not exist | GoTrue v2.189+ lo elimino | Ver TECH-DEBT-007 |
| Conflicto de nombres de contenedores | Stack manual y Dokploy corriendo en paralelo | docker compose down desde ~/supabase-project |
---
## Deuda tecnica
- TECH-DEBT-006: Backup remoto (S3) — bloqueante antes del primer dato real de empresa.
- TECH-DEBT-007: Migrar auth.jwt() a alternativa compatible con GoTrue v2.189+.
---
## Datos operativos de esta instalacion
- VPS: inq-mart-2 - IP VPN: 172.20.5.11
- Dokploy: https://172.20.5.11 (acceso via VPN)
- Repo Supabase: https://git.inquality.com.py/marcos/supabase-motor-fomularios
- Backups: /var/backups/supabase-prod/ (diarios, retencion 30 dias)
- Studio: ssh -L 3001:localhost:3001 administrator@172.20.5.11 -> http://localhost:3001

89
OBSERVATIONS.md Normal file
View File

@@ -0,0 +1,89 @@
# OBSERVATIONS.md — Observaciones y breaking changes
> Registro de cambios al instrumento que rompen compatibilidad con datos existentes
> o que requieren atención del director antes/después de re-seedear. No bloquean el
> trabajo del agente — se documentan para que el director decida qué hacer con los
> datos ya capturados.
---
## Etapa 3I2 — Revisión del instrumento v2.1 (feedback equipo médico)
### 1. Eliminación de "Prefiero no decir" (Cambio 1)
Afecta: A5, A6, 1.1, 1.5, 1.6, 1.13, 2.5, 3.1, 3.4.
Si existen respuestas guardadas con el valor `"Prefiero no decir"` en alguna de estas
preguntas, esos valores quedan **huérfanos**: ya no aparecen como opción en el
formulario ni en `seed.sql`, pero permanecen en `responses.answers` tal cual fueron
guardados. No se borran ni se migran automáticamente.
**Impacto en triggers (1.1, 1.6, 1.13):** estas preguntas ahora tienen solo 2 opciones
(Sí/No). La lógica condicional (incluyendo el nuevo salto-triple, Cambio 4a) solo
reacciona a `"Sí"`. Una respuesta histórica `"Prefiero no decir"` en un trigger se
trataría como "no Sí" a efectos de la lógica condicional si se re-evaluara, pero no
hay re-evaluación retroactiva de respuestas ya enviadas.
### 2. 2.1 cambia de `single_choice` a `multiple_choice` (Cambio 5)
Respuestas existentes para 2.1 están guardadas como un string único (`"Yo soy el/la
principal"`, etc.). El formulario nuevo espera un array de strings para preguntas
`multiple_choice`. Las respuestas históricas de 2.1 quedan en formato de string,
inconsistente con el nuevo esquema esperado para esa pregunta.
Ver `TECH_DEBT.md` → TECH-DEBT-006 (deuda de ranking pendiente sobre esta misma
pregunta).
### 3. Eliminación de la pregunta 2.4 (Cambio 6)
"¿Contás con apoyo de otras personas para el cuidado?" se elimina del seed por ser
similar/redundante con 2.1 (feedback del equipo médico).
El `DELETE FROM public.questions WHERE survey_id = ...` al inicio de `seed.sql` borra
la fila de la pregunta 2.4 (id `40000000-0000-0000-0000-000000000027`). Si existen
filas en `responses` cuyo `answers` referencian `question_id` = 2.4 (por
`question_id`, no por código embebido en JSON — depende del esquema de `responses`),
esas referencias quedan apuntando a un `question_id` que ya no existe en `questions`
tras el re-seed.
**No re-seedear la base de producción sin que el director confirme que no hay
respuestas reales que dependan de 2.4**, o decidir conservar la fila de 2.4 marcada
como `archived`/inactiva en lugar de borrarla (cambio de diseño — Nivel 3, no
ejecutado en esta etapa).
### 4. Cambio de texto en opción de 3.3 (Cambio 8)
"Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis ingresos".
Respuestas existentes que eligieron la opción anterior quedan guardadas con el texto
literal `"Es difícil sostenerlo con mis ingresos"`, que ya no coincide con ninguna
opción del seed actual. Cualquier agregación por valor exacto de opción debe
contemplar ambos strings como equivalentes para no perder esos datos en reportes.
### 5. División de la primera franja salarial en 3.4 (Cambio 9)
"Sueldo mínimo Gs. 5.000.000" se reemplaza por dos opciones nuevas: "Sueldo mínimo"
y "Gs. 3.000.000 a 5.000.000".
Respuestas existentes con el valor `"Sueldo mínimo Gs. 5.000.000"` quedan con ese
valor, que ya no existe como opción. No hay forma automática de reasignar esas
respuestas a una de las dos franjas nuevas (la franja exacta del respondente no se
puede inferir del valor agregado anterior).
### 6. Cambio de texto en última opción de 6.1 (Cambio 10)
"No, nunca se ha hablado del tema" → "No, no se puede hablar del tema".
Respuestas existentes con el valor anterior quedan con ese texto literal, que ya no
coincide con ninguna opción del seed actual.
---
## Resumen para el director
- Ningún cambio de esta etapa borra datos directamente, salvo el `DELETE` estándar de
`questions` por `survey_id` (mecanismo de idempotencia ya existente en `seed.sql`,
no nuevo de esta etapa) — pero ese `DELETE` ahora también elimina la fila de 2.4.
- Los cambios 1, 8, 9 y 10 dejan **valores de texto huérfanos** en respuestas
históricas (ya no matchean ninguna opción vigente). No afecta el funcionamiento del
formulario hacia adelante, pero sí cualquier reporte/agregación que dependa de
matchear el string exacto de la opción.
- El cambio 5 (2.1) deja respuestas históricas en un formato (string) distinto al que
el formulario nuevo produce (array). Si se construye un reporte sobre 2.1, debe
manejar ambos formatos o normalizarse en una migración de datos aparte.
- **No se ejecutó ningún re-seed sobre la base real** — esto aplica solo cuando el
director apruebe re-seedear con `seed.sql` v2.1.

View File

@@ -1,23 +1,31 @@
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v2 # SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v2.1
> Versión 2 — incorpora revisión de la colega (Jun 2026). Reemplaza la v1 (34 preguntas). > Versión 2.1 — incorpora feedback del equipo médico, Etapa 3I2 (Jun 2026). Reemplaza la v2.
> 44 preguntas · 8 secciones · Códigos únicos corregidos. > 44 preguntas (se elimina 2.4, se agrega 6.6) · 8 secciones · Códigos únicos corregidos.
> Claude Code reescribe `seed.sql` a partir de ESTE contrato. > Claude Code reescribe `seed.sql` a partir de ESTE contrato.
## Convenciones ## Convenciones
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→`rating`, - Tipos: radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→`rating`,
texto libre largo→`text_long`, texto corto→`text_short`. texto libre largo→`text_long`, texto corto→`text_short`.
- **`conditional_rules` en la pregunta DEPENDIENTE** (cada pregunta declara su propia condición). - **`conditional_rules` en la pregunta DEPENDIENTE** (cada pregunta declara su propia condición).
- Los 3 triggers (1.1, 1.6, 1.13) son `single_choice` de 3 opciones; solo **"Sí"** dispara el bloque. - Los 3 triggers (1.1, 1.6, 1.13) son `single_choice` de 2 opciones (Sí · No); solo **"Sí"** dispara
el bloque correspondiente.
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico (marcadas ✅). - `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico (marcadas ✅).
- `3.4` (salary) — `is_sensitive=true` + analyst-only (ADR-001 addendum): nunca en vistas org_admin. - `3.4` (salary) — `is_sensitive=true` + analyst-only (ADR-001 addendum): nunca en vistas org_admin.
- RE-INDEX: las preguntas marcadas son candidatas a índice compuesto (metodología TBD Sprint 3). - RE-INDEX: las preguntas marcadas son candidatas a índice compuesto (metodología TBD Sprint 3).
- En preguntas `multiple_choice` el formulario muestra el subtítulo "Podés marcar más de una opción".
- "Prefiero no decir" eliminada de todas las preguntas (Cambio 1, Etapa 3I2).
## Estructura condicional (3 bloques) ## Estructura condicional (4 bloques)
- **cond-disc-propia** — 1.1='Sí' → muestra 1.2, 1.3, 1.4, 1.5 - **cond-disc-propia** — 1.1='Sí' → muestra 1.2, 1.3, 1.4, 1.5
- **cond-familiar** — 1.6='Sí' → muestra 1.7, 1.8, 1.9, 1.10, 1.11, 1.12 - **cond-familiar** — 1.6='Sí' → muestra 1.7, 1.8, 1.9, 1.10, 1.11, 1.12
- **cond-adulto-mayor** — 1.13='Sí' → muestra 1.14, 1.15, 1.16 - **cond-adulto-mayor** — 1.13='Sí' → muestra 1.14, 1.15, 1.16
- Secciones A, 2, 3, 4, 5, 6, cierre: **top-level, siempre visibles.** - **salto-triple** — si 1.1='No' Y 1.6='No' Y 1.13='No' (es decir, ninguno de los tres dio
'Sí'), se ocultan completas las secciones 2, 3, 4, 5 y las preguntas 6.1, 6.2, 6.3. El
formulario salta directo a 6.4. Implementado como `conditional_rules`
`{"if_question":["1.1","1.6","1.13"],"op":"eq_any","value":"Sí","action":"show"}` en cada
pregunta del bloque (equivalente: se muestra si AL MENOS uno de los tres es 'Sí').
- Sección A, 6.4-6.6 y Sección 7 (cierre): **top-level, siempre visibles.**
--- ---
@@ -28,49 +36,68 @@
| A2 | single_choice | ¿En qué ciudad vivís?* | 219 ciudades (GeoSelectWidget: dependiente de A1) | qi_city — Sprint 3 | | A2 | single_choice | ¿En qué ciudad vivís?* | 219 ciudades (GeoSelectWidget: dependiente de A1) | qi_city — Sprint 3 |
| A3 | text_short | ¿En qué barrio vivís? | — (texto libre, opcional) | | | A3 | text_short | ¿En qué barrio vivís? | — (texto libre, opcional) | |
| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Directorio · Gerencia · Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector | | A4 | single_choice | ¿En qué área o gerencia trabajás?* | Directorio · Gerencia · Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector |
| A5 | single_choice | ¿Cuántos años tenés? | Menos de 25 años · 25 a 34 años · 35 a 44 años · 45 a 54 años · 55 años o más · Prefiero no decir | qi_age_bucket | | A5 | single_choice | ¿Cuántos años tenés? | Menos de 25 años · 25 a 34 años · 35 a 44 años · 45 a 54 años · 55 años o más | qi_age_bucket |
| A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir | | | A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | |
| A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | | | A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | |
## Sección 1 — Prevalencia: cuidado y discapacidad *(sensible)* ## Sección 1 — Prevalencia: cuidado y discapacidad *(sensible)*
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive | | Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1.1 | single_choice | trigger | ¿Vos mismo/a vivís con alguna discapacidad o condición de salud que afecte tu funcionamiento cotidiano?* | Sí · No · Prefiero no decir | ✅ | | 1.1 | single_choice | trigger | ¿Vos mismo/a vivís con alguna discapacidad o condición de salud que afecte tu funcionamiento cotidiano?* | Sí · No | ✅ |
| 1.2 | multiple_choice | 1.1=Sí | ¿Qué tipo de discapacidad o condición tenés? | Discapacidad física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica que genera limitaciones · Otra | ✅ | | 1.2 | multiple_choice | 1.1=Sí | ¿Qué tipo de discapacidad o condición tenés? | Discapacidad física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica que genera limitaciones · Otra | ✅ |
| 1.3 | multiple_choice | 1.1=Sí | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utilizo | ✅ | | 1.3 | multiple_choice | 1.1=Sí | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utilizo | ✅ |
| 1.4 | single_choice | 1.1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ | | 1.4 | single_choice | 1.1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
| 1.5 | single_choice | 1.1=Sí | ¿Tu condición afecta tu desempeño o comodidad en el trabajo? | Sí, de forma significativa · Sí, pero en aspectos puntuales · No, está bien gestionada · Prefiero no decir | ✅ | | 1.5 | single_choice | 1.1=Sí | ¿Tu condición afecta tu desempeño o comodidad en el trabajo? | Sí, de forma significativa · Sí, pero en aspectos puntuales · No, está bien gestionada | ✅ |
| 1.6 | single_choice | trigger | ¿Tenés algún familiar cercano (hijo/a, pareja, hermano/a, padre/madre) que viva con discapacidad?* | Sí · No · Prefiero no decir | ✅ | | 1.6 | single_choice | trigger | ¿Tenés algún familiar cercano (hijo/a, pareja, hermano/a, padre/madre) que viva con discapacidad?* | Sí · No | ✅ |
| 1.7 | multiple_choice | 1.6=Sí | ¿Cuál es tu vínculo con esa persona? | Hijo/a · Cónyuge/pareja · Padre o madre · Hermano/a · Otro familiar | ✅ | | 1.7 | multiple_choice | 1.6=Sí | ¿Cuál es tu vínculo con esa persona? | Hijo/a · Cónyuge/pareja · Padre o madre · Hermano/a · Otro familiar | ✅ |
| 1.8 | multiple_choice | 1.6=Sí | ¿Qué tipo de discapacidad tiene? | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica severa · Otra | ✅ | | 1.8 | multiple_choice | 1.6=Sí | ¿Qué tipo de discapacidad tiene? | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica severa · Otra | ✅ |
| 1.9 | multiple_choice | 1.6=Sí | ¿Qué tipo de apoyo o acompañamiento necesita principalmente? | Movilidad física · Comunicación · Apoyo escolar/educativo · Inclusión laboral · Terapias/rehabilitación · Apoyo emocional/psicosocial | ✅ | | 1.9 | multiple_choice | 1.6=Sí | ¿Qué tipo de apoyo o acompañamiento necesita principalmente? | Movilidad física · Comunicación · Apoyo escolar/educativo · Inclusión laboral · Terapias/rehabilitación · Apoyo emocional/psicosocial | ✅ |
| 1.10 | multiple_choice | 1.6=Sí | ¿Usa algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utiliza | ✅ | | 1.10 | multiple_choice | 1.6=Sí | ¿Usa algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utiliza | ✅ |
| 1.11 | single_choice | 1.6=Sí | Si es niño/a o adolescente en edad escolar: ¿está escolarizado/a y enfrenta barreras de aprendizaje? | Escolarizado/a, sin barreras importantes · Escolarizado/a, enfrenta barreras o faltan ajustes · No está escolarizado/a · No aplica | ✅ | | 1.11 | single_choice | 1.6=Sí | Si es niño/a o adolescente en edad escolar: ¿está escolarizado/a y enfrenta barreras de aprendizaje? | Escolarizado/a, sin barreras importantes · Escolarizado/a, enfrenta barreras o faltan ajustes · No está escolarizado/a · No aplica | ✅ |
| 1.12 | single_choice | 1.6=Sí | ¿La persona con discapacidad se encuentra trabajando actualmente? | Sí, está trabajando · No, y está buscando empleo · No, y no está buscando · No aplica | ✅ | | 1.12 | single_choice | 1.6=Sí | ¿La persona con discapacidad se encuentra trabajando actualmente? | Sí, está trabajando · No, y está buscando empleo · No, y no está buscando · No aplica | ✅ |
| 1.13 | single_choice | trigger | ¿Tenés algún adulto mayor (60+ años) a tu cuidado o bajo tu responsabilidad?* | Sí · No · Prefiero no decir | | | 1.13 | single_choice | trigger | ¿Tenés algún adulto mayor (60+ años) a tu cuidado o bajo tu responsabilidad?* | Sí · No | |
| 1.14 | single_choice | 1.13=Sí | ¿Qué nivel de dependencia tiene? (la más representativa) | Independiente, pero necesita supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ | | 1.14 | single_choice | 1.13=Sí | ¿Qué nivel de dependencia tiene? (la más representativa) | Independiente, pero necesita supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ |
| 1.15 | multiple_choice | 1.13=Sí | ¿Tiene una condición de salud que genere mayor necesidad de cuidado? | Demencia/Alzheimer · ACV/Parálisis · Enfermedad crónica grave · Movilidad reducida · Deterioro cognitivo · Sin condición específica | ✅ | | 1.15 | multiple_choice | 1.13=Sí | ¿Tiene una condición de salud que genere mayor necesidad de cuidado? | Demencia/Alzheimer · ACV/Parálisis · Enfermedad crónica grave · Movilidad reducida · Deterioro cognitivo · Sin condición específica | ✅ |
| 1.16 | single_choice | 1.13=Sí | ¿Vive con vos o en otra vivienda? | Vive en mi hogar · Vive en otra vivienda · Está en residencia/institución | | | 1.16 | single_choice | 1.13=Sí | ¿Vive con vos o en otra vivienda? | Vive en mi hogar · Vive en otra vivienda · Está en residencia/institución | |
## Sección 2 — Intensidad y rol del cuidado (top-level) ## Sección 2 — Intensidad y rol del cuidado (top-level)
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| 2.1 | single_choice | ¿Quién es el/la responsable principal del cuidado? | Yo soy el/la principal · Comparto con otro familiar · Hay cuidador/a pagado/a · Está en institución/centro de día | | 2.1 | multiple_choice ⚠️ TECH-DEBT | ¿Quién es el/la responsable principal del cuidado? | Yo soy el/la principal · Comparto con otro familiar · Hay cuidador/a pagado/a · Está en institución/centro de día |
| 2.2 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · Entre 1 y 3h · Entre 3 y 6h · Más de 6h · Varía mucho | | 2.2 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · Entre 1 y 3h · Entre 3 y 6h · Más de 6h · Varía mucho |
| 2.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Durante horario laboral (llamadas/emergencias) · Al llegar del trabajo · Durante la noche · Los fines de semana · Es continuo sin horario fijo | | 2.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Durante horario laboral (llamadas/emergencias) · Al llegar del trabajo · Durante la noche · Los fines de semana · Es continuo sin horario fijo |
| 2.4 | single_choice | ¿Contás con apoyo de otras personas para el cuidado? | Sí, apoyo familiar suficiente · Algo de apoyo, no suficiente · No, soy el/la único/a responsable · Tengo apoyo contratado | | 2.5 | single_choice | Como cuidador/a primario, ¿presentás signos de agotamiento o estrés crónico? | Sí, frecuentemente · A veces · No, lo llevo bien |
| 2.5 | single_choice | Como cuidador/a primario, ¿presentás signos de agotamiento o estrés crónico? | Sí, frecuentemente · A veces · No, lo llevo bien · Prefiero no decir |
> ⚠️ TECH-DEBT (2.1): pasó de `single_choice` a `multiple_choice` — selección múltiple sin orden
> de prioridad. Deuda: implementar ranking (Principal cuidador soy yo > secundario instituto >
> principal pagado > secundario familiar). Ver `TECH_DEBT.md`.
>
> **2.4 eliminada** (Cambio 6, Etapa 3I2): "¿Contás con apoyo de otras personas para el cuidado?"
> — feedback del equipo médico: pregunta similar/redundante con 2.1. Ver `OBSERVATIONS.md`.
## Sección 3 — Impacto económico del cuidado (top-level) ## Sección 3 — Impacto económico del cuidado (top-level)
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
> 3.4: is_sensitive=true + analyst-only (no exponer a org_admin).
| Code | Tipo | Pregunta | Opciones | Notas | | Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---| |---|---|---|---|---|
| 3.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | Sin gastos significativos · Menos de Gs. 500.000 · Gs. 500.0001.500.000 · Gs. 1.500.0003.000.000 · Gs. 3.000.0006.000.000 · Más de Gs. 6.000.000 · Prefiero no decir | | | 3.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | Menos de Gs. 500.000 · Gs. 500.0001.500.000 · Gs. 1.500.0003.000.000 · Gs. 3.000.0006.000.000 · Más de Gs. 6.000.000 | |
| 3.2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? | Terapias especializadas · Cuidador/a particular · Centro de día/institución · Transporte adaptado · Medicación alto costo · Equipamiento médico/tecnología asistiva · No utilizo servicios formales | | | 3.2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? | Terapias especializadas · Cuidador/a particular · Centro de día/institución · Transporte adaptado · Medicación alto costo · Equipamiento médico/tecnología asistiva · No utilizo servicios formales | |
| 3.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Con mi empleo de tiempo completo · Con mi empleo de tiempo parcial · Con trabajo independiente/emprendimiento · Me ayudan mis familiares · Es difícil sostenerlo con mis ingresos | | | 3.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Con mi empleo de tiempo completo · Con mi empleo de tiempo parcial · Con trabajo independiente/emprendimiento · Me ayudan mis familiares · No puedo sostenerlo con mis ingresos | |
| 3.4 | single_choice | ¿En qué franja salarial mensual te ubicás? *(CONFIDENCIAL — analyst-only)* | Sueldo mínimo Gs. 5.000.000 · Gs. 5.000.0018.000.000 · Gs. 8.000.00112.000.000 · Gs. 12.000.00115.000.000 · Gs. 15.000.00118.000.000 · Gs. 18.000.00125.000.000 · Gs. 25.000.00150.000.000 · Gs. 50.000.001 o más · Prefiero no decir | is_sensitive=true; never in org_admin views | | 3.4 | single_choice | ¿En qué franja salarial mensual te ubicás? *(CONFIDENCIAL — analyst-only)* | Sueldo mínimo · Gs. 3.000.000 a 5.000.000 · Gs. 5.000.0018.000.000 · Gs. 8.000.00112.000.000 · Gs. 12.000.00115.000.000 · Gs. 15.000.00118.000.000 · Gs. 18.000.00125.000.000 · Gs. 25.000.00150.000.000 · Gs. 50.000.001 o más | is_sensitive=true; never in org_admin views |
| 3.5 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | 1=Sin impacto · 2=Leve · 3=Moderado · 4=Alto · 5=Muy alto | RE-INDEX | | 3.5 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | 1=Sin impacto · 2=Leve · 3=Moderado · 4=Alto · 5=Muy alto | RE-INDEX |
> **Cambio 7** (3.1): se elimina "Sin gastos significativos" además de "Prefiero no decir".
> **Cambio 8** (3.3): "Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis
> ingresos". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
> **Cambio 9** (3.4): "Sueldo mínimo Gs. 5.000.000" se divide en "Sueldo mínimo" y
> "Gs. 3.000.000 a 5.000.000". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
## Sección 4 — Tiempo y descanso (top-level) ## Sección 4 — Tiempo y descanso (top-level)
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| 4.1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien, me siento recuperado/a · Descanso de forma irregular, pero lo manejo · Me cuesta descansar, me siento cansado/a con frecuencia · Descanso muy poco, el agotamiento afecta mi día a día | RE-INDEX | | 4.1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien, me siento recuperado/a · Descanso de forma irregular, pero lo manejo · Me cuesta descansar, me siento cansado/a con frecuencia · Descanso muy poco, el agotamiento afecta mi día a día | RE-INDEX |
@@ -78,19 +105,25 @@
| 4.3 | rating | En una escala del 1 al 5, ¿cómo evaluás tu bienestar general en este momento? | 1=Muy mal · 2=Mal · 3=Regular · 4=Bien · 5=Muy bien | RE-INDEX | | 4.3 | rating | En una escala del 1 al 5, ¿cómo evaluás tu bienestar general en este momento? | 1=Muy mal · 2=Mal · 3=Regular · 4=Bien · 5=Muy bien | RE-INDEX |
## Sección 5 — Impacto en el trabajo (top-level) ## Sección 5 — Impacto en el trabajo (top-level)
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| 5.1 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca o casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días | RE-INDEX | | 5.1 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca o casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días | RE-INDEX |
| 5.2 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (13 veces al año) · Varias veces (410 veces al año) · Con frecuencia (más de 10 veces al año) | | | 5.2 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (13 veces al año) · Varias veces (410 veces al año) · Con frecuencia (más de 10 veces al año) | |
## Sección 6 — Conciencia organizacional (top-level) ## Sección 6 — Conciencia organizacional (top-level)
> Cond. (6.1-6.3): **salto-triple** — se ocultan si 1.1='No' Y 1.6='No' Y 1.13='No'.
> 6.4-6.6: siempre visibles (destino del salto).
| Code | Tipo | Pregunta | Opciones | Notas | | Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---| |---|---|---|---|---|
| 6.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente?* | Sí, me siento acompañado/a · Parcialmente, hay cierta comprensión · No, es algo bastante invisible · No, nunca se ha hablado del tema | RE-INDEX | | 6.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente?* | Sí, me siento acompañado/a · Parcialmente, hay cierta comprensión · No, es algo bastante invisible · No, no se puede hablar del tema | RE-INDEX. Cambio 10: última opción reescrita (era "No, nunca se ha hablado del tema"). Breaking change — ver `OBSERVATIONS.md`. |
| 6.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? Elegí hasta 3 | Flexibilidad horaria o trabajo remoto · Apoyo económico o convenios/descuentos · Acceso a terapias o salud · Contención/apoyo emocional o psicológico · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos y servicios disponibles | ⚠️ Ranking → multiple_choice para v1. Ranking widget al backlog. | | 6.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? Elegí hasta 3 | Flexibilidad horaria o trabajo remoto · Apoyo económico o convenios/descuentos · Acceso a terapias o salud · Contención/apoyo emocional o psicológico · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos y servicios disponibles | ⚠️ Ranking → multiple_choice para v1. Ranking widget al backlog. |
| 6.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez, si hay más personas en la misma situación · No por ahora · Prefiero mantenerlo en privado | | | 6.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez, si hay más personas en la misma situación · No por ahora · Prefiero mantenerlo en privado | |
| 6.4 | single_choice | ¿Conocés a algún compañero/a de trabajo que esté al cuidado de una persona con discapacidad o dependiente? | Sí, conozco a varias personas · Sí, conozco a una persona · No conozco a nadie · No estoy seguro/a | Código corregido (era 6.2 duplicado) | | 6.4 | single_choice | ¿Conocés a algún compañero/a de trabajo que esté al cuidado de una persona con discapacidad o dependiente? | Sí, conozco a varias personas · Sí, conozco a una persona · No conozco a nadie · No estoy seguro/a | Código corregido (era 6.2 duplicado) |
| 6.5 | single_choice | ¿Considerás que la empresa debería ofrecer apoyos a quienes cuidan a una persona dependiente? | Sí, definitivamente · Tal vez, dependería del caso · No lo considero necesario · No lo sé | Código corregido (era 6.3 duplicado) | | 6.5 | single_choice | ¿Considerás que la empresa debería ofrecer apoyos a quienes cuidan a una persona dependiente? | Sí, definitivamente · Tal vez, dependería del caso · No lo considero necesario · No lo sé | Código corregido (era 6.3 duplicado) |
| 6.6 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo a personas con discapacidad y/o cuidadores? | Sí · No · Tal vez | Nueva (Cambio 4b, Etapa 3I2) |
## Sección 7 — Para cerrar (top-level) ## Sección 7 — Para cerrar (top-level)
| Code | Tipo | Pregunta | Notas | | Code | Tipo | Pregunta | Notas |

View File

@@ -58,3 +58,30 @@
- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert - **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert
y extienda la whitelist de `agg_segment`. y extienda la whitelist de `agg_segment`.
- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad). - **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad).
---
## [TECH-DEBT-006] Ranking widget para 2.1
- **Estado:** Etapa 3I2 (Cambio 5) cambió 2.1 ("¿Quién es el/la responsable principal
del cuidado?") de `single_choice` a `multiple_choice`. Permite selección múltiple
pero sin orden de prioridad — pierde la noción de "responsable principal" cuando
el respondente marca más de una opción.
- **Deuda:** implementar widget de ranking para 2.1 con el orden:
Principal cuidador soy yo > secundario instituto > principal pagado > secundario
familiar (mismo patrón que TECH-DEBT-001 para 6.2).
- **Trigger:** decisión del director sobre el tipo de 2.1 en próxima versión del
instrumento.
---
## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** ABIERTO.
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
implementar un gate de autenticación para staff (Basic Auth con env var como
mínimo viable).
- **Impacto actual:** ninguno en producción — la URL no está publicada.
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.

View File

@@ -0,0 +1,228 @@
import { createClient } from '@supabase/supabase-js'
import { ResponsesTable, type DisplayRow } from './responses-table'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500
const PROMPT_MAX_LEN = 60
const SURVEY_OPTIONS = [
{ label: 'Todos', id: null },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] as const
interface AllowedQuestion {
id: string
code: string
prompt: string
type: string
position: number
}
interface AnswerRow {
value: unknown
question_id: string
}
interface ResponseRow {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
answers: AnswerRow[]
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
}
function formatDate(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text
}
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
function renderValue(value: unknown, type: string): string | null {
if (value === null || value === undefined) return null
switch (type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
}
export default async function AdminResponsesPage({
searchParams,
}: {
searchParams: Promise<{ survey_id?: string }>
}) {
const { survey_id: surveyId } = await searchParams
const supabase = getSupabaseAdmin()
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true })
if (questionsError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{questionsError.message}</pre>
</div>
)
}
const allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
const allowedIds = allowedQuestions.map((q) => q.id)
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro).
let responsesQuery = supabase
.from('responses')
.select(
`
id,
submitted_at,
qi_zone,
qi_sector,
answers!inner ( value, question_id )
`
)
.in('answers.question_id', allowedIds)
if (surveyId) {
responsesQuery = responsesQuery.eq('survey_id', surveyId)
}
const { data: responsesData, error: responsesError } =
allowedIds.length > 0
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
: { data: [] as ResponseRow[], error: null }
if (responsesError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{responsesError.message}</pre>
</div>
)
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
// 3. Contadores totales (independientes del límite de 500 submissions),
// filtrados por survey_id cuando hay filtro activo.
let submissionsCountQuery = supabase
.from('responses')
.select('*', { count: 'exact', head: true })
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
const { count: totalSubmissions } = await submissionsCountQuery
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
const { count: totalAnswers } =
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = []
responses.forEach((response, idx) => {
const answers = response.answers
.filter((a) => questionMap.has(a.question_id))
.sort(
(a, b) =>
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
)
answers.forEach((a, ai) => {
const q = questionMap.get(a.question_id)!
const isFirstInGroup = ai === 0
rows.push({
key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1,
isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code,
prompt: truncate(q.prompt),
rendered: renderValue(a.value, q.type),
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
})
})
})
return (
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
actualizado {formatDate(new Date().toISOString())}
</p>
<nav className="admin-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.label}
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
className={
opt.id === (surveyId ?? null)
? 'admin-survey-link admin-survey-link-active'
: 'admin-survey-link'
}
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header>
<div className="admin-table-wrapper">
<ResponsesTable rows={rows} />
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
export interface DisplayRow {
key: string
submissionNum: number
isFirstInGroup: boolean
date: string
code: string
prompt: string
rendered: string | null
zone: string
sector: string
}
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(submissionNum)) next.delete(submissionNum)
else next.add(submissionNum)
return next
})
}
return (
<table className="admin-table">
<thead>
<tr>
<th>#</th>
<th>Fecha</th>
<th>Código</th>
<th>Pregunta</th>
<th>Valor</th>
<th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th>
</tr>
</thead>
<tbody>
{rows
.filter((row) => row.isFirstInGroup || expanded.has(row.submissionNum))
.map((row) => {
const groupClass =
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
const isExpanded = expanded.has(row.submissionNum)
const trClass = row.isFirstInGroup
? `${groupClass} admin-group-start admin-row-toggle`
: groupClass
return (
<tr
key={row.key}
className={trClass}
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
onKeyDown={
row.isFirstInGroup
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(row.submissionNum)
}
}
: undefined
}
role={row.isFirstInGroup ? 'button' : undefined}
tabIndex={row.isFirstInGroup ? 0 : undefined}
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
>
<td>
{row.isFirstInGroup && (
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
)}
{row.submissionNum}
</td>
<td>{row.date}</td>
<td>{row.code}</td>
<td className="admin-prompt">{row.prompt}</td>
<td>
{row.rendered === null ? (
<span className="admin-nodata">sin dato</span>
) : (
row.rendered
)}
</td>
<td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</td>
</tr>
)
})}
</tbody>
</table>
)
}

View File

@@ -14,6 +14,14 @@ import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import type { DashboardFilters } from '@/lib/dashboard-types' import type { DashboardFilters } from '@/lib/dashboard-types'
// El survey_id se pasa como query param: /dashboard?survey_id=UUID // El survey_id se pasa como query param: /dashboard?survey_id=UUID
// '00000000-0000-0000-0000-000000000000' = flag p_all (vista agregada de todos los surveys)
const SURVEY_OPTIONS = [
{ label: 'Todos', id: '00000000-0000-0000-0000-000000000000' },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
]
export default function DashboardPage() { export default function DashboardPage() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001' const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
@@ -40,6 +48,22 @@ export default function DashboardPage() {
Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026 Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>} {filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
</p> </p>
<nav className="db-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.id}
href={`/dashboard?survey_id=${opt.id}`}
className={
opt.id === surveyId
? 'db-survey-link db-survey-link-active'
: 'db-survey-link'
}
aria-current={opt.id === surveyId ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
</header> </header>
{/* Fila 1: KPIs (span 12) */} {/* Fila 1: KPIs (span 12) */}

View File

@@ -559,6 +559,14 @@ body {
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; } .db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; }
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; } .db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; }
.db-active-filter { color: var(--color-re-teal); font-weight: 600; } .db-active-filter { color: var(--color-re-teal); font-weight: 600; }
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.db-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid rgba(67,187,200,0.3); color: #9ca3af; text-decoration: none;
transition: all 0.2s ease;
}
.db-survey-link:hover { color: #E7E6E5; border-color: var(--color-re-teal); }
.db-survey-link-active { background: var(--color-re-teal); color: #0F1923; border-color: var(--color-re-teal); }
.db-section { margin-bottom: 16px; } .db-section { margin-bottom: 16px; }
.db-grid { .db-grid {
display: grid; display: grid;
@@ -742,11 +750,67 @@ body {
} }
.db-filter-reset:hover { background: rgba(67,187,200,0.2); } .db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
transition: all 0.2s ease;
}
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap;
}
.admin-refresh:hover { background: var(--color-primary-hover); }
.admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card);
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.admin-table th {
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
}
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); vertical-align: top; }
.admin-table tbody tr:last-child td { border-bottom: none; }
.admin-table tbody tr.admin-group-even { background: var(--color-bg); }
.admin-table tbody tr.admin-group-start td { border-top: 2px solid var(--color-re-dark); }
.admin-table tbody tr:hover { background: var(--color-primary-light); }
.admin-prompt { color: var(--color-text); }
.admin-row-toggle { cursor: pointer; }
.admin-toggle-icon {
display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem;
}
.admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark);
opacity: 0.6; font-size: 0.7rem; font-style: italic;
}
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
.admin-error { color: var(--color-error); font-weight: 600; }
.admin-error-detail {
background: var(--color-error-bg); border-radius: var(--radius);
padding: 12px; font-family: 'Courier New', monospace; font-size: 0.75rem;
overflow-x: auto;
}
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.db-kpi-row { grid-template-columns: repeat(2,1fr); } .db-kpi-row { grid-template-columns: repeat(2,1fr); }
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); } .db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-page { flex-direction: column; } .db-page { flex-direction: column; }
.admin-col-optional { display: none; }
} }
/* ── Reduced motion ── */ /* ── Reduced motion ── */

View File

@@ -26,15 +26,17 @@ export function ConsentScreen({ brandConfig, onAccept }: ConsentScreenProps) {
</p> </p>
<ol className="consent-list"> <ol className="consent-list">
<li> <li>
<strong>Diagnóstico interno de {orgName}:</strong> tus respuestas ayudan a la empresa <strong>Diagnóstico interno de {orgName}:</strong> Tus respuestas ayudan a la empresa,
a entender la realidad de las personas que cuidan a familiares con discapacidad o adultos en alianza con Fundación Solidaridad, a entender la realidad de las personas que cuidan
mayores, para diseñar apoyos más adecuados. Solo se trabaja con{' '} a familiares con discapacidad o adultos mayores, para diseñar apoyos más adecuados. Solo
se trabaja con{' '}
<strong>datos agregados</strong> nunca accede a respuestas individuales. <strong>datos agregados</strong> nunca accede a respuestas individuales.
</li> </li>
<li> <li>
<strong>Estudio país (opcional):</strong> tus respuestas anonimizadas contribuyen a un <strong>Estudio país (opcional):</strong> Tus respuestas serán{' '}
mapeo nacional sobre la economía del cuidado en Paraguay. Los datos se procesan de <strong>anónimas</strong> y contribuyen a un mapeo nacional sobre la economía del
forma irreversiblemente desvinculada de cualquier empresa o persona. cuidado en Paraguay. Los datos se procesan de manera que no pueden asociarse a ninguna
empresa ni persona.
</li> </li>
</ol> </ol>
</section> </section>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState, useTransition } from 'react' import { useCallback, useEffect, useRef, useState, useTransition } from 'react'
import type { FormAnswers, Question, Survey } from '@/lib/types' import type { FormAnswers, Question, Survey } from '@/lib/types'
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage' import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
import { getHiddenCodes, isQuestionVisible } from '@/lib/conditional' import { getHiddenCodes, isQuestionVisible, ruleQuestionCodes } from '@/lib/conditional'
import { submitSurvey } from '@/app/actions/submit-survey' import { submitSurvey } from '@/app/actions/submit-survey'
import { ConsentScreen } from './consent-screen' import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar' import { ProgressBar } from './progress-bar'
@@ -71,7 +71,7 @@ export function SurveyForm({ survey, questions }: SurveyFormProps) {
} else { } else {
const appeared = questions.some( const appeared = questions.some(
(q) => (q) =>
q.conditional_rules?.some((r) => r.if_question === code) && q.conditional_rules?.some((r) => ruleQuestionCodes(r).includes(code)) &&
isQuestionVisible(q, updated) && isQuestionVisible(q, updated) &&
!isQuestionVisible(q, prev) !isQuestionVisible(q, prev)
) )

View File

@@ -40,11 +40,13 @@ export function CheckboxGroup({
{required && <span className="required-mark" aria-hidden="true"> *</span>} {required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>} {required && <span className="sr-only"> (obligatorio)</span>}
</legend> </legend>
{maxSelect !== null && ( {maxSelect !== null ? (
<p className="max-select-hint" aria-live="polite"> <p className="max-select-hint" aria-live="polite">
Elegí hasta {maxSelect} opciones Elegí hasta {maxSelect} opciones
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`} {value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
</p> </p>
) : (
<p className="max-select-hint">Podés marcar más de una opción</p>
)} )}
<div className="options-list" role="list"> <div className="options-list" role="list">
{options.map((opt) => { {options.map((opt) => {

View File

@@ -0,0 +1,155 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 11 junio 2026
> Director: Marcos · Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
---
## 1. URLs operativas
| Recurso | URL |
|---|---|
| Formulario Demo | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001 |
| Formulario InQuality | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000002 |
| Formulario Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000003 |
| Dashboard — Todos | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=00000000-0000-0000-0000-000000000000 |
| Dashboard — InQuality | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000002 |
| Dashboard — Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000003 |
| Dashboard — Demo | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000001 |
| Control de respuestas | https://economia-cuidado.inqualityhq.com/admin/responses |
---
## 2. Infraestructura — OPERATIVA ✅
- **App:** Next.js 15.3.8 (CVE-2025-66478 resuelto ✅)
- **DB:** Supabase self-hosted en Dokploy v0.29.4
- **VPS:** inq-mart-2, IP VPN: 172.20.5.11
- **Repos:**
- App: `git.inquality.com.py/marcos/motor-de-encuestas` (rama main)
- Supabase stack: `git.inquality.com.py/marcos/supabase-motor-fomularios` (rama master)
- **API Supabase:** https://db.inqualityhq.com (Kong + SSL)
- **Backups:** `/var/backups/supabase-prod/`, cron 3AM, GFS (3 días diarios + domingos 14 días)
- **Studio:** solo vía SSH tunnel → `ssh -L 3001:localhost:3001 administrator@172.20.5.11`
---
## 3. Instrumento — v2.1 ACTIVO
- **44 preguntas**, 8 secciones
- **Salto triple:** 1.1=No AND 1.6=No AND 1.13=No → salta a 6.4 (validado con datos reales ✅)
- **Nueva pregunta 6.6:** voluntariado en redes de apoyo (Sí/No/Tal vez)
- **2.4 eliminada**, **2.1 → multiple_choice**
- "Prefiero no decir" eliminado de todas las preguntas
- Hint "Podés marcar más de una opción" en todas las multiple_choice
- "Tecnología asistiva" en 1.3 y 1.10
---
## 4. DB — estado actual
| Tabla | Filas |
|---|---|
| organizations | 3 (Empresa Demo, InQuality, Fundación Solidaridad) |
| surveys | 3 (una por organización) |
| questions | 132 (44 × 3 surveys) |
| responses | 15 (8 InQuality + 7 Demo — todas de prueba) |
| answers | ~500 (estimado) |
---
## 5. Dashboard — multitenante operativo ✅
- Selector de empresa en el header: Todos / InQuality / Fundación Solidaridad / Demo
- "Todos" usa UUID especial `00000000-0000-0000-0000-000000000000` → pool agregado
- Implementado via flag en `_dash_responses` (migración `20260611000001`)
- Validado visualmente con n=15: KPIs, heatmap departamental, perfil población ✅
---
## 6. Vista de control /admin/responses
- Tabla plana: una fila por answer, agrupada por submission
- Colapsar/expandir por submission (Client Component island)
- Excluye: `text_long`, `is_sensitive=true` (TECH-DEBT-008 — sin auth)
- Sin columna "Tipo"
---
## 7. Método — Sprint 2 cerrado ✅
- `method/PATTERNS.md` y `method/ANTI_PATTERNS.md` creados en el repo
- C.8 promovido a estable con casos reales del proyecto
- AP.10 y AP.11 documentados con casos reales
---
## 8. Deuda técnica abierta
| ID | Descripción | Bloqueante |
|---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | SÍ — antes del primer dato real |
| TECH-DEBT-007 | auth.jwt() deprecado en GoTrue v2.189+ | No (piloto) |
| TECH-DEBT-008 | /admin/responses sin auth | No (URL no publicada) |
| TECH-DEBT-009 | Rotar secrets Supabase post-CVE-2025-66478 | SÍ — antes del primer dato real |
---
## 9. Pendientes próxima sesión
1. TECH-DEBT-006 — backup remoto S3
2. TECH-DEBT-009 — rotar secrets Supabase
3. RE Index — confirmar mapeo G1/G2/G4/G5 → códigos seed con Marcos
4. Informe descargable — prometido en deck comercial
5. Onboarding empresas reales — cuando validación esté lista
---
## 10. Decisiones tomadas — no reabrir
1. Supabase en Dokploy siempre via modo Git
2. POSTGRES_PASSWORD siempre con `openssl rand -hex`
3. kong.yml: una sola entrada por bloque keyauth_credentials
4. SMTP_PORT=587 siempre explícito aunque SMTP esté desactivado
5. GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito
6. LUA_AUTH_EXPR con prefijo "Bearer " en kong-entrypoint.sh
7. NEXT_PUBLIC_SUPABASE_URL via Build Argument en Dokploy
8. App conecta a https://db.inqualityhq.com
9. /admin/responses no muestra is_sensitive=true sin auth (TECH-DEBT-008)
10. brand_config.org_name alimenta el nombre de empresa en el formulario
11. Organizaciones separadas para cada encuesta
12. conditional_rules referencian códigos de pregunta, no UUIDs
13. UUID cero `00000000-...` es el flag p_all del dashboard
14. Next.js pinneado a versión exacta `15.3.8` (sin caret)
---
## 11. Contexto comercial
- 2 empresas comprometidas para piloto (InQuality y Fundación Solidaridad en espacio de pruebas)
- Cronograma: JUN inicio · JUL-AGO relevamiento · SEP análisis · OCT informe + evento
- Meta: 20 empresas pioneras
- Precios: Gs. 4.5M (≤50) · 7.5M (≤200) · 12M (≤400)
- GAPs deck vs producto: RE Index no implementado, informe descargable no existe
- Contacto Fundación: Valeria Arce — ddff@fundacionsolidaridad.org.py · +595 991 854333
---
## 12. Prompt para nueva sesión
Pegá al inicio de la nueva conversación:
---
Sos el copiloto técnico del proyecto "Datos País sobre el Cuidado" — plataforma soberana
de encuestas sobre economía del cuidado en Paraguay. Tenemos múltiples sesiones de trabajo.
Stack: Next.js 15.3.8 + Supabase self-hosted + Dokploy v0.29.4.
URL: https://economia-cuidado.inqualityhq.com
Repos en Gitea: git.inquality.com.py/marcos/
El estado completo está en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` en el repo.
Los prompts de etapas pasadas están en `sprints/`.
Las reglas no negociables están en `CLAUDE.md`.
Próximos pasos: [COMPLETAR CON LO QUE VAS A HACER]

View File

@@ -11,9 +11,17 @@ export function isQuestionVisible(
return rules.every((rule) => evaluateRule(rule, answers)) return rules.every((rule) => evaluateRule(rule, answers))
} }
/** Normalizes if_question to an array of codes regardless of rule shape. */
export function ruleQuestionCodes(rule: ConditionalRule): string[] {
return Array.isArray(rule.if_question) ? rule.if_question : [rule.if_question]
}
function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean { function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
if (rule.op === 'eq') { if (rule.op === 'eq') {
return answers[rule.if_question] === rule.value return answers[rule.if_question as string] === rule.value
}
if (rule.op === 'eq_any') {
return ruleQuestionCodes(rule).some((code) => answers[code] === rule.value)
} }
return false return false
} }
@@ -32,8 +40,8 @@ export function getHiddenCodes(
return questions return questions
.filter((q) => { .filter((q) => {
if (!q.conditional_rules?.length) return false if (!q.conditional_rules?.length) return false
const dependsOnTrigger = q.conditional_rules.some( const dependsOnTrigger = q.conditional_rules.some((r) =>
(r) => r.if_question === triggerCode ruleQuestionCodes(r).includes(triggerCode)
) )
if (!dependsOnTrigger) return false if (!dependsOnTrigger) return false
return !isQuestionVisible(q, projected) return !isQuestionVisible(q, projected)
@@ -46,7 +54,7 @@ export function getTriggerCodes(questions: Question[]): Set<string> {
const triggers = new Set<string>() const triggers = new Set<string>()
for (const q of questions) { for (const q of questions) {
for (const rule of q.conditional_rules ?? []) { for (const rule of q.conditional_rules ?? []) {
triggers.add(rule.if_question) for (const code of ruleQuestionCodes(rule)) triggers.add(code)
} }
} }
return triggers return triggers

View File

@@ -19,8 +19,8 @@ export interface RatingOptions {
} }
export interface ConditionalRule { export interface ConditionalRule {
if_question: string if_question: string | string[]
op: 'eq' op: 'eq' | 'eq_any'
value: string value: string
action: 'show' action: 'show'
} }

246
method/ANTI_PATTERNS.md Normal file
View File

@@ -0,0 +1,246 @@
# Anti-patrones
> Comportamientos que producen daño concreto y deben evitarse. Cada uno está documentado con el caso real donde apareció.
>
> A diferencia de los patrones (que son propuestos hasta validación), los anti-patrones se documentan estables porque el daño ya está observado.
---
## AP.1 — Tests verdes como aprobación final
**Comportamiento:** declarar una etapa terminada porque los tests pasaron, sin abrir el artefacto final con los ojos.
**Daño concreto:** llegás a producción con outputs visuales rotos. Caso real (InQ ROI MVP): los tests E2E verificaban que el PDF se generaba y tenía las páginas esperadas, pero nadie abrió el PDF. El heatmap embebido estaba en blanco por un bug de timing en html2canvas. Se descubrió 2 días después cuando un consultor lo abrió.
**Cómo detectarlo:** el reporte del agente dice "tests verdes, build limpio, PDF generado en X path" pero no incluye observaciones visuales del PDF.
**Cómo evitarlo:** aplicar **Principio 2** (validación humana es ojo) y **Patrón C.1** (validación visual mandatoria).
---
## AP.2 — Pasamanos invertido (humano como operador)
**Comportamiento:** el humano traduce manualmente entre dos agentes o entre agente y código. Copia outputs, formatea, transporta contexto entre sesiones.
**Daño concreto:** el humano se vuelve cuello de botella del sistema. Lo peor: deja de pensar en arquitectura/producto y se concentra en operación. Caso real (InQ ROI Sprint 1.5, Etapa 5): el director del proyecto sintió "soy pasamanos" después de varios checkpoints transportando reportes de Claude Code al arquitecto.
**Cómo detectarlo:** preguntate si lo que estás haciendo lo podría hacer un script o un prompt mejor estructurado. Si la respuesta es sí, estás en este anti-patrón.
**Cómo evitarlo:** aplicar **Principio 5** (humano director). Mover lo operativo a archivos (prompts pre-armados, templates) y reservar el chat para decisiones reales.
---
## AP.3 — Racionalización de output anómalo
**Comportamiento:** cuando un output del agente tiene una métrica anómala (PDF inusualmente liviano, número muy redondo, payback "0.0 meses"), el agente o el humano racionalizan ("numéricamente correcto", "es el caso esperado") en lugar de investigar la causa raíz.
**Daño concreto:** bugs latentes que se descubren en producción. Caso real (InQ ROI Sprint 1.5, Etapa 4): el PDF de ROI pesaba 36 KB con 4 páginas (9 KB/página), comparado con 78 KB/3 páginas (26 KB/página) de los otros PDFs. El agente reportó "PDF generado". El humano paró y pidió investigar — había secciones que no se renderizaban.
**Cómo detectarlo:** cualquier número que parezca "anómalo pero técnicamente válido". Especialmente: tamaños de archivo, payback inmediato, ROI extremo, métricas con muchos ceros.
**Cómo evitarlo:** aplicar **Principio 3** (auditoría adversarial) y **Patrón E.3** (anti-racionalización explícita).
---
## AP.4 — Decisión recurrente no registrada
**Comportamiento:** el agente vuelve a preguntar lo mismo en cada sesión nueva, o miembros del equipo asumen distinto porque la decisión no está documentada.
**Daño concreto:** outputs inconsistentes entre etapas, tiempo perdido re-debatiendo, deriva del producto. Caso real (InQ ROI Sprint 1.5, varias etapas): cada vez que Claude Code iba a aplicar formato de cifras, había que recordarle si usaba separador europeo o americano. Hasta que se documentó en `CLAUDE.md`.
**Cómo detectarlo:** el agente te hace una pregunta que ya respondiste en alguna sesión anterior.
**Cómo evitarlo:** aplicar **Principio 4** (decisiones como contratos) y **Patrón B.3** (decisiones tomadas explícitas en cada prompt).
---
## AP.5 — Improvisación sin gobernanza
**Comportamiento:** un equipo (o miembros individuales) usa agentes de IA sin método compartido. Cada uno improvisa con su herramienta preferida, sin briefs, sin validación estructurada, sin documentar decisiones.
**Daño concreto:**
1. Deuda técnica invisible: artefactos producidos por IA que nadie puede auditar después
2. Aprendizaje no se acumula: descubrimientos individuales no se transfieren
3. Calidad inconsistente y no atribuible
4. Imposibilidad de mejorar sistemáticamente
**Cómo detectarlo:** preguntá al equipo "¿cómo hicieron este artefacto con IA?" — si las respuestas son distintas y vagas, estás acá.
**Cómo evitarlo:** este método entero está diseñado para evitarlo. Específicamente: empezar con **Principios 1 y 4**.
---
## AP.6 — Brief grande pegado en chat
**Comportamiento:** pegar un brief de 800+ líneas en el chat del agente como mensaje inicial.
**Daño concreto:**
- Consume contexto innecesariamente
- No es auditable después
- Se pierde si la sesión se corta
- Cuesta tokens repetirlo
Caso real (InQ ROI Sprint 1.5): el primer intento fue pegar el BRIEF.md completo en chat. Se reemplazó por la estructura `sprints/sprint-N/` con archivos versionados, mensaje inicial corto referenciando paths.
**Cómo detectarlo:** si tu mensaje inicial al agente tiene más de 30 líneas, probablemente estás en este anti-patrón.
**Cómo evitarlo:** aplicar **Patrón B.1** (prompt como artefacto del repo) y **Patrón B.2** (mensaje inicial corto).
---
## AP.7 — Bypass total de permisos en desarrollo local
**Comportamiento:** usar `--dangerously-skip-permissions` (o equivalente) rutinariamente en máquinas de desarrollo donde corren proyectos reales. El motivo declarado suele ser "evitar la fricción de aprobar cada comando".
**Daño concreto:**
1. **Pérdida de auditoría en tiempo real:** cuando un agente ejecuta una acción no intencionada (borrar archivos, modificar git history, instalar dependencias rotas), el humano lo descubre por consecuencia, no por la pausa de aprobación.
2. **Datos perdidos en repos en evolución:** un `rm -rf` accidental sobre el directorio equivocado puede borrar trabajo no comiteado, configuraciones locales, o assets generados que no están en git.
3. **Confianza falsa en el agente:** el dev se acostumbra a "todo funciona" y deja de auditar. Cuando aparece un comando raro, el reflejo de pausar ya no existe.
4. **Imposibilidad de saber cuándo empezó un problema:** sin puntos de control, debuggear "¿cuándo pasó X?" requiere reconstruir desde logs en vez de tener una traza de aprobaciones.
**Caso real (no en este equipo, observado en otros proyectos):** un desarrollador con bypass total dejó al agente refactorizar carpetas con `mv` masivos. Una iteración movió archivos a una ruta equivocada por un typo del agente en la ruta destino. Sin permisos, los `mv` se ejecutaron en cadena. Recuperar tomó 4 horas porque los archivos no estaban en stash ni en commits.
**Cómo detectarlo:**
- El dev menciona "skipeo todos los permisos para ir más rápido"
- No existe `.claude/settings.json` ni equivalente en el repo
- El dev no sabe explicar qué hace `--dangerously-skip-permissions`
**Cómo evitarlo:**
- Aplicar **Patrón D.4** (permisos versionados en `.claude/`)
- Configurar `allow` para comandos rutinarios (que es lo que daba fricción)
- Configurar `deny` para comandos críticos (`rm -rf` con paths absolutos, `git push --force`, `npm publish`, `git reset --hard`)
- **Aceptar la fricción ocasional como precio de la gobernanza**
**Cuándo SÍ es aceptable usar bypass total:**
- Sandboxes efímeros (contenedores Docker que se destruyen post-ejecución)
- CI/CD donde no hay datos del dev
- Máquinas virtuales dedicadas para experimentación
**Cuándo NO es aceptable:**
- Máquina personal del dev con repos reales
- Cualquier entorno donde haya datos no replicables
- Cualquier entorno con credenciales o secretos
---
## Cómo agregar un anti-patrón
Solo se agregan anti-patrones con daño concreto observado, no especulativo. Estructura obligatoria:
1. **Nombre breve** (forma de "AP.N — Descripción")
2. **Comportamiento:** descripción de la acción problemática
3. **Daño concreto:** qué pasa cuando ocurre, con caso real citado
4. **Cómo detectarlo:** señal observable
5. **Cómo evitarlo:** principio o patrón que lo previene
No agregar anti-patrones de "esto sería malo si pasara" — solo de cosas que **pasaron en proyectos reales** del equipo.
---
## AP.8 — Adelanto silencioso de deuda técnica
**Comportamiento:** el agente adelanta ítems explícitos de `TECH_DEBT.md`, `BACKLOG.md`, u otros archivos de planificación sin autorización del director, argumentando "mejora de legibilidad", "consistencia con etapa anterior", "resolver bug menor", o similar.
El cambio puede ser técnicamente correcto y producir un output mejor, pero **desestabiliza la planificación** del sprint y la priorización del director.
**Daño concreto:**
1. **Erosión de la planificación:** ítems planificados para sprints futuros se ejecutan en el sprint actual sin que el director lo decida. La capacidad estimada del sprint se desborda silenciosamente.
2. **Pérdida de control de scope:** el sprint termina con más cambios que los autorizados. La trazabilidad de "qué se hizo y cuándo" se rompe.
3. **Mensaje al agente:** "los Nivel 3 mal clasificados a veces se aprueban retroactivamente, podés seguir intentando". La política de iniciativa proactiva se debilita por aceptación implícita.
4. **Decisiones arquitectónicas no tomadas:** el cambio puede tener implicaciones que el agente no anticipó pero el director hubiera detectado. Por ejemplo: cambiar Σ a "SUM" se ve bien aisladamente, pero puede entrar en conflicto con políticas de tipografía financiera o internacionalización.
**Caso real (InQ ROI Sprint 1.5):**
- **Etapa 7:** Claude Code reescribió fórmulas matemáticas de la nota metodológica del PDF ROI a ASCII puro (Σ → "SUM", × → "x", → "-") sin reportar el cambio en el reporte de la etapa. El bug Unicode estaba documentado en `TECH_DEBT.md` con plan "embebido de fuente Unicode en Sprint 2". El cambio era Nivel 3 (afecta contenido del entregable, choca con plan de TECH_DEBT) pero pasó sin reporte.
- **Etapa 8:** Claude Code extendió el cambio a los PDFs Actual y Automatizado, esta vez clasificándolo como Nivel 1 con el argumento "mismo criterio que ROI Etapa 7". Detectado en el reporte y discutido. El director decidió aceptar retroactivamente la solución porque resultó ser correcta para el dominio (terminología financiera/Excel), pero el patrón quedó documentado como anti-patrón.
**Cómo detectarlo:**
- El agente reporta un cambio justificado como "mejora", "consistencia", "resuelve bug", "limpia código" o similar.
- Verificá si ese cambio resuelve un ítem listado en `TECH_DEBT.md`, `BACKLOG.md`, u observaciones diferidas. **Si está listado, era Nivel 3 sin importar el tamaño técnico.**
- El agente invoca precedente ("se hizo lo mismo en etapa anterior") como justificación. Verificá si el precedente fue autorizado o si también es un adelanto silencioso.
**Cómo evitarlo:**
- Aplicar **Patrón C.6 refinado** (ver `methodology/PATTERNS.md`): cualquier cambio que resuelve ítem listado en archivos de planificación es Nivel 3, consulta previa.
- En `CLAUDE.md` del proyecto, agregar regla: "Antes de aplicar un cambio fuera del scope del prompt actual, verificar si está listado en TECH_DEBT.md, BACKLOG.md, o equivalentes. Si está listado, consultar antes de proceder."
- Auditoría adversarial al reporte (Patrón C.3): cuando el agente reporta cambios "consistentes" o "menores", verificar contra archivos de planificación, no solo contra el prompt de la etapa.
**Distinción importante:**
AP.8 NO es lo mismo que AP.3 (Racionalización de output anómalo). AP.3 es minimizar un problema observado. AP.8 es ejecutar trabajo no autorizado argumentando que es mejora.
AP.8 también difiere del Patrón C.6 mal aplicado. C.6 mal aplicado es clasificar mal el nivel de impacto. AP.8 es específicamente sobre adelantar deuda técnica planificada — un subconjunto que merece nombre propio.
---
## AP.9 — Resolución unilateral de restricciones contradictorias
**Comportamiento:** el agente enfrenta dos requisitos del prompt que colisionan (ej: "no tocar archivo X" + "build limpio sin errores TypeScript") y resuelve la contradicción actuando mínimamente en lugar de detenerse y reportar el conflicto.
**Daño concreto:** el agente produce código funcionalmente correcto en el área autorizada pero roto en el área restringida, y describe el resultado como "comportamiento esperado" minimizando la severidad real. Caso real (InQ ROI Sprint 2, Etapa 1): el prompt restringía `ActivityPanel.tsx` explícitamente (`❌`), pero también exigía build sin errores TypeScript. Cambiar `types.ts` inevitablemente generaba errores TypeScript en `ActivityPanel.tsx`. El agente lo corrigió mínimamente (clasificó como Nivel 2) en lugar de detenerse. Resultado: slider roto que requirió toda la Etapa 2 para corregir.
**Cómo detectarlo:** el reporte declara build limpio pero hay un archivo restringido en el diff. O el agente menciona "ajuste mínimo para compatibilidad de tipos" en un archivo que debería estar intacto.
**Cómo evitarlo:**
1. En el prompt: cuando haya restricción `❌` sobre un archivo, agregar explícitamente: *"Si este cambio produce errores TypeScript en archivos restringidos, DETENTE, no los corrijas, reportá el conflicto al director antes de continuar."*
2. Control preventivo: `git diff --name-only HEAD` al final — si aparece un archivo restringido, revertir y reportar.
3. No incluir simultáneamente restricciones absolutas (`❌ no tocar X`) y requisitos que inevitablemente afectan X (cambios de tipo transitivos). Si el conflicto es inevitable, resolverlo en el prompt antes de dárselo al agente.
**Relación con otros patrones:**
- Requiere el control preventivo de **C.7** (git diff como control de scope) para detección temprana.
- Distinto de **AP.5** (improvisación sin gobernanza): AP.9 ocurre dentro de un prompt estructurado, no por ausencia de método.
---
## AP.10 — Propagación silenciosa de inconsistencia entre fuentes
**Comportamiento:** un prompt contiene una referencia (código, ruta, esquema) que el agente
podría verificar contra otra fuente del repo. En lugar de hacerlo, el agente ejecuta
literalmente y traslada la responsabilidad al director con frases como "la validación
semántica la hace el director" o "los textos son los solicitados literalmente".
**Daño:** las inconsistencias se propagan a producción con apariencia de precisión técnica.
*Caso real:* en Etapa 2D del proyecto Datos País, 11 de 12 subtítulos del dashboard
quedaron con códigos de pregunta discrepantes del seed real. El dashboard mostraba
métricas mal etiquetadas: el KPI "Ausencia promedio · 3.1 · días por mes" referenciaba
una pregunta que en el seed real era *gasto en cuidado*, no ausencias. Detectado en la
auditoría adversarial posterior, no antes. Si hubieran cargado datos reales, RRHH habría
visto guaraníes presentados como días.
**Detectar:** el reporte del agente declara "los textos son los solicitados literalmente";
hay códigos, rutas o nombres que el agente pudo verificar contra otra fuente y no lo hizo;
el agente delega la verificación semántica al humano sin haber verificado lo verificable.
**Evitar:** Patrón C.8 + cláusula explícita en el prompt: *"si detectás una discrepancia
entre el prompt y la fuente única, DETENÉ la ejecución y reportá antes de avanzar"*.
**Distinción:** ≠ AP.3 — AP.3 minimiza un problema observado; AP.10 evita verificar lo
verificable. ≠ AP.9 — AP.9 resuelve restricciones contradictorias del propio prompt;
AP.10 propaga inconsistencias entre el prompt y otra fuente.
## AP.11 — Contrato desactualizado por código
**Comportamiento:** una etapa modifica el comportamiento real del sistema (seed, esquema,
RLS, RPCs) pero no actualiza el documento que sirve como contrato o fuente única
(`SEED_SPEC.md`, `ARCHITECTURE.md`, ADRs). El documento queda mintiendo por silencio.
**Daño:** el próximo agente o autor de prompts que lo lea va a tomar decisiones basadas
en información falsa. *Caso real:* en Etapa 1D del proyecto Datos País se modificó
`seed.sql` para que A1/A2/A3 fueran widgets geo (Departamento/Ciudad/Barrio en lugar de
texto libre País/Ciudad/Sucursal), pero no se actualizó `SEED_SPEC.md`. Dos sprints
después, Etapa 2E se escribió tomando SEED_SPEC como verdad y proponiendo agregar una
pregunta A1b que duplicaba la A1 ya existente. El conflicto se detectó antes de ejecutar
porque el agente aplicó el Patrón C.8; sin C.8, se habría creado una pregunta duplicada
en producción.
**Detectar:** cuando una etapa cambia comportamiento del sistema, preguntar explícitamente
"¿qué documento de contrato describe esto, y lo actualicé?". Si la respuesta es "no sé"
o "después lo veo", AP.11 latente. También: cuando una etapa posterior asume contenido
del contrato que ya no coincide con la realidad.
**Evitar:** todo prompt que modifique un sistema documentado en un contrato debe incluir,
como parte del DoD, "actualizar el documento de contrato correspondiente con los cambios".
Es Principio 4 aplicado al mantenimiento, no solo a la creación.
**Distinción:** ≠ AP.4 — AP.4 es decisión no registrada; AP.11 es decisión registrada en
código pero NO actualizada en el contrato escrito.

513
method/PATTERNS.md Normal file
View File

@@ -0,0 +1,513 @@
# Patrones operativos
> Aplicaciones concretas de los 5 principios. Estructurados por categoría operativa.
>
> Estado de cada patrón: **propuesto** (validado en 1 proyecto) o **estable** (validado en 2+ proyectos).
>
> A la fecha, todos los patrones aquí son propuestos. Caso de validación: InQ ROI Sprint 1.5.
---
## Categoría A — Disciplina antes de codear
### A.1 Brief versionado antes que código
**Estado:** propuesto · **Deriva de:** Principio 1
Todo sprint o cambio mayor arranca con un brief en `sprints/sprint-N/BRIEF.md` antes de pedirle algo a un agente. El brief define alcance, decisiones tomadas, criterios de éxito y lo que NO entra.
**Indicador de aplicación correcta:** podés iniciar una sesión nueva con Claude Code pasándole solo el path del brief y CLAUDE.md, sin contexto adicional, y empieza a trabajar correctamente.
**Indicador de violación:** el agente te hace preguntas que ya están respondidas en otro lugar (o peor, no las hace y asume).
---
### A.2 Mockup como contrato visual
**Estado:** propuesto · **Deriva de:** Principio 1
Si el cambio es visual, antes de implementarlo se construye un mockup interactivo (HTML, Figma, Visualizer) que el humano aprueba. El mockup es el contrato: el agente lo replica, no lo interpreta libremente.
**Indicador de aplicación correcta:** cuando ves el output del agente, podés compararlo lado a lado con el mockup y la diferencia es marginal.
**Indicador de violación:** llegás a Etapa N de un rediseño visual y descubrís que el output "no tiene nada que ver" con lo que esperabas.
---
### A.3 Roadmap explícito antes de arrancar
**Estado:** propuesto · **Deriva de:** Principios 1 y 4
Todo sprint con más de 3 etapas tiene un roadmap explícito desde el inicio. Cada etapa tiene **expectativa calibrada** de qué cambia y qué NO cambia respecto a la anterior.
**Indicador de aplicación correcta:** después de cada checkpoint, el resultado coincide con lo que esperabas ver, no te sorprende ni para bien ni para mal.
**Indicador de violación:** te sorprende negativamente un output intermedio porque pensabas que ya tenía X feature que en realidad está agendado 3 etapas después.
---
## Categoría B — Operación con agentes
### B.1 Prompt como artefacto del repo
**Estado:** propuesto · **Deriva de:** Principios 4 y 5
Cada etapa de trabajo agéntico tiene su prompt en `sprints/sprint-N/ETAPA_X_PROMPT.md`, versionado. El chat con el agente es **puente** entre el prompt y el reporte, no contenedor de la decisión.
**Por qué:** los prompts en archivo son auditables, editables antes de ejecutar, replicables, y no se pierden al cerrar la sesión.
**Estructura mínima de un prompt de etapa:**
1. Objetivo
2. Pre-requisitos / contexto previo
3. Decisiones ya tomadas (no consultar de nuevo)
4. Tareas concretas
5. Entregables (DoD checklist)
6. Qué reportar al final
7. Qué NO hacer en esta etapa
---
### B.2 Mensaje inicial corto, contexto en archivos
**Estado:** propuesto · **Deriva de:** Principio 5
El mensaje al agente al arrancar una sesión es de 3-5 párrafos, NO el prompt entero pegado. Estructura:
1. "Procedé con etapa X"
2. Archivos a leer en orden
3. Confirmaciones que esperás antes de que empiece
4. Restricción de alcance ("no avances a Y sin OK")
**Por qué:** el contexto en archivos sobrevive a la sesión, queda versionado, y el agente lo consulta cuando lo necesita en vez de tenerlo permanentemente en ventana.
---
### B.3 Decisiones tomadas como sección explícita
**Estado:** propuesto · **Deriva de:** Principio 4
En cada prompt de etapa, incluir sección "DECISIONES YA TOMADAS — no consultar de nuevo" con las definiciones cerradas. Esto impide que el agente vuelva a preguntar lo ya resuelto.
**Ejemplo:**
```
DECISIONES YA TOMADAS (no consultar de nuevo)
- Payback negativo → "Sin payback (costo aumenta)"
- Caracteres compactos: "<" y "~"
- Pluralización: siempre plural ("1.0 meses")
```
---
### B.4 Reglas no negociables en CLAUDE.md
**Estado:** propuesto · **Deriva de:** Principio 4
Las reglas que aplican a TODO el proyecto van en `CLAUDE.md`, no solo en briefs específicos. El agente carga CLAUDE.md en cada sesión nueva por defecto.
**Ejemplo de qué va ahí:**
- Reglas de marca (cómo se escribe el nombre, qué colores)
- Restricciones de comportamiento (qué no decir, qué no asumir)
- Verificaciones obligatorias pre-entrega
---
## Categoría C — Validación de output
### C.1 Validación visual mandatoria del artefacto final
**Estado:** propuesto · **Deriva de:** Principio 2
Si el output de la etapa es un artefacto visible (PDF, screenshot, página renderizada, documento), el humano lo abre con sus ojos antes del OK. Tests verdes no es suficiente.
**Tooling sugerido:**
- PDFs/imágenes: Preview en Mac, visor PDF en Linux
- Páginas web: dev server en navegador
- Documentos: editor que renderice (no solo source)
**Cuándo evolucionar este patrón:** ver methodology/AUTOMATION_STRATEGY.md.
La validación visual humana es Fase 1. Puede complementarse con agentes
auditores (Fase 3) pero NO se reemplaza completamente hasta que el
equipo desarrolle criterio adversarial maduro.
*Cuando el output incluye textos que referencian otras fuentes del repo (códigos, IDs, rutas), aplicar también C.8.*
---
### C.2 Validación programática antes de declarar completado (agente)
**Estado:** propuesto · **Deriva de:** Principios 2 y 3
Antes de declarar una etapa completada, el agente debe ejecutar validaciones programáticas, no solo confiar en tests unitarios. Para outputs visuales:
- Análisis de píxeles (¿están los colores esperados?)
- Text extraction (¿están las palabras clave?)
- Tamaño de archivo (¿es razonable?)
- Cantidad de páginas (¿coincide con el diseño?)
**Por qué:** un test unitario puede pasar mientras el output final está roto. La validación programática del artefacto cierra ese gap.
---
### C.3 Auditoría adversarial al reporte
**Estado:** propuesto · **Deriva de:** Principio 3
Cuando llega un reporte de agente "todo OK", aplicar 3 preguntas:
1. ¿Los números reportados cuadran si recalculo o pido dump?
2. ¿El artefacto es el que pedí?
3. ¿Lo que no menciona podría ser relevante?
**Casos típicos de detección:**
- PDF de 36 KB con 4 páginas (es muy poco → algo no se renderizó)
- KPI con valor "0.0 meses" (numéricamente correcto pero confuso)
- Reporte que dice "PDF generado" sin abrir el PDF generado
---
### C.4 Reporte con datos concretos, no descripciones (agente)
**Estado:** propuesto · **Deriva de:** Principio 3
Los reportes del agente deben incluir métricas concretas, no descripciones genéricas.
**Mal reporte:** "PDF generado correctamente, contiene todo lo esperado."
**Buen reporte:**
```
PDF generado: 78 KB, 4 páginas
- Página 1: KPI Hero + 4 cards secundarios + top 3 ahorros
- Página 2: tabla con 11 filas + footer
- Página 3: 3 secciones (composición, trayectoria, transparencia)
- Página 4: nota metodológica
Análisis de píxeles del heatmap: verde 36.329px, ámbar 3.889px, rojo 3.713px
Text extraction confirma palabras clave: "InQ ROI", "Powered by InQuality", "Payback"
```
### C.5 Validaciones programáticas explícitas en el prompt
**Estado:** propuesto · **Deriva de:** Principios 2 y 5
Cada prompt de etapa incluye una sección **"Validaciones automatizadas mandatorias"** con criterios objetivos verificables por código que el agente debe ejecutar antes de declarar terminada la etapa o usar `present_files`.
**Por qué:** documenta explícitamente qué se va a auto-validar, hace transferible el conocimiento operativo al equipo, captura el aprendizaje sobre qué problemas son recurrentes y vale la pena chequear automáticamente.
**Cómo se aplica:**
```markdown
## Validaciones automatizadas mandatorias
Antes de present_files, ejecutá y confirmá:
PDFs:
- pdf-parse confirma palabras clave esperadas: [lista]
- pdf-parse rechaza términos prohibidos: [lista]
- Cada página entre [N-M] KB
- Cantidad de páginas == [N exacto]
Tests:
- Tests previos verdes (>= [N])
- Tests nuevos verdes
Build:
- npm run build limpio
- Sin warnings TS ni lint nuevos
```
**Indicador de aplicación correcta:** los problemas que ya cazaste manualmente en sprints anteriores aparecen como validación automatizada en sprints siguientes. Aprendizaje acumulativo.
**Indicador de violación:** el agente declara etapa terminada y vos cazás el mismo tipo de problema que ya cazaste 3 etapas atrás. Significa que el aprendizaje no se está capturando como validación.
**Relación con otros patrones:**
- Extiende **C.2** (validación programática) haciéndola explícita en el prompt
- No reemplaza **C.1** (validación visual humana) — son complementarias
- Es Fase 2 del roadmap de `AUTOMATION_STRATEGY.md`
### C.6 Iniciativa agéntica con consulta clasificada por impacto
**Estado:** propuesto · **Deriva de:** Principios 3, 4 y 5
Cuando el agente detecta una oportunidad de mejora **fuera del scope del prompt**, debe clasificar el impacto del cambio antes de proceder y actuar según la política definida en `PRINCIPLES.md` sección "Política de iniciativa agéntica".
**Niveles de impacto:**
1. **Trivial** (cambio interno, no visible al usuario, reversible sin costo)
→ ejecutar y reportar al final como "decisión menor tomada"
2. **Visible reversible** (cambio visible al usuario pero sin afectar marca, datos, ni arquitectura)
→ ejecutar, **mencionar explícitamente en el reporte final** como "decisión proactiva, validar/revertir"
3. **De marca, arquitectura o datos** (cambio que afecta identidad, estructura, o información del usuario)
**NO ejecutar**. Consultar antes de proceder.
**Por qué:** los agentes producen mejor cuando tienen libertad para optimizar lo local. Pero esa libertad sin límites lleva a cambios silenciosos que el humano descubre por casualidad. Una clasificación explícita preserva la velocidad agéntica sin perder gobernanza.
**Caso real:** InQ ROI Sprint 1.5 Etapa 3. Claude Code agregó "Hecho desde Paraguay 🇵🇾" al footer del web app sin estar en el brief. El cambio era razonable, alineado con identidad de marca, pero **el humano lo descubrió revisando screenshots**, no porque se reportara. Decisión retroactiva: mantener, pero documentar el patrón para que en el futuro este tipo de cambios (impacto nivel 3, afecta marca) sean consulta previa, no decisión unilateral.
**Indicador de aplicación correcta:**
- El reporte final del agente tiene una sección explícita "Decisiones tomadas por iniciativa" listando cambios fuera del scope
- Los cambios nivel 3 no se ejecutan sin consulta previa documentada
- El humano puede aprobar o revertir cambios nivel 2 sin tener que reconstruir contexto
**Indicador de violación:**
- Descubrir cambios en el output que no están ni en el prompt ni en el reporte
- Cambios de marca, arquitectura o datos ejecutados sin consulta previa
- "Pequeñas mejoras de UX" agregadas en silencio que cambian el comportamiento del producto
**Relación con otros patrones:**
- Complementa **E.2** (Hallazgos no esperados) — pero E.2 es para descubrimientos pasivos (cosas que el agente notó), C.6 es para decisiones activas (cosas que el agente hizo).
- Refuerza **C.3** (Auditoría adversarial) — provee al humano una sección concreta donde buscar cambios silenciosos.
## Refinamiento — criterios explícitos de clasificación (aprendizajes de Sprint 1.5)
Durante Sprint 1.5 InQ ROI se detectaron dos casos de mala clasificación de impacto. A partir de esos casos, se refinaron los criterios para distinguir entre Niveles 1, 2 y 3:
### Reglas de clasificación (en orden de evaluación)
Evaluar en este orden estricto. El primer criterio que aplique determina el nivel — no seguir evaluando los siguientes.
**1. ¿El cambio resuelve un ítem listado en `TECH_DEBT.md`, `BACKLOG.md`, o archivos de planificación?**
→ Nivel 3 (consulta previa). Sin importar el tamaño técnico.
**Justificación:** ítems en archivos de planificación son decisiones del director sobre cuándo trabajarlos. El agente no puede promover ítems al sprint en curso por iniciativa.
**2. ¿El cambio choca con una restricción explícita del prompt de la etapa?**
→ Nivel 3 (consulta previa). Sin excepciones.
Ejemplo de restricciones explícitas:
- "NO entra: X" en sección de alcance
- "NO tocar Y" en reglas operativas
- "X está agendado para Etapa Z" en decisiones tomadas
**3. ¿El cambio modifica paginación, layout, cantidad de archivos generados, o estructura de salida?**
→ Nivel 2 mínimo. Reportar.
**4. ¿El cambio es visible al usuario final (texto en interfaz, color, posición de elemento, contenido de PDF)?**
→ Nivel 2 mínimo. Reportar.
**5. ¿El cambio afecta marca, identidad, dependencias, o configuración del producto?**
→ Nivel 3 (consulta previa).
**6. Si nada de lo anterior aplica → Nivel 1**
Ejemplos válidos de Nivel 1:
- Renombrar variable interna por claridad
- Reordenar imports
- Mejorar comentario de código
- Extraer función helper usada en múltiples lugares
- Eliminar código muerto evidente (sin riesgo de uso futuro)
- Limpiar artefactos accidentales del refactor (variables `void` no usadas)
### Argumentos que NO son justificación válida para Nivel 1
El agente puede invocar argumentos que parecen razonables pero violan los criterios de arriba:
- **"Es mejora de legibilidad"** → si afecta contenido visible, es Nivel 2 mínimo
- **"Es consistencia con etapa anterior"** → verificar si la etapa anterior fue autorizada o si también es adelanto silencioso (ver AP.8)
- **"Es trivial de revertir"** → la reversibilidad NO es criterio de clasificación. Cambios reversibles también pueden ser Nivel 3.
- **"Resuelve un bug menor"** → si el bug está documentado en TECH_DEBT.md, es Nivel 3
- **"Es lo que el usuario esperaría"** → eso es asumir. La política existe para no asumir.
### Casos reales documentados (Sprint 1.5 InQ ROI)
**Caso 1 (Etapa 5):** compactación de páginas reportada como Nivel 1.
- Realidad: Nivel 2 mínimo (modificó paginación visible)
- Resultado: nota metodológica truncada, descubierto en validación visual
- Lección: paginación es estructura de salida, criterio 3 aplica
**Caso 2 (Etapa 7-8):** reescritura de fórmulas Σ → "SUM" reportada inicialmente sin clasificación, después como Nivel 1.
- Realidad: Nivel 3 (resolvía ítem de TECH_DEBT.md sobre Unicode roto)
- Resultado: aceptado retroactivamente porque resultó ser solución correcta para el dominio, pero el patrón quedó como anti-patrón AP.8
- Lección: ítem en TECH_DEBT.md activa criterio 1 directo, sin importar el tamaño
### Implicancia para CLAUDE.md del proyecto
En el `CLAUDE.md` de cada proyecto, agregar a la sección "Política de iniciativa proactiva":
```
Antes de aplicar un cambio fuera del scope explícito del prompt actual:
1. Verificá si el cambio está listado en TECH_DEBT.md, BACKLOG.md, o
archivos de planificación equivalentes. Si está, es Nivel 3 — consultá.
2. Verificá si el cambio choca con una restricción explícita del prompt
("NO entra", "NO tocar", "está agendado para Etapa X"). Si choca,
es Nivel 3 — consultá.
3. Verificá si el cambio modifica paginación, layout, estructura de
salida, contenido visible. Si lo hace, es Nivel 2 mínimo — reportá.
4. Solo si nada de lo anterior aplica, ejecutá como Nivel 1.
Argumentos NO válidos para evitar la consulta:
- "Es mejora de legibilidad"
- "Es consistencia con etapa anterior" (verificar si esa etapa fue
autorizada o también fue adelanto silencioso)
- "Es trivial de revertir"
- "Resuelve un bug menor"
```
---
## Categoría D — Estructura del repo
### D.1 Carpeta `sprints/` para activos de planificación
**Estado:** propuesto · **Deriva de:** Principio 4
Cada sprint tiene su carpeta `sprints/sprint-N/` con todos sus artefactos:
- `BRIEF.md`
- `ETAPA_X_PROMPT.md` (uno por etapa)
- `ETAPA_X_REPORT.md` (opcional, reporte del agente capturado)
- `etapa-X-audit.md` (cuando aplica)
- `screenshots/` con evidencia visual
**Por qué:** en 6 meses, abrir esa carpeta te da el historial completo de cómo se construyó el sprint. Sin esto, el conocimiento queda en commits + memoria, que es frágil.
---
### D.2 Carpeta `methodology/` para el método en sí
**Estado:** propuesto · **Deriva de:** Principio 4
El método de trabajo vive en `methodology/`, separado del producto. Esto permite que el método evolucione independiente de cualquier producto específico.
---
### D.3 ADRs para decisiones arquitectónicas
**Estado:** propuesto · **Deriva de:** Principio 4
Las decisiones arquitectónicas importantes se registran como ADR (Architectural Decision Record) en `ARCHITECTURE.md` o `docs/adr/`. Estructura mínima:
- Contexto (qué problema teníamos)
- Decisión (qué elegimos)
- Alternativas consideradas
- Consecuencias (qué implica)
---
### D.4 Permisos del agente versionados en `.claude/`
**Estado:** propuesto · **Deriva de:** Principios 4 y 5
Las decisiones sobre qué comandos puede ejecutar el agente sin consultar al humano se versionan en archivos de configuración del repo. Esto evita dos extremos: la fricción de aprobar cada comando manualmente, y el bypass total que abre la puerta a acciones no intencionadas.
**Estructura recomendada:**
```
repo/
└── .claude/
├── settings.json ← commitable: deny patterns críticos
└── settings.local.json ← gitignored: allow personales del dev
```
**Reglas:**
1. **`settings.json` es del proyecto** — contiene patrones `deny` (comandos peligrosos prohibidos para todos: `rm -rf /`, `git push --force`, `npm publish`, etc.). Se comitea al repo.
2. **`settings.local.json` es del desarrollador** — contiene patrones `allow` personales (cada uno decide qué comandos pre-aprueba). Va en `.gitignore`. Cuando un dev nuevo arranca, **debe construir su propio** archivo siguiendo el template, no copiar el de otro.
3. **El equipo hereda las protecciones, no las preferencias** — los `deny` son compartidos, los `allow` son individuales.
**Por qué:**
- La fricción de aprobar cada `npm install` destruye el flow de trabajo
- Pero el bypass total elimina los puntos de control donde se detectan acciones no intencionadas
- La gobernanza está en clasificar comandos por riesgo, no en aprobar todo o aprobar nada
**Indicador de aplicación correcta:**
- En cada sesión de Claude Code, los comandos rutinarios pasan sin pedir aprobación
- Los comandos no rutinarios siguen pidiendo aprobación (es bueno — ahí prestás atención)
- Si un comando peligroso aparece, está bloqueado por `deny` o pide aprobación explícita
**Indicador de violación:**
- El dev usa `--dangerously-skip-permissions` rutinariamente
- El archivo `settings.json` contiene `allow` que deberían ser personales (paga la complejidad de mantenerlo sincronizado entre devs)
- No hay archivo de configuración y cada dev aprueba todo manualmente cada vez (fricción crónica, señal de que falta este patrón)
**Template:** ver `methodology/TEMPLATES/CLAUDE_PERMISSIONS_TEMPLATE.md`.
**Relación con otros patrones:**
- Complementa **B.4** (reglas no negociables en CLAUDE.md) — pero B.4 es sobre comportamiento del agente, D.4 es sobre permisos del sistema operativo
- Refuerza **Principio 5** (humano director) — sin permisos configurados, el dev se vuelve operador de aprobación
**Casos reales que valida:**
- InQ ROI: el director identificó fatiga de aprobación después de ~30 etapas. Configurar permisos eliminó la fricción sin perder gobernanza.
## Categoría E — Comunicación humano ↔ agente
### E.1 Separar lo validado de lo asumido
**Estado:** propuesto · **Deriva de:** Principios 2 y 3
En los reportes del agente, marcar explícitamente qué fue verificado vs qué se asume funcionando.
**Ejemplo:**
```
Verificado:
- Tests verdes (455/455)
- PDF generado y abierto con pdf-parse
- Análisis de píxeles confirma heatmap completo
Asumido:
- El componente se renderiza igual en Firefox (solo testeado en Chromium)
- La fuente Roboto está disponible en el sistema cliente (no validado)
```
---
### E.2 Hallazgos no esperados como sección explícita
**Estado:** propuesto · **Deriva de:** Principios 3 y 5
Al final de cada etapa, el agente reporta "hallazgos no esperados" — cosas que descubrió durante la implementación que merecen decisión arquitectónica, aunque no formen parte del DoD.
**Ejemplo:** "El cambio de `--primary` propagó a focus rings de inputs (no estaba en el plan). Decisión necesaria: ¿se mantiene o se vuelve a slate?"
---
### E.3 Anti-racionalización explícita
**Estado:** propuesto · **Deriva de:** Principio 3
Cuando un test revela algo raro, un cálculo no cuadra, o un output difiere de lo esperado: **investigar causa raíz, no minimizar**.
Patrón típico de violación: el agente dice "el cálculo es numéricamente correcto" cuando lo que está reportando es un problema de presentación (ej. "0.0 meses" es matemáticamente correcto pero un bug de UX).
---
### C.7 Control preventivo de scope — `git diff` antes de reportar
**Estado:** propuesto · **Deriva de:** Principios 3 y 4 · **Origen:** Sprint 2 Etapa 1
Incluir en todo prompt de Claude Code la siguiente verificación como paso obligatorio antes de reportar:
```bash
git diff --name-only HEAD
```
El resultado debe contener únicamente los archivos listados como autorizados en el prompt. Si aparece un archivo no autorizado:
1. Revertirlo: `git checkout HEAD -- <archivo>`
2. Listarlo bajo "Conflictos de scope" en el reporte
3. No incluirlo en el entregable de la etapa
**Por qué es preventivo, no detectivo:** los controles detectivos (grep de tipos, build, tests) verifican que el código resultante funciona, pero no detectan archivos modificados fuera del scope. Un agente puede tocar `ActivityPanel.tsx` y los tests seguir verdes si el cambio no rompe nada. El `git diff` captura el cambio antes de que se declare la etapa terminada.
**Indicador de aplicación correcta:** el reporte del agente incluye explícitamente la salida de `git diff --name-only HEAD` y todos los archivos listados son los autorizados.
**Indicador de violación:** el reporte no incluye la salida del git diff, o el diff contiene archivos no listados sin explicación.
**Relación con otros patrones:**
- Es respuesta directa al daño documentado en **AP.9** (resolución unilateral de restricciones contradictorias).
- Complementa **C.3** (auditoría adversarial) dándole al humano una checklist concreta de qué verificar.
---
### C.8 — Verificación de códigos contra fuente única · *deriva de P3, P4 · origen: 2do proyecto*
Cuando un prompt menciona códigos de un instrumento, esquema o contrato (preguntas, columnas,
campos), el agente verifica cada código contra la fuente única (`SEED_SPEC.md`, `schema.sql`,
ADRs) **antes** de ejecutar. Si detecta una discrepancia entre el código nombrado en el
prompt y su definición en la fuente única, **DETIENE la ejecución y reporta el conflicto**.
Ejecutar "literalmente" y delegar la verificación semántica al director es AP.10.
-**Correcto:** el reporte del agente incluye una sección "Verificación C.8" con cada
código del prompt mapeado a su fila en la fuente única, antes de la ejecución. Si hay
discrepancia, la ejecución no arranca hasta que el director resuelva.
-**Violación:** el agente ejecuta el prompt al pie de la letra y reporta "los textos son
los solicitados literalmente; la validación semántica la hace el director" — exporta el
trabajo de verificar al humano y propaga la inconsistencia.
**Cómo se incorpora a un prompt:** agregar un paso "Verificación C.8" como primera tarea
del DoD, listando los códigos a verificar y la ruta exacta de la fuente única.
**Casos reales en Datos País:** Etapa 2D (no se aplicó — daño AP.10), Etapa 2E (se aplicó —
detectó conflicto A1/A1b antes de ejecutar), Etapa 2F (se aplicó — sin discrepancias).
---
## Cómo agregar un patrón nuevo
1. Identificar el patrón durante un proyecto real (no inventarlo en abstracto).
2. Documentarlo con: nombre, categoría, principio del que deriva, descripción, indicador de aplicación correcta, indicador de violación.
3. Marcarlo como **propuesto** con cita del proyecto donde apareció.
4. Después de aplicarlo en un 2do proyecto distinto, promoverlo a **estable**.
## Cómo retirar un patrón
Si un patrón no se sostiene en práctica o tiene falsos positivos frecuentes, se retira con un PR que documente:
- Por qué no funcionó
- Qué alternativa probamos
- Si hay un patrón nuevo que lo reemplaza

View File

@@ -0,0 +1,178 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — junio 2026
> Generado al cierre de una sesión extendida de dirección técnica.
> Propósito: minimizar pérdida de contexto al abrir una nueva conversación.
> Director: Marcos (Roy) · Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
---
## 1. Qué es el proyecto
Plataforma soberana de encuestas sobre economía del cuidado en Paraguay.
- **Producto:** formulario anónimo + dashboard analítico con k-anonimato.
- **Marca:** RE (Responsabilidad y Empatía Empresarial) · Fundación Solidaridad · InQuality.
- **URL producción:** https://economia-cuidado.inqualityhq.com
- **Survey demo:** https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001
---
## 2. Infraestructura actual — OPERATIVA ✅
### Stack
- **App:** Next.js 15.3.3 + TypeScript + Tailwind CSS
- **DB:** Supabase self-hosted en Dokploy (stack oficial clonado)
- **Deploy:** Dokploy v0.29.4 en VPS inq-mart-2 (IP VPN: 172.20.5.11)
- **Repos:**
- App: `git.inquality.com.py/marcos/motor-de-encuestas` (migrado desde GitHub InqGit)
- Supabase stack: `git.inquality.com.py/marcos/supabase-motor-fomularios`
- **Dominio API Supabase:** `db.inqualityhq.com` (Kong expuesto con SSL via Traefik/Let's Encrypt)
### Supabase self-hosted — detalles técnicos
- Stack corriendo desde repo Gitea (modo Git, NO docker raw — docker raw no tiene ./volumes/db/)
- Kong conectado a `dokploy-network` (persistente en docker-compose.yml del repo)
- Fix aplicado: `LUA_AUTH_EXPR` en `kong-entrypoint.sh` con prefijo `Bearer ` para PostgREST JWT propagation
- Variables de entorno en Dokploy: ANON_KEY, SERVICE_ROLE_KEY, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY (nombres que usa kong.yml)
- SMTP_PORT=587 requerido aunque SMTP esté desactivado (GoTrue lo exige como int)
- GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false (GoTrue lo exige como bool explícito)
- Backups: script GFS en /usr/local/bin/supabase-backup.sh, cron 3AM root, retención 3 días diarios + domingos 14 días
- Studio: solo accesible via SSH tunnel `ssh -L 3001:localhost:3001 administrator@172.20.5.11`
### Variables de entorno de la app Next.js (Build Arguments en Dokploy)
- NEXT_PUBLIC_SUPABASE_URL=https://db.inqualityhq.com
- NEXT_PUBLIC_SUPABASE_ANON_KEY=<JWT anon>
- SUPABASE_SERVICE_ROLE_KEY=<JWT service_role> (Environment Setting, no Build Arg)
---
## 3. Estado del código — Sprint 2 COMPLETADO, Sprint 3 COMPLETADO
### Migraciones aplicadas (12 en total, en orden)
1. 20260531000001_create_tables.sql — 7 tablas
2. 20260531000002_enable_rls.sql
3. 20260531000003_rls_policies.sql — PARCIAL (ver TECH-DEBT-007)
4. 20260531000004_constraints_triggers.sql
5. 20260531000005_agg_segment.sql
6. 20260531000006_grants.sql
7. 20260602111851_revoke_consent_select.sql
8. 20260604200932_fix_answers_insert_policy.sql
9. 20260605093948_dashboard_rpc.sql
10. 20260605093949_rls_deny_sensitive.sql
11. 20260605140000_011_truncate_testing_data.sql
12. 20260605140001_012_dashboard_rpc_v2.sql — 11 RPCs v2 del dashboard
### Políticas RLS activas (7)
- answers_insert, deny_sensitive_answers
- consent_records_insert, consent_versions_select_all
- questions_select_live, responses_insert
- surveys_select_live_anon
### Políticas RLS que fallaron (TECH-DEBT-007)
- org_select_own, surveys_select_auth, questions_select_auth
- Causa: auth.jwt() deprecado en GoTrue v2.189+ — no es bloqueante para el piloto
### Dashboard — widgets y códigos reales del instrumento
| Widget | Códigos reales | Subtítulo |
|---|---|---|
| K1 Cuidadores activos | 1.6 + 1.13 | % plantilla con responsabilidades de cuidado |
| K2 Frecuencia ausentismo | 5.2 | % con 4+ ausencias al año |
| K3 Bienestar | 4.3 (rating 1-5) | autorreporte bienestar general |
| W1 Prevalencia por depto | A1 (Departamento) | % cuidadores activos por departamento |
| W2 Perfil población | 1.1+1.6+1.13 | composición no excluyente |
| W3 Madurez organizacional | 6.1(×0.7)+6.5(×0.3) | score 0-100 |
| W4 Horas×Ausentismo | 2.2×5.2 | correlación intensidad/ausentismo |
| W5 Pirámide demográfica | A5×A6 | etario por género (k≥5) |
| W6 Apoyos demandados | 6.2 | top 8 multi-select |
| W7 Intensidad vs Bienestar | 2.2×4.3 | bins k≥5 |
| W8 Saturación cuidador | 4.3+5.1+5.2 | indicador compuesto |
---
## 4. Deuda técnica registrada
| ID | Descripción | Bloqueante |
|---|---|---|
| TECH-DEBT-006 | Backup remoto S3 (Backblaze B2 o Wasabi ~$5/mes) | **SÍ — antes del primer dato real** |
| TECH-DEBT-007 | auth.jwt() deprecado en GoTrue v2.189+ — current_app_role() y current_org_id() no funcionales | No (service_role bypasa RLS) |
| CVE-2025-66478 | Next.js 15.3.3 con vulnerabilidad de seguridad crítica | **SÍ — antes del primer dato real** |
---
## 5. Prompts pendientes de ejecutar en Claude Code
### ETAPA 2G — Cierre Sprint 2 (método)
**Archivo:** `sprints/sprint-2/ETAPA_2G_PROMPT.md`
**Qué hace:** promueve Patrón C.8 a estable + documenta AP.10 y AP.11 en method/ANTI_PATTERNS.md con casos reales del proyecto.
### ETAPA 3V — Vista de control /admin/responses
**Archivo:** `sprints/sprint-3-infra/ETAPA_3V_PROMPT.md`
**Qué hace:** crea ruta Next.js /admin/responses que muestra tabla de todas las respuestas ordenadas por fecha descendente, sin auth, usando service_role.
---
## 6. Contexto comercial
- **2 empresas comprometidas** para piloto (no en producción aún)
- **Cronograma deck:** JUN 2026 inicio · JUL-AGO relevamiento · SEP análisis · OCT primer informe + evento
- **Meta:** 20 empresas pioneras
- **Precios:** Gs. 4.5M (≤50) · 7.5M (≤200) · 12M (≤400)
- **GAPs deck vs. producto:**
- RE Index no implementado (fórmula: Bienestar×0.35 + Concentración×0.25 + Descanso×0.25 + Apoyo×0.15)
- Informe descargable prometido en el deck, no existe todavía
- **Contacto Fundación:** Valeria Arce — ddff@fundacionsolidaridad.org.py · +595 991 854333
---
## 7. Documentos producidos en estas sesiones
| Archivo | Descripción |
|---|---|
| INSTALL_SUPABASE.md | Guía instalación self-hosted (384 líneas) — con tabla de errores conocidos |
| BACKUP_STRATEGY.md | Estrategia backup GFS (335 líneas) — con procedimiento de restore |
| DOCUMENTACION_PLATAFORMA.md | Doc conceptual con 3 diagramas Mermaid |
| DatoPais_DocumentacionConceptual.docx | Word con 3 PNGs de diagramas embebidos |
| DatoPais_DimensionamientoEconomico.docx | Word con 5 charts económicos |
| DatoPais_ValidacionInstrumento.xlsx | Planilla colaborativa 4 hojas |
| sprints/sprint-2/ETAPA_2G_PROMPT.md | Cierre Sprint 2 — PENDIENTE ejecución |
| sprints/sprint-3-infra/ETAPA_3V_PROMPT.md | Vista /admin/responses — PENDIENTE ejecución |
---
## 8. Decisiones clave tomadas (no reabrir)
1. **docker raw de Dokploy es incompatible con Supabase** — usar siempre modo Git; el compose oficial necesita ./volumes/db/ que docker raw no tiene.
2. **POSTGRES_PASSWORD siempre con openssl rand -hex** — base64 tiene caracteres especiales que rompen el escaping de Docker Compose.
3. **kong.yml: una sola entrada por bloque keyauth_credentials** — dos entradas con el mismo valor JWT causa uniqueness violation.
4. **SMTP_PORT=587 siempre explícito** aunque SMTP esté desactivado — GoTrue lo parsea como int y falla si está vacío.
5. **GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito** — GoTrue lo parsea como bool y falla si está vacío.
6. **LUA_AUTH_EXPR con prefijo "Bearer "** en kong-entrypoint.sh rama legacy — sin el prefijo PostgREST recibe JWT vacío.
7. **kong.yml usa $ANON_KEY y $SERVICE_ROLE_KEY** — alineado con los nombres de variables en Dokploy; SUPABASE_ANON_KEY y SUPABASE_SERVICE_KEY son aliases adicionales.
8. **App Next.js conecta a Supabase por URL pública** (https://db.inqualityhq.com) — las variables NEXT_PUBLIC_* se embeben en el bundle en build time y el navegador no puede resolver nombres Docker internos.
9. **NEXT_PUBLIC_SUPABASE_URL va como Build Argument** en Dokploy, no como Environment Setting — se embebe en el bundle de JavaScript durante el build.
10. **Repos en Gitea** (migrado desde GitHub InqGit) — VS Code configurado apuntando a git.inquality.com.py.
---
## 9. Patrones y anti-patrones del método (pendiente formalización en 2G)
### C.8 — Verificación de códigos contra fuente única (PROMOVER A ESTABLE)
Todos los widget subtitles y RPC mappings deben verificarse contra el instrumento real (SEED_SPEC.md). El drift silencioso entre instrumento e implementación es un failure mode de alto costo.
### AP.10 — Propagación silenciosa de inconsistencia entre fuentes
Caso real: Etapa 2D tenía subtítulos con códigos incorrectos que no se detectaron hasta auditoría explícita. Los errores de mapeo de códigos no generan errores en tiempo de compilación.
### AP.11 — Contrato desactualizado por código
Caso real: Etapa 1D dejó SEED_SPEC.md desactualizado respecto al seed.sql. El documento de referencia divergió del código sin señal visible.
---
## 10. Próximos pasos en orden de prioridad
1. **TECH-DEBT-006** — Backup remoto S3 (bloqueante antes del primer dato real)
2. **CVE-2025-66478** — Actualizar Next.js antes del primer dato real
3. **ETAPA 2G** — Ejecutar en Claude Code (método, no infraestructura)
4. **ETAPA 3V** — Ejecutar en Claude Code (vista /admin/responses)
5. **RE Index** — Implementar como widget real (confirmar mapeo G1/G2/G4/G5 → códigos seed con Marcos primero)
6. **Informe descargable** — Prometido en deck comercial, no existe
7. **Onboarding 2 empresas comprometidas** — cuando validación esté lista
8. **Validación instrumento** — Respuestas equipo de salud en DatoPais_ValidacionInstrumento.xlsx (12 preguntas metodológicas abiertas)

View File

@@ -0,0 +1,70 @@
# PROMPT DE CONTINUIDAD — nueva sesión de dirección técnica
## Datos País sobre el Cuidado
> Pegá este prompt al inicio de una nueva conversación con Claude.
> Actualizado: junio 2026.
---
Sos el copiloto técnico del proyecto "Datos País sobre el Cuidado" — una plataforma soberana de encuestas sobre economía del cuidado en Paraguay. Ya tenemos múltiples sesiones de trabajo juntos. Este mensaje te pone al día antes de continuar.
## Contexto del proyecto
Plataforma de dos caras: formulario web anónimo + dashboard analítico con k-anonimato. Marca RE, Fundación Solidaridad, powered by InQuality.
- **Stack:** Next.js 15.3.3 + TypeScript + Tailwind CSS + Supabase self-hosted
- **Deploy:** Dokploy v0.29.4 en VPS (IP VPN: 172.20.5.11)
- **URL producción:** https://economia-cuidado.inqualityhq.com
- **Repos:** app en `git.inquality.com.py/marcos/motor-de-encuestas`, Supabase stack en `git.inquality.com.py/marcos/supabase-motor-fomularios`
- **API Supabase:** https://db.inqualityhq.com (Kong con SSL)
## Estado actual — Sprint 3 COMPLETADO
El formulario y el dashboard están operativos con Supabase self-hosted. Las 12 migraciones están aplicadas. Backups locales configurados con retención GFS. El formulario carga y permite completar la encuesta end-to-end.
## Deuda técnica abierta (ver TECH_DEBT.md en el repo)
- **TECH-DEBT-006** — Backup remoto S3 — BLOQUEANTE antes del primer dato real de empresa
- **TECH-DEBT-007** — auth.jwt() deprecado en GoTrue v2.189+ — no bloqueante para el piloto
- **CVE-2025-66478** — Next.js 15.3.3 con vulnerabilidad crítica — BLOQUEANTE antes del primer dato real
## Decisiones tomadas — no reabrir
1. Supabase en Dokploy siempre via modo Git (docker raw no tiene ./volumes/db/)
2. POSTGRES_PASSWORD siempre con openssl rand -hex (evita caracteres problemáticos)
3. kong.yml: una sola entrada por bloque keyauth_credentials
4. SMTP_PORT=587 siempre explícito aunque SMTP esté desactivado
5. GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito
6. LUA_AUTH_EXPR con prefijo "Bearer " en kong-entrypoint.sh rama legacy
7. NEXT_PUBLIC_SUPABASE_URL via Build Argument en Dokploy (no Environment Setting)
8. App Next.js conecta a Supabase por URL pública https://db.inqualityhq.com
## Prompts pendientes de ejecutar en Claude Code
1. **ETAPA 2G**`sprints/sprint-2/ETAPA_2G_PROMPT.md` — cierre Sprint 2, formaliza patrones C.8, AP.10, AP.11 en el método
2. **ETAPA 3V**`sprints/sprint-3-infra/ETAPA_3V_PROMPT.md` — vista /admin/responses para control operativo
## Próximos pasos en orden
1. TECH-DEBT-006 (S3 backup) — bloqueante
2. CVE-2025-66478 (Next.js update) — bloqueante
3. Ejecutar ETAPA 2G en Claude Code
4. Ejecutar ETAPA 3V en Claude Code
5. RE Index — confirmar mapeo G1/G2/G4/G5 → códigos seed antes de implementar
6. Informe descargable — prometido en deck comercial, no existe aún
## Método de trabajo
Usamos niveles de autonomía explícitos en los prompts (Nivel 1 = reportar antes de hacer, Nivel 3 = ejecutar con validación al final). Los prompts de Claude Code están en `sprints/`. Las decisiones arquitecturales se documentan como ADRs en `docs/adr/`. Las reglas no negociables del proyecto están en `CLAUDE.md` del repo.
## Preguntas frecuentes de contexto
**¿Por qué Supabase self-hosted y no Cloud?** Soberanía del dato — datos de cuidadores paraguayos no deben vivir en servidores de terceros fuera de control del proyecto. Decisión tomada antes de ingresar el primer dato real.
**¿Por qué k-anonimato k≥5?** Regla no negociable del sistema. En empresas chicas, un cruce demográfico de menos de 5 personas puede reidentificar a un individuo aunque el nombre nunca se haya guardado.
**¿Por qué el dashboard usa service_role?** Porque las políticas de org_admin con JWT custom claims no están implementadas (TECH-DEBT-007). Para el piloto, el dashboard es herramienta interna y service_role es aceptable. Cuando haya org_admin real, hay que resolver TECH-DEBT-007 primero.
---
¿Listo para continuar? El próximo paso es [COMPLETAR CON LO QUE VAYAS A HACER].

View File

@@ -0,0 +1,48 @@
# TECH_DEBT.md — actualización junio 2026
## Para aplicar en el repo del proyecto
> Reemplazar el contenido existente de TECH_DEBT.md en el repo con este bloque,
> o mergear los ítems nuevos si ya existe el archivo con otros ítems previos.
---
## Deuda técnica abierta
### TECH-DEBT-006 — Backup remoto S3
**Estado:** ABIERTO — BLOQUEANTE antes del primer dato real de empresa
**Descripción:** Los backups locales están configurados y funcionando (GFS, 3AM diario, retención 3 días + domingos 14 días). Pero los backups viven solo en el mismo VPS. Si el VPS muere, se pierden datos Y backups.
**Solución:** rsync nocturno a S3-compatible (Backblaze B2 o Wasabi, ~USD 5/mes) usando rclone.
**Referencia:** BACKUP_STRATEGY.md sección 5 — TECH-DEBT-006.
### TECH-DEBT-007 — auth.jwt() deprecado en GoTrue v2.189+
**Estado:** ABIERTO — no bloqueante para el piloto
**Descripción:** Las funciones helper `current_app_role()` y `current_org_id()` en migration 003 usan `auth.jwt()` que fue eliminado en GoTrue v2.189+. Las políticas que dependen de estas funciones no se crearon:
- `org_select_own` en organizations
- `surveys_select_auth` en surveys
- `questions_select_auth` en questions
**Impacto actual:** ninguno — el dashboard usa service_role que bypasa RLS. Los respondentes usan anon que tiene las políticas básicas de INSERT.
**Impacto futuro:** org_admin no podrá autenticarse con custom claims (app_role, org_id) hasta resolver esto.
**Solución:** migrar a auth.session() o implementar JWT claims via hook de GoTrue v2.
**NO resolver hasta tener caso de uso real de org_admin autenticado.**
### CVE-2025-66478 — Next.js 15.3.3 con vulnerabilidad de seguridad
**Estado:** ABIERTO — BLOQUEANTE antes del primer dato real de empresa
**Descripción:** Next.js 15.3.3 tiene una vulnerabilidad de seguridad crítica. El build lo reporta explícitamente: "This version has a security vulnerability. Please upgrade to a patched version."
**Solución:** actualizar next en package.json a la versión parcheada disponible, correr npm install, hacer redeploy.
**Referencia:** https://nextjs.org/blog/CVE-2025-66478
---
## Deuda técnica cerrada en Sprint 3
### TECH-DEBT-002 — KPI NPS eliminado ✅
KPI NPS eliminado — instrumento no tiene pregunta NPS.
### TECH-DEBT-003 — Brecha de políticas ✅
Widget W6 redefinido como demanda-only (6.2), sin oferta. Documentado.
### TECH-DEBT-004 — Intención directa de salida no capturada
Intención directa de salida laboral no capturada en el instrumento v2. No resuelto pero documentado como gap conocido.
### TECH-DEBT-005 — A1 País vs A1b ✅
Cerrado: A1 ya es Departamento (18 opciones Paraguay) en el instrumento final.

81
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.3", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
@@ -895,15 +895,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.3.3", "version": "15.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz",
"integrity": "sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==", "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==", "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -917,9 +917,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==", "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -933,9 +933,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==", "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -952,9 +952,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==", "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -971,9 +971,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==", "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -990,9 +990,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==", "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1009,9 +1009,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==", "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1025,9 +1025,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==", "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2808,13 +2808,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.3.3", "version": "15.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz",
"integrity": "sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==", "integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.3.3", "@next/env": "15.3.8",
"@swc/counter": "0.1.3", "@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"busboy": "1.6.0", "busboy": "1.6.0",
@@ -2829,14 +2828,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.3.3", "@next/swc-darwin-arm64": "15.3.5",
"@next/swc-darwin-x64": "15.3.3", "@next/swc-darwin-x64": "15.3.5",
"@next/swc-linux-arm64-gnu": "15.3.3", "@next/swc-linux-arm64-gnu": "15.3.5",
"@next/swc-linux-arm64-musl": "15.3.3", "@next/swc-linux-arm64-musl": "15.3.5",
"@next/swc-linux-x64-gnu": "15.3.3", "@next/swc-linux-x64-gnu": "15.3.5",
"@next/swc-linux-x64-musl": "15.3.3", "@next/swc-linux-x64-musl": "15.3.5",
"@next/swc-win32-arm64-msvc": "15.3.3", "@next/swc-win32-arm64-msvc": "15.3.5",
"@next/swc-win32-x64-msvc": "15.3.3", "@next/swc-win32-x64-msvc": "15.3.5",
"sharp": "^0.34.1" "sharp": "^0.34.1"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.3", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",

View File

@@ -0,0 +1,189 @@
# ETAPA 3C — Texto de consentimiento + dos nuevas encuestas piloto
## 1. Objetivo
Dos cambios independientes:
1. Actualizar el texto de la pantalla de consentimiento para usar el nombre de empresa
dinámico desde la DB y el nuevo wording aprobado.
2. Insertar en el seed dos nuevas organizaciones y dos nuevos surveys (InQuality y
Fundación Solidaridad) como encuestas de espacio de pruebas reales.
---
## 2. Contexto previo
- El nombre de empresa se lee de `surveys.brand_config.org_name` (JSONB), no de
`organizations.name`. El formulario ya tiene acceso a ese valor via `brand_config`.
- El survey actual (`30000000-...-0001`) tiene `brand_config.org_name = "InQuality"`.
- El componente que muestra la pantalla de consentimiento es `ConsentScreen`,
llamado desde `survey-form.tsx` con `brandConfig={survey.brand_config}`.
- Las dos nuevas encuestas son para uso real en espacio de pruebas — los encuestados
serán notificados cuando la plataforma madure.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Organizaciones separadas para cada encuesta — cada una con su propio `org_id`.
- Los surveys nuevos tienen `status='live'` para que sean accesibles por UUID.
- El survey demo existente (`30000000-...-0001`) NO se toca — sigue funcionando.
- Los nuevos UUIDs siguen el patrón del seed: `10000000-...-0002` para org InQuality
real, `10000000-...-0003` para Fundación Solidaridad, y surveys `30000000-...-0002`
y `30000000-...-0003` respectivamente.
- `brand_config.org_name` es el campo que alimenta el texto dinámico del consentimiento.
---
## 4. Cambio 1 — Texto del consentimiento
### 4.1 Texto actual (aproximado)
```
Diagnóstico interno de [org_name]: tus respuestas ayudan a la empresa a entender
la realidad de las personas que cuidan a familiares con discapacidad o adultos
mayores, para diseñar apoyos más adecuados. Solo se trabaja con datos agregados
— nunca accede a respuestas individuales.
Estudio país (opcional): tus respuestas anonimizadas contribuyen a un mapeo nacional
sobre la economía del cuidado en Paraguay. Los datos se procesan de forma
irreversiblemente desvinculada de cualquier empresa o persona.
```
### 4.2 Texto nuevo
```
Diagnóstico interno de [ORG_NAME]: Tus respuestas ayudan a la empresa, en alianza
con Fundación Solidaridad, a entender la realidad de las personas que cuidan a
familiares con discapacidad o adultos mayores, para diseñar apoyos más adecuados.
Solo se trabaja con datos agregados — nunca accede a respuestas individuales.
Estudio país (opcional): Tus respuestas serán anónimas y contribuyen a un mapeo
nacional sobre la economía del cuidado en Paraguay. Los datos se procesan de manera
que no pueden asociarse a ninguna empresa ni persona.
```
`[ORG_NAME]` = `brand_config.org_name` — ya disponible en el componente.
Las palabras en negrita son: el nombre de la empresa, "datos agregados",
"anónimas". Mantener el mismo patrón de negritas que el texto actual.
### 4.3 Archivo a modificar
Buscar en `ConsentScreen` o donde esté el JSX que renderiza estos dos párrafos.
Solo cambiar el texto — no la estructura ni el estilo.
---
## 5. Cambio 2 — Dos nuevas organizaciones y surveys en seed.sql
### 5.1 Organización InQuality
```sql
INSERT INTO public.organizations (id, name, sector, size_bucket)
VALUES (
'10000000-0000-0000-0000-000000000002',
'InQuality',
'Consultoría/Tecnología',
'1-50'
) ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
sector = EXCLUDED.sector,
size_bucket = EXCLUDED.size_bucket;
```
### 5.2 Organización Fundación Solidaridad
```sql
INSERT INTO public.organizations (id, name, sector, size_bucket)
VALUES (
'10000000-0000-0000-0000-000000000003',
'Fundación Solidaridad',
'Sociedad Civil/ONG',
'1-50'
) ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
sector = EXCLUDED.sector,
size_bucket = EXCLUDED.size_bucket;
```
### 5.3 Survey InQuality
```sql
INSERT INTO public.surveys (
id, org_id, title, status, is_anonymous,
data_sensitivity, security_class, controller_role,
consent_version_id, brand_config, k_threshold
) VALUES (
'30000000-0000-0000-0000-000000000002',
'10000000-0000-0000-0000-000000000002',
'Mapeo de Cuidadores — 2026',
'live',
true,
'sensible',
'confidencial',
'A',
'20000000-0000-0000-0000-000000000001',
'{"org_name": "InQuality", "branch_name": "Equipo InQuality", "branch_city": "Asunción"}',
5
) ON CONFLICT (id) DO UPDATE SET
brand_config = EXCLUDED.brand_config,
status = EXCLUDED.status;
```
### 5.4 Survey Fundación Solidaridad
```sql
INSERT INTO public.surveys (
id, org_id, title, status, is_anonymous,
data_sensitivity, security_class, controller_role,
consent_version_id, brand_config, k_threshold
) VALUES (
'30000000-0000-0000-0000-000000000003',
'10000000-0000-0000-0000-000000000003',
'Mapeo de Cuidadores — 2026',
'live',
true,
'sensible',
'confidencial',
'A',
'20000000-0000-0000-0000-000000000001',
'{"org_name": "Fundación Solidaridad", "branch_name": "Equipo Fundación", "branch_city": "Asunción"}',
5
) ON CONFLICT (id) DO UPDATE SET
brand_config = EXCLUDED.brand_config,
status = EXCLUDED.status;
```
### 5.5 Notas sobre el seed
- Usar `ON CONFLICT (id) DO UPDATE` para que el seed sea idempotente.
- El `consent_version_id` referencia el consentimiento existente
(`20000000-0000-0000-0000-000000000001`) — mismo texto para ambas.
- Las URLs resultantes serán:
- InQuality: `?s=30000000-0000-0000-0000-000000000002`
- Fundación Solidaridad: `?s=30000000-0000-0000-0000-000000000003`
---
## 6. Entregables (DoD)
- [ ] Texto del consentimiento actualizado en el componente con el nuevo wording.
- [ ] `[ORG_NAME]` dinámico desde `brand_config.org_name`.
- [ ] Negritas correctas: nombre de empresa, "datos agregados", "anónimas".
- [ ] Dos nuevas organizaciones en `seed.sql` con `ON CONFLICT`.
- [ ] Dos nuevos surveys en `seed.sql` con `ON CONFLICT`.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 7. Qué reportar
```
Verificado:
- Texto de consentimiento actualizado ✓
- org_name dinámico ✓ (valor en demo: "InQuality" o "Empresa Demo")
- Seed actualizado con 2 orgs + 2 surveys ✓
- ON CONFLICT en todos los nuevos INSERTs ✓
- Build exitoso ✓
- Diff en scope ✓
URLs de los nuevos surveys:
- InQuality: ?s=30000000-0000-0000-0000-000000000002
- Fundación Solidaridad: ?s=30000000-0000-0000-0000-000000000003
Asumido:
- [decisiones de implementación]
```
## 8. Qué NO hacer
- ❌ No tocar el survey demo existente (30000000-...-0001).
- ❌ No cambiar la estructura de `brand_config` — solo el texto del componente.
- ❌ No re-seedear la DB de producción — solo actualizar seed.sql.
- ❌ No cambiar estilos ni layout del ConsentScreen.
- ❌ No tocar dashboard ni RPCs.

View File

@@ -0,0 +1,174 @@
# ETAPA 3I2 — Revisión del instrumento v2.1 (feedback equipo médico)
## 1. Objetivo
Aplicar 10 cambios al instrumento "Mapeo de Cuidadores" resultado del feedback del equipo
de salud. Los cambios afectan SEED_SPEC.md, seed.sql, y el código del formulario Next.js.
---
## 2. Contexto previo
- Instrumento actual: v2, 44 preguntas, 8 secciones, en `SEED_SPEC.md`.
- La DB tiene al menos 1 respuesta real. Algunos cambios son breaking para datos existentes
(opciones renombradas, tipo cambiado) — documentar en OBSERVATIONS.md, no bloquear.
- El formulario Next.js usa un sistema de conditional_rules en el JSON de cada pregunta.
- Los 3 triggers (1.1, 1.6, 1.13) controlan bloques condicionales enteros.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Cambio 4: el salto a 6.4 ocurre cuando las TRES preguntas 1.1, 1.6, 1.13 dan "No"
simultáneamente. Si al menos una da "Sí", las secciones 2-5 y 6.1-6.3 se muestran normalmente.
- Cambio 4: las secciones 2, 3, 4, 5, 6.1, 6.2, 6.3 se saltean completamente — ir directo a 6.4.
- Cambio 5: 2.1 pasa a multiple_choice. Es breaking para datos existentes — documentar, no bloquear.
- Cambio 6: 2.4 se elimina del seed. Si hay respuestas con question_id de 2.4, se pierden
al re-seedear. Documentar en OBSERVATIONS.md.
- Cambio 4: pregunta 6.6 opciones: Sí · No · Tal vez.
- Cambio 3: "Tecnología asistiva" va en penúltima posición (antes de "No utilizo"/"No utiliza").
- "Prefiero no decir" se elimina de TODAS las preguntas que lo tengan.
---
## 4. Los 10 cambios — especificación exacta
### Cambio 1 — Eliminar "Prefiero no decir" de todo el instrumento
Preguntas afectadas: A5, A6, 1.1, 1.5, 1.6, 1.13, 2.5, 3.1, 3.4.
En cada una, eliminar la opción "Prefiero no decir" del array de opciones en seed.sql.
En SEED_SPEC.md, eliminarla de la columna Opciones de cada pregunta.
⚠️ Los triggers 1.1, 1.6, 1.13 quedan con solo 2 opciones: Sí · No.
La lógica condicional no cambia — solo "Sí" dispara el bloque.
### Cambio 2 — Subtítulo hint en todas las preguntas multiple_choice
En el componente del formulario que renderiza preguntas de tipo `multiple_choice`,
agregar un subtítulo/hint debajo del prompt: "Podés marcar más de una opción".
Preguntas afectadas: 1.2, 1.3, 1.7, 1.8, 1.9, 1.10, 1.15, 2.3, 3.2, 3.3, 4.2, 6.2.
Es un cambio de UI — no afecta seed.sql ni SEED_SPEC.md.
### Cambio 3 — Agregar "Tecnología asistiva" en 1.3 y 1.10
1.3 opciones actuales: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utilizo
1.3 opciones nuevas: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utilizo
1.10 opciones actuales: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utiliza
1.10 opciones nuevas: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utiliza
### Cambio 4 — Salto condicional triple + nueva pregunta 6.6
#### 4a. Lógica de salto
Cuando las tres responden "No": 1.1="No" AND 1.6="No" AND 1.13="No",
las secciones 2, 3, 4, 5 completas + preguntas 6.1, 6.2, 6.3 se ocultan.
El formulario salta directamente a 6.4.
Implementación sugerida: agregar una regla condicional en las preguntas de las secciones
2-5 y 6.1-6.3 que las oculte cuando se cumple esa condición triple. La forma exacta de
implementarlo (conditional_rules en cada pregunta vs. lógica en el componente de sección)
la decide Claude Code según la arquitectura existente del formulario.
#### 4b. Nueva pregunta 6.6
Agregar al final de la Sección 6 (después de 6.5, antes de Sección 7):
- Code: 6.6
- Tipo: single_choice
- Pregunta: "¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo
a personas con discapacidad y/o cuidadores?"
- Opciones: Sí · No · Tal vez
- is_sensitive: false
- top-level (siempre visible)
Actualizar position de 7.1 para que quede después de 6.6.
Actualizar el conteo total de preguntas en SEED_SPEC.md (pasa de 44 a 45, o 44 si se
compensa con la eliminación de 2.4 — verificar el conteo final).
### Cambio 5 — 2.1 pasa a multiple_choice
Cambiar tipo de `single_choice` a `multiple_choice` en seed.sql y SEED_SPEC.md.
Agregar nota en SEED_SPEC.md:
"⚠️ TECH-DEBT: múltiple selección sin orden de prioridad. Deuda: implementar ranking
(Principal cuidador soy yo > secundario instituto > principal pagado > secundario familiar)."
### Cambio 6 — Eliminar 2.4
Eliminar la pregunta 2.4 ("¿Contás con apoyo de otras personas para el cuidado?")
del seed.sql y SEED_SPEC.md.
Renumerar positions de las preguntas posteriores si es necesario.
Documentar en OBSERVATIONS.md: "2.4 eliminada por feedback equipo médico (similar a 2.1)."
### Cambio 7 — En 3.1 eliminar "Sin gastos significativos"
El cambio 1 ya elimina "Prefiero no decir" de 3.1.
Adicionalmente, eliminar "Sin gastos significativos" de las opciones de 3.1.
3.1 opciones finales: Menos de Gs. 500.000 · Gs. 500.0001.500.000 · Gs. 1.500.0003.000.000
· Gs. 3.000.0006.000.000 · Más de Gs. 6.000.000
### Cambio 8 — En 3.3 cambiar texto de opción
Cambiar: "Es difícil sostenerlo con mis ingresos"
Por: "No puedo sostenerlo con mis ingresos"
En seed.sql y SEED_SPEC.md.
Documentar en OBSERVATIONS.md: "Respuestas existentes con el texto anterior quedan
con valor 'Es difícil sostenerlo con mis ingresos' — breaking change en datos existentes."
### Cambio 9 — En 3.4 reestructurar primera franja salarial
Opción actual: "Sueldo mínimo Gs. 5.000.000"
Reemplazar por dos opciones:
1. "Sueldo mínimo"
2. "Gs. 3.000.000 a 5.000.000"
Las demás franjas no cambian.
Documentar en OBSERVATIONS.md: "Respuestas existentes con 'Sueldo mínimo Gs. 5.000.000'
quedan con valor anterior — breaking change en datos existentes."
### Cambio 10 — En 6.1 cambiar texto de última opción
Cambiar: "No, nunca se ha hablado del tema"
Por: "No, no se puede hablar del tema"
En seed.sql y SEED_SPEC.md.
Documentar en OBSERVATIONS.md: "Breaking change en datos existentes para esta opción."
---
## 5. Archivos a modificar
| Archivo | Cambios |
|---|---|
| `SEED_SPEC.md` | Todos los cambios de opciones, tipos, preguntas nuevas/eliminadas |
| `supabase/seed.sql` | Todos los cambios — es la fuente de verdad para re-seedear |
| Componente formulario (multiple_choice) | Cambio 2: agregar hint |
| Lógica condicional del formulario | Cambio 4a: salto triple |
| `OBSERVATIONS.md` o `TECH_DEBT.md` | Documentar breaking changes y nueva deuda |
---
## 6. Entregables (DoD)
- [ ] SEED_SPEC.md actualizado con todos los cambios.
- [ ] seed.sql actualizado — re-seedeable desde cero produce el instrumento v2.1.
- [ ] Componente multiple_choice muestra hint "Podés marcar más de una opción".
- [ ] Lógica de salto 1.1+1.6+1.13 todos "No" → salta a 6.4.
- [ ] Pregunta 6.6 visible en el formulario.
- [ ] "Prefiero no decir" ausente en todas las preguntas.
- [ ] 2.4 eliminada del seed.
- [ ] Breaking changes documentados en OBSERVATIONS.md.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 7. Qué reportar
```
Verificado:
- SEED_SPEC.md actualizado ✓
- seed.sql actualizado ✓
- Conteo final de preguntas: [N]
- Hint en multiple_choice: ✓
- Salto triple implementado: ✓ (describir cómo)
- 6.6 agregada ✓
- "Prefiero no decir" eliminado de: [lista de preguntas]
- 2.4 eliminada ✓
- Build exitoso ✓
- Diff en scope ✓
Breaking changes documentados en OBSERVATIONS.md:
- [lista]
Asumido:
- [decisiones de implementación]
```
## 8. Qué NO hacer
- ❌ No re-seedear la DB de producción — solo actualizar seed.sql.
Re-seedear producción requiere aprobación explícita del director.
- ❌ No cambiar la estructura de tablas ni agregar columnas — solo opciones y lógica.
- ❌ No tocar el dashboard ni las RPCs.
- ❌ No resolver ítems de TECH_DEBT que no estén listados acá.
- ❌ No cambiar el diseño visual del formulario más allá del hint de multiple_choice.

View File

@@ -0,0 +1,437 @@
# ETAPA 3I — Migración a Supabase self-hosted en Dokploy
## 1. Objetivo
Migrar de Supabase Cloud a Supabase self-hosted en Dokploy, manteniendo Cloud
operativo en paralelo durante la transición y como respaldo durante 30 días post-cierre.
**Esta etapa es operativa, no de código. Nivel 3 extendido por tocar infraestructura.**
---
## 2. Modo de ejecución — diferente a etapas anteriores
Esta etapa **NO se ejecuta de una tirada**. Son 6 pasos atómicos, cada uno con su gate
de validación. Después de cada paso, el agente reporta lo verificado y **espera
confirmación explícita del director** antes de avanzar al siguiente.
Si un paso falla, el rollback es el del propio paso (documentado). No avanzar al siguiente
hasta que el actual cierre limpio.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Supabase self-hosted **completo** (Postgres + Auth + Storage + Realtime + Studio + Kong),
no Postgres pelado. Compatibilidad 100% con código actual.
- **Partida limpia:** no se migran datos de Cloud. Se aplican las 12 migraciones del repo
sobre una DB vacía. No hay nada en Cloud que conservar (testing ya truncado en 2E).
- **Cloud y self-hosted en paralelo** durante la transición. Cloud se mantiene 30 días
post-validación como respaldo, después se elimina.
- **Studio detrás de SSH** (no expuesto a internet sin auth). API por subdominio propio
con SSL.
- **Backups locales** en el mismo VPS (diarios, retención 30 días, con restore probado).
Backup remoto queda como deuda técnica antes del primer dato real.
- **VPS objetivo:** el mismo VPS de Dokploy donde corre la app Next.js (17 GB RAM
disponibles, según notas del proyecto).
- **Dominio nuevo:** `db.inqualityhq.com` o `supabase.inqualityhq.com` para el API
(decidir cuál en el paso 4). Studio sin dominio público.
---
## 4. PASOS
### PASO 1 · Levantar Supabase self-hosted en Dokploy
**Qué hacer:**
1. Acceder a Dokploy.
2. Crear un nuevo proyecto "supabase-prod" (o nombre equivalente).
3. Usar el template oficial de Supabase self-hosted (Dokploy lo tiene como template
prefijado, o se carga el `docker-compose.yml` oficial de Supabase desde
https://supabase.com/docs/guides/self-hosting/docker).
4. Generar los secrets críticos **localmente, no usar los defaults del template:**
- `POSTGRES_PASSWORD` — generar con `openssl rand -base64 32`
- `JWT_SECRET` — generar con `openssl rand -base64 64`
- `ANON_KEY` y `SERVICE_ROLE_KEY` — generar a partir del JWT_SECRET con la
herramienta oficial: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
- `DASHBOARD_USERNAME` y `DASHBOARD_PASSWORD` — generar fuerte (16+ caracteres)
5. **NO usar las claves de ejemplo** que vienen en la documentación de Supabase. Esas
están publicadas y son inseguras.
6. Configurar las variables de entorno del stack en Dokploy.
7. Iniciar el stack.
8. Esperar que todos los contenedores estén `healthy`.
**Gate de validación (verificar TODO antes de avanzar al paso 2):**
```bash
# Mostrame el output de estos checks:
docker ps --filter "label=com.docker.compose.project=supabase-prod" \
--format "table {{.Names}}\t{{.Status}}"
# Esperado: ~8 contenedores, todos "Up (healthy)" o "Up"
# Postgres responde
docker exec supabase-db psql -U postgres -c 'SELECT version();'
# Esperado: versión de Postgres
# Kong responde
curl -s http://localhost:8000/rest/v1/ -H "apikey: <ANON_KEY>"
# Esperado: respuesta JSON (no 404, no 502)
# Studio responde localmente (todavía no por dominio)
curl -s -o /dev/null -w "%{http_code}" http://localhost:3001
# Esperado: 200 o 401 (auth requerida)
```
**Qué reportar:**
- Confirmación de los 4 checks arriba con outputs reales.
- **NUNCA pegar los secrets en el reporte.** Mostrar solo que existen, no su valor.
Ejemplo: `JWT_SECRET configurado (longitud: 64 chars) ✓`
- Recursos consumidos: `docker stats --no-stream` con los 8 contenedores.
**Rollback si falla:**
- Detener y borrar el stack en Dokploy.
- Investigar logs: `docker logs <container>` del que falle.
- Volver a empezar el paso 1.
**DETENERSE. Esperar confirmación del director antes del paso 2.**
---
### PASO 2 · Aplicar las 12 migraciones del repo
**Qué hacer:**
1. Conectarse al Postgres self-hosted desde el VPS (no desde fuera):
```bash
docker exec -it supabase-db psql -U postgres -d postgres
```
2. Verificar que la DB está vacía:
```sql
\dt public.*
-- Esperado: 0 tablas (o solo las del seed de Supabase, no las del proyecto)
```
3. Salir del psql.
4. Desde el directorio del repo del proyecto (en local del dev, no en el VPS), aplicar
las migraciones usando la CLI de Supabase apuntada a la nueva DB:
```bash
# Configurar conexión a la nueva DB self-hosted
# (la DB URL incluye el password generado en paso 1)
export SUPABASE_DB_URL="postgresql://postgres:<POSTGRES_PASSWORD>@<VPS_IP>:5432/postgres"
# O usar supabase CLI con link:
supabase link --project-ref <self-hosted-ref> --db-url $SUPABASE_DB_URL
# Aplicar migraciones
supabase db push --db-url $SUPABASE_DB_URL
```
**Alternativa si la CLI tiene problemas:** copiar el contenido de cada archivo
`supabase/migrations/*.sql` y ejecutarlo manualmente con `psql`, en orden de timestamp.
**Gate de validación:**
```sql
-- Conteo de objetos creados (esperado: 12 tablas + N funciones)
SELECT count(*) FROM pg_tables WHERE schemaname='public';
-- Esperado: ≥10 tablas (surveys, responses, answers, consent_records, questions, etc.)
SELECT count(*) FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname='public' AND p.proname LIKE 'rpc_%';
-- Esperado: 9 funciones rpc_* (las del dashboard de Etapa 2E)
-- Seed cargado
SELECT count(*) FROM public.surveys;
-- Esperado: 1 (la encuesta piloto)
SELECT count(*) FROM public.questions;
-- Esperado: 44
SELECT count(*) FROM public.question_options;
-- Esperado: ≥200 (las opciones de las preguntas single/multiple choice)
-- RLS habilitada
SELECT tablename FROM pg_tables WHERE schemaname='public'
AND rowsecurity = false;
-- Esperado: vacío (todas las tablas con RLS habilitada)
-- Test funcional: una RPC del dashboard devuelve suppressed con N=0
SELECT rpc_dashboard_kpis('30000000-0000-0000-0000-000000000001'::uuid, '{}'::jsonb);
-- Esperado: status='suppressed'
```
**Qué reportar:**
- Outputs de los 6 queries arriba.
- Si algún número está fuera de lo esperado: detener, no continuar.
**Rollback si falla:**
- `TRUNCATE` no aplica todavía (no hay nada que conservar).
- Borrar la DB y recrearla:
```bash
docker exec supabase-db psql -U postgres -c 'DROP DATABASE postgres;'
# Reiniciar el contenedor para que reaplique el init
```
- Reaplicar migraciones desde cero.
**DETENERSE. Esperar confirmación del director antes del paso 3.**
---
### PASO 3 · Backups locales con prueba de restore
**Qué hacer:**
1. Crear directorio de backups en el VPS: `mkdir -p /var/backups/supabase-prod && chmod 700 /var/backups/supabase-prod`
2. Crear script de backup `/usr/local/bin/supabase-backup.sh`:
```bash
#!/bin/bash
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/var/backups/supabase-prod/supabase_$TS.sql.gz
docker exec supabase-db pg_dump -U postgres postgres | gzip > "$OUT"
chmod 600 "$OUT"
# Retención 30 días
find /var/backups/supabase-prod -name "supabase_*.sql.gz" -mtime +30 -delete
echo "Backup OK: $OUT ($(du -h "$OUT" | cut -f1))"
```
3. `chmod +x /usr/local/bin/supabase-backup.sh`
4. Probar manualmente: ejecutarlo una vez, verificar que el archivo se crea.
5. Agregar a crontab: `0 3 * * * /usr/local/bin/supabase-backup.sh >> /var/log/supabase-backup.log 2>&1`
6. **Prueba de restore obligatoria:**
- Crear una DB temporal de prueba: `docker exec supabase-db createdb -U postgres restore_test`
- Restaurar el último backup en esa DB: `gunzip < /var/backups/supabase-prod/supabase_*.sql.gz | docker exec -i supabase-db psql -U postgres restore_test`
- Verificar que tiene los mismos objetos que la DB principal:
```sql
\c restore_test
SELECT count(*) FROM public.questions;
-- Esperado: 44 (mismo que la principal)
```
- Eliminar la DB de prueba: `docker exec supabase-db dropdb -U postgres restore_test`
**Gate de validación:**
- Script ejecutable y verificado manualmente.
- Backup más reciente: `ls -lh /var/backups/supabase-prod/` muestra al menos un archivo.
- Cron line confirmada: `crontab -l | grep supabase-backup`
- Restore probado: la DB de prueba mostró el conteo correcto antes de eliminarse.
**Qué reportar:**
- Tamaño del primer backup (informativo: nos da una idea de cuánto va a crecer).
- Confirmación de los 4 puntos del gate.
**Rollback si falla:**
- Eliminar el script y el cron.
- Re-investigar y volver a configurar.
**Deuda técnica explícita** (agregar a TECH_DEBT.md como TECH-DEBT-006):
> Backup remoto pendiente. Hoy solo local en el mismo VPS. Si el VPS muere, se pierden
> los datos. **Bloqueante antes del primer dato real de empresa.** Recomendación:
> rsync nocturno a S3-compatible (Backblaze B2 o Wasabi, ~$5/mes).
**DETENERSE. Esperar confirmación del director antes del paso 4.**
---
### PASO 4 · Dominio + SSL para API, Studio detrás de SSH
**Qué hacer:**
1. **Decidir nombre del subdominio para API.** Recomendación: `db.inqualityhq.com`.
2. Crear el registro DNS A apuntando al IP del VPS (esto se hace en el panel de DNS
del dominio, fuera de Dokploy).
3. En Dokploy, configurar el reverse proxy / Traefik:
- Ruta `db.inqualityhq.com` → contenedor `supabase-kong:8000`.
- SSL automático con Let's Encrypt.
4. Esperar a que el certificado SSL se emita (1-5 min normalmente).
5. **Bloquear Studio del exterior:**
- El contenedor `supabase-studio` (puerto 3001) NO debe tener regla de exposición
pública en Dokploy/Traefik.
- Verificar con: `curl http://<VPS_IP>:3001` desde fuera del VPS → debe **fallar**
(connection refused o timeout).
6. **Acceso a Studio vía SSH tunnel** (documentar el comando para el director):
```bash
# Desde la máquina del dev:
ssh -L 3001:localhost:3001 usuario@<VPS_IP>
# Después abrir http://localhost:3001 en el navegador
```
**Gate de validación:**
```bash
# API por dominio con SSL
curl -s https://db.inqualityhq.com/rest/v1/ -H "apikey: <ANON_KEY>"
# Esperado: respuesta JSON, no error de certificado
# SSL válido
curl -vI https://db.inqualityhq.com/ 2>&1 | grep "SSL certificate verify"
# Esperado: "SSL certificate verify ok"
# Studio NO accesible desde fuera
curl -s -o /dev/null -w "%{http_code}" http://<VPS_IP>:3001 --max-time 5
# Esperado: timeout o connection refused (no 200)
# Studio sí accesible vía túnel SSH (documentar instrucciones para el director)
```
**Qué reportar:**
- URL del API funcionando con SSL.
- Confirmación de que Studio no está expuesto.
- Comando exacto para que el director acceda a Studio por SSH tunnel.
**Rollback si falla:**
- Quitar el registro DNS.
- Quitar la regla de Traefik.
- Volver a usar IP+puerto temporalmente.
**DETENERSE. Esperar confirmación del director antes del paso 5.**
---
### PASO 5 · Cambiar variables de entorno de la app Next.js + deploy
**ESTE ES EL PASO MÁS CRÍTICO. El error más fácil de cometer es apuntar la app a la
DB nueva pero dejar alguna variable apuntando a Cloud — comportamiento mixto, datos
inconsistentes.**
**Qué hacer:**
1. Listar TODAS las variables de entorno de la app actual relacionadas con Supabase:
```
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY (server-only)
IP_HASH_SALT (independiente, no tocar)
```
Verificar que **no hay otras** referencias a Cloud en el código:
```bash
grep -rE "supabase\.co|<cloud-project-ref>" --include="*.ts" --include="*.tsx" \
--include="*.env*" .
# Esperado: vacío o solo en comentarios
```
2. Generar un snapshot de las variables ACTUALES (sin pegarlas en el reporte):
```bash
# En el dashboard de Dokploy de la app Next.js, exportar las env vars actuales
# Guardarlas en un archivo local seguro como backup
```
3. Actualizar en Dokploy las 3 variables:
- `NEXT_PUBLIC_SUPABASE_URL` → `https://db.inqualityhq.com`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` → la nueva (de paso 1)
- `SUPABASE_SERVICE_ROLE_KEY` → la nueva (de paso 1)
4. Trigger redeploy de la app Next.js.
5. Esperar que termine el build y arranque.
**Gate de validación:**
- App Next.js arranca sin errores (`docker logs` del contenedor de la app).
- Abrir `https://economia-cuidado.inqualityhq.com` — el consent screen carga.
- En la consola del navegador, verificar que las requests van a `db.inqualityhq.com`,
NO a `*.supabase.co`.
- Verificar que NO hay errores 401/403 en el Network tab del navegador.
**Qué reportar:**
- Confirmación de que la app arranca.
- Captura de Network tab mostrando requests a `db.inqualityhq.com`.
- Confirmación de que no hay errores en consola del navegador.
**Rollback si falla:**
- Volver las 3 variables a los valores anteriores (los del archivo backup del punto 2).
- Trigger redeploy.
- La app vuelve a apuntar a Cloud. **Cloud sigue vivo, así que rollback es completo.**
**DETENERSE. Esperar confirmación del director antes del paso 6.**
---
### PASO 6 · Validación funcional end-to-end + ventana de observación
**Qué hacer:**
1. Director ejecuta manualmente el flujo completo:
- Abre `https://economia-cuidado.inqualityhq.com?s=30000000-0000-0000-0000-000000000001`
- Acepta los 2 consentimientos.
- Completa el formulario hasta el final.
- Envía.
- Verifica que llegó al "gracias por participar" o equivalente.
2. Agente verifica en la nueva DB que se persistió:
```sql
-- Conectado a la self-hosted DB
SELECT count(*) FROM responses WHERE survey_id = '30000000-0000-0000-0000-000000000001';
-- Esperado: ≥1
SELECT count(*) FROM answers a
JOIN responses r ON a.response_id = r.id
WHERE r.survey_id = '30000000-0000-0000-0000-000000000001';
-- Esperado: ≥1 (varias filas)
SELECT count(*) FROM consent_records;
-- Esperado: ≥1
-- Verificar qi_zone se popula (Etapa 2E)
SELECT qi_zone, count(*) FROM responses GROUP BY qi_zone;
-- Esperado: al menos una fila con qi_zone NO NULL
```
3. Abrir `/dashboard?survey_id=...` y verificar que los widgets muestran suppressed
(N<5 todavía).
4. Cargar 4 respuestas más para alcanzar N=5 y verificar que al menos un widget pasa
a estado `ok` (validación de que el k-anonimato libera datos al cruzar el umbral).
5. **Ventana de observación de 24-48h:**
- Sin tráfico real (todavía no hay piloto), durante este período el director
y/o el agente monitorean los logs por errores no esperados.
6. Truncar las respuestas de prueba (igual que en Etapa 2E):
```sql
TRUNCATE answers, responses, consent_records RESTART IDENTITY CASCADE;
```
**Gate de validación final:**
- Flujo end-to-end funcionó.
- Datos llegaron a la DB correcta (self-hosted).
- qi_zone se populó (Etapa 2E está funcionando).
- Dashboard responde con datos reales (sale de suppressed al cruzar k=5).
- 24-48h sin errores en logs.
- Tablas truncadas, queda solo el seed.
**Qué reportar:**
- Capturas del flujo end-to-end completado.
- Outputs de los queries de verificación.
- Confirmación del período de observación sin errores.
- Confirmación de que las tablas se truncaron al final.
**DETENERSE. Esperar confirmación del director.**
**Recién con esta confirmación, Cloud queda como respaldo congelado por 30 días.**
---
## 5. DoD de la etapa completa
- [ ] Los 6 pasos completados con sus gates de validación.
- [ ] Director confirmó cada paso antes del siguiente.
- [ ] Supabase self-hosted operativo en Dokploy.
- [ ] App Next.js apuntando a la nueva DB.
- [ ] Backups locales diarios configurados, restore probado.
- [ ] Studio no expuesto a internet (acceso solo por SSH tunnel).
- [ ] API por subdominio con SSL.
- [ ] TECH-DEBT-006 agregado (backup remoto pendiente antes de dato real).
- [ ] Cloud sigue operativa pero NO recibe tráfico. Marcada para eliminación a 30 días.
## 6. Qué reportar al final de la etapa
```
Migración Supabase Cloud → self-hosted completada el [fecha].
Self-hosted operativo en:
- API: https://db.inqualityhq.com
- Studio: solo vía SSH tunnel
- Backups: /var/backups/supabase-prod (diarios, retención 30d, restore probado)
App Next.js:
- Apuntando a self-hosted ✓
- Flujo end-to-end validado ✓
- Sin errores en ventana de observación de [X] horas ✓
Cloud:
- Sigue activa, sin tráfico.
- Marcada para eliminación: [fecha + 30 días].
Pendiente / deuda técnica:
- TECH-DEBT-006: backup remoto antes del primer dato real.
Decisiones proactivas (Nivel 2):
- [si las hubo]
```
## 7. Qué NO hacer
- ❌ Avanzar al siguiente paso sin confirmación del director.
- ❌ Pegar secrets (passwords, keys) en reportes — solo confirmar que existen.
- ❌ Eliminar la cuenta de Supabase Cloud antes de los 30 días post-validación.
- ❌ Tocar código de la aplicación (esta etapa es 100% infraestructura).
- ❌ Usar los secrets de ejemplo de la documentación de Supabase — todos generados localmente.
- ❌ Exponer Studio a internet sin auth.
- ❌ Saltarse la prueba de restore (un backup sin restore probado no es un backup).

View File

@@ -0,0 +1,175 @@
# ETAPA 3V-B — Vista de control: tabla plana de respuestas individuales
## 1. Objetivo
Reemplazar la vista `/admin/responses` actual (una fila por submission) por una tabla plana
con **una fila por respuesta individual** (una fila por cada pregunta respondida), mostrando
todos los campos explícitamente incluyendo nulos marcados visualmente.
---
## 2. Contexto previo
- La Etapa 3V-A implementó la vista de metadatos de submissions (una fila por response).
- El director quiere ver las respuestas individuales a las preguntas, no solo los metadatos.
- El esquema relevante: `responses``answers` (via response_id) → `questions` (via question_id).
- `answers.value` es JSONB. Los tipos de preguntas existentes en el instrumento son:
- `single_choice` → value es string: `"Asunción (Capital)"`
- `multiple_choice` → value es array: `["opción1","opción2"]`
- `rating` → value es número: `3`
- `text_short` → value es string corto
- `text_long`**NUNCA mostrar** — regla no negociable (RLS + privacidad)
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Ruta: `/admin/responses` — reemplaza la página anterior completamente.
- Sin auth (herramienta interna de control).
- Sin paginación por ahora — máximo 500 filas (limit en la query).
- Ordenado por `responses.submitted_at DESC`, luego por `questions.position ASC` dentro de cada submission.
- Valores nulos mostrados con badge visual "sin dato" — fondo `--color-re-light` (#E7E6E5), texto `--color-re-dark` (#494F4F) opacidad 0.6.
- text_long excluido completamente — ni la columna ni el valor aparecen.
- Convención CSS: `.admin-*` en `app/globals.css`, igual que la etapa anterior.
- Cliente service-role inline en el Server Component, igual que en la versión anterior.
---
## 4. Estructura de la tabla
### 4.1 Encabezado de página
- Título: "Control de respuestas"
- Subtítulo: total de submissions + total de respuestas individuales + fecha/hora actualización.
- Botón "Actualizar" (`<a href="/admin/responses">`).
### 4.2 Columnas de la tabla (una fila por answer)
| Columna | Fuente | Notas |
|---|---|---|
| # submission | índice de la submission (1, 2, 3...) | mismo número para todas las filas de esa submission |
| Fecha | `responses.submitted_at` | DD/MM/YYYY HH:mm — solo en la primera fila de cada submission |
| Código | `questions.code` | A1, A2, 1.1, 1.6, etc. |
| Pregunta | `questions.prompt` | texto de la pregunta, truncado a 60 chars con "..." |
| Tipo | `questions.type` | badge con el tipo |
| Valor | `answers.value` | renderizado según tipo (ver 4.3) |
| Departamento | `responses.qi_zone` | solo en primera fila de cada submission |
| Sector | `responses.qi_sector` | solo en primera fila de cada submission |
### 4.3 Renderizado del valor según tipo
```typescript
function renderValue(value: unknown, type: string): string {
if (value === null || value === undefined) return null // → badge "sin dato"
switch(type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '') // quitar comillas del JSONB string
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value) // número, opcionalmente con ★ repetidas
case 'text_long':
return null // NUNCA mostrar — excluir de la query directamente
default:
return String(value)
}
}
```
### 4.4 Agrupación visual de submissions
Las filas de una misma submission deben estar visualmente agrupadas:
- Separador visual entre submissions (border-top más pronunciado o fondo alternado por submission).
- Fecha, Departamento y Sector solo se muestran en la primera fila de cada submission.
- Las filas siguientes de la misma submission muestran esas celdas vacías (o con colspan si es HTML puro).
---
## 5. Query recomendada
```typescript
const { data } = await supabase
.from('responses')
.select(`
id,
submitted_at,
qi_zone,
qi_sector,
qi_age_bucket,
ip_hash,
surveys ( title ),
answers (
value,
questions (
code,
prompt,
type,
position,
is_sensitive
)
)
`)
.order('submitted_at', { ascending: false })
.limit(500)
```
Después en el componente, aplanar el resultado:
```typescript
// Para cada response, para cada answer (excluir text_long e is_sensitive):
// generar una fila con los datos de la response + los datos de la answer/question
const rows = responses.flatMap((response, responseIdx) =>
response.answers
.filter(a => a.questions?.type !== 'text_long' && !a.questions?.is_sensitive)
.sort((a, b) => (a.questions?.position ?? 0) - (b.questions?.position ?? 0))
.map((answer, answerIdx) => ({
responseIdx: responseIdx + 1,
isFirstInGroup: answerIdx === 0,
submitted_at: response.submitted_at,
qi_zone: response.qi_zone,
qi_sector: response.qi_sector,
ip_hash: response.ip_hash,
survey_title: response.surveys?.title,
code: answer.questions?.code,
prompt: answer.questions?.prompt,
type: answer.questions?.type,
value: answer.value,
}))
)
```
---
## 6. Entregables (DoD)
- [ ] Ruta `/admin/responses` renderiza con tabla plana (HTTP 200).
- [ ] Una fila por answer, agrupadas visualmente por submission.
- [ ] Columnas: # submission, Fecha, Código, Pregunta (truncada), Tipo, Valor, Departamento, Sector.
- [ ] `text_long` e `is_sensitive=true` excluidos.
- [ ] Valores nulos muestran badge "sin dato" con estilo tenue.
- [ ] Fecha, Departamento, Sector solo en primera fila de cada grupo.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` solo en `app/admin/` y `app/globals.css`.
## 7. Qué reportar
```
Verificado:
- Ruta /admin/responses accesible ✓
- Total submissions: [N] · Total filas en tabla: [N]
- Tipos de preguntas visibles: [lista]
- text_long excluido ✓ / is_sensitive excluido ✓
- Badge "sin dato" visible en campos nulos ✓ / no hay nulos en los datos actuales
- Build exitoso ✓
- Diff en scope ✓
Asumido:
- [decisiones cosméticas]
```
## 8. Qué NO hacer
- ❌ No mostrar text_long ni is_sensitive bajo ningún concepto.
- ❌ No exponer SUPABASE_SERVICE_ROLE_KEY al cliente.
- ❌ No tocar el formulario ni el dashboard.
- ❌ No agregar dependencias npm.
- ❌ No paginar todavía — limit 500 es suficiente para el piloto.

View File

@@ -0,0 +1,133 @@
# ETAPA 3V — Vista de control: listado de respuestas
## 1. Objetivo
Crear una página de control operativo en `/admin/responses` que muestre todas las
respuestas recibidas, ordenadas por fecha descendente (más recientes primero).
Es una herramienta interna de diagnóstico — sin auth, sin PII, sin datos sensibles.
---
## 2. Contexto previo
- La migración está en Supabase self-hosted.
- Las tablas relevantes son `responses`, `answers`, `questions`, `surveys`.
- `answers.value` es JSONB — contiene el valor de la respuesta.
- Campos sensibles protegidos por RLS: `answers` donde `question.is_sensitive=true`
y los tipos `text_long`. No mostrar esos campos aunque lleguen.
- La página usa el cliente server-side (`SUPABASE_SERVICE_ROLE_KEY`) porque bypasa RLS
y permite ver todos los datos. No usar el cliente anon.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Ruta: `/admin/responses` — Server Component de Next.js (no client component).
- Sin auth (es herramienta interna de control).
- Sin paginación por ahora — mostrar las últimas 200 respuestas máximo.
- Ordenado por `submitted_at DESC`.
- HTML semántico limpio, paleta RE existente (`#43BBC8`, `#494F4F`, `#F8AD13`).
- No usar componentes del dashboard existente — página standalone.
---
## 4. Qué mostrar
### 4.1 Encabezado de página
- Título: "Control de respuestas"
- Subtítulo: conteo total de respuestas en la base + fecha/hora de última actualización.
- Botón "Actualizar" que recarga la página (simple `<a href="/admin/responses">`).
### 4.2 Tabla de respuestas (una fila por `response`)
Columnas:
| Columna | Fuente | Notas |
|---|---|---|
| # | índice visual (1, 2, 3...) | más reciente = 1 |
| Fecha | `responses.submitted_at` | formato: DD/MM/YYYY HH:mm |
| Encuesta | `surveys.title` | JOIN con surveys |
| Departamento | `responses.qi_zone` | puede ser null → mostrar "—" |
| Sector | `responses.qi_sector` | puede ser null → mostrar "—" |
| Rango etario | `responses.qi_age_bucket` | puede ser null → mostrar "—" |
| Respuestas | count de `answers` para esa response | número simple |
| IP hash | `responses.ip_hash` | primeros 8 chars + "..." — solo para dedup |
### 4.3 Detalle expandible (opcional, Nivel 2 — reportar si se implementa)
Si el tiempo lo permite: al hacer click en una fila, mostrar un panel con las
respuestas individuales (código de pregunta + valor), excluyendo:
- Preguntas con `is_sensitive = true`
- Preguntas de tipo `text_long`
Si no se implementa el detalle, la tabla sola es suficiente para cerrar la etapa.
---
## 5. Implementación
### 5.1 Query recomendada
```typescript
// En un Server Component — usar el cliente con service_role
const { data: responses } = await supabase
.from('responses')
.select(`
id,
submitted_at,
qi_zone,
qi_sector,
qi_age_bucket,
ip_hash,
surveys ( title ),
answers ( count )
`)
.order('submitted_at', { ascending: false })
.limit(200)
```
### 5.2 Cliente server-side
Usar `createClient` con `SUPABASE_SERVICE_ROLE_KEY` (server-side only, no exponer
al cliente). Si existe `lib/supabase-server.ts` o similar, usarlo. Si no, crear
el cliente inline en el Server Component con la service role key.
### 5.3 Estilos
- Paleta RE: teal `#43BBC8`, dark `#494F4F`, orange `#F8AD13`, light `#E7E6E5`.
- Tabla con filas alternadas (zebra), hover sutil.
- Header de tabla sticky si la lista es larga.
- Responsive — en mobile, las columnas menos importantes se ocultan.
- Fuente: la que ya usa el proyecto (IBM Plex Sans o la que esté configurada).
- NO usar Tailwind si el proyecto no lo usa — verificar primero con
`grep "tailwind" package.json`.
---
## 6. Entregables (DoD)
- [ ] Ruta `/admin/responses` accesible y renderiza.
- [ ] Tabla muestra respuestas ordenadas por fecha descendente.
- [ ] Conteo total en el encabezado.
- [ ] Columnas: #, Fecha, Encuesta, Departamento, Sector, Rango etario, Respuestas, IP hash (truncado).
- [ ] Campos nulos muestran "—".
- [ ] IP hash truncada (primeros 8 chars).
- [ ] Build sin errores (`next build`).
- [ ] `git diff --name-only HEAD` solo en `app/admin/` y posiblemente `lib/`.
## 7. Qué reportar
```
Verificado:
- Ruta /admin/responses accesible ✓
- Columnas presentes: [lista]
- Conteo total: [número]
- Build exitoso ✓
- Diff en scope ✓
Asumido:
- [decisiones cosméticas o de implementación]
Decisiones proactivas (Nivel 2):
- [si implementó el detalle expandible o no]
```
## 8. Qué NO hacer
- ❌ No agregar auth — es herramienta interna, la URL es suficiente como "seguridad por oscuridad" por ahora.
- ❌ No mostrar campos text_long ni is_sensitive=true.
- ❌ No exponer SUPABASE_SERVICE_ROLE_KEY al cliente (solo server-side).
- ❌ No tocar el formulario ni el dashboard existente.
- ❌ No agregar dependencias npm.
- ❌ No resolver ítems del backlog que no sean los listados acá.

View File

@@ -0,0 +1,124 @@
# ETAPA 3ALL — Flag p_all en el dashboard (vista agregada de todos los surveys)
## 1. Objetivo
Permitir que el dashboard muestre datos de TODOS los surveys mezclados, usando un
UUID especial como flag. Cambio mínimo — solo toca `_dash_responses` y el frontend.
Las 11 RPCs no se modifican.
---
## 2. Contexto previo
- `_dash_responses(p_survey_id uuid, ...)` es la función base que usan las 11 RPCs.
- Todas las RPCs pasan `p_survey_id` a `_dash_responses`.
- El dashboard lee `?survey_id=UUID` de la URL y lo pasa a `/api/dashboard/[widget]`.
- `route.ts` requiere `survey_id` en la query string (400 si falta).
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- UUID especial para "todos": `00000000-0000-0000-0000-000000000000` (UUID cero).
No puede existir como survey real. Es el flag `p_all`.
- Implementación en `_dash_responses`: una sola línea cambia.
- Las 11 RPCs NO se tocan — el cambio es transparente para ellas.
- El cambio va en una NUEVA migración, no modifica las existentes.
- El k-anonimato sigue aplicando — el pool más grande solo beneficia la protección.
---
## 4. Cambios requeridos
### 4.1 Nueva migración SQL
Crear `supabase/migrations/20260611000001_dash_responses_all.sql`:
```sql
-- Migración: agregar flag p_all a _dash_responses
-- UUID especial '00000000-0000-0000-0000-000000000000' = todos los surveys
CREATE OR REPLACE FUNCTION public._dash_responses(
p_survey_id uuid,
p_depto text DEFAULT NULL,
p_genero text[] DEFAULT NULL,
p_etario text[] DEFAULT NULL,
p_modalidad text[] DEFAULT NULL
)
RETURNS TABLE(response_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT r.id
FROM public.responses r
WHERE (p_survey_id = '00000000-0000-0000-0000-000000000000'
OR r.survey_id = p_survey_id)
AND (p_depto IS NULL OR r.qi_zone = p_depto)
AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A6'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;
```
### 4.2 route.ts — permitir el UUID especial
En `app/api/dashboard/[widget]/route.ts`, donde se valida `survey_id`:
El UUID cero debe pasar la validación existente — verificar si hay algún check
adicional que lo rechazaría (ej. validación de formato UUID o lookup en DB).
Si `route.ts` solo verifica presencia del parámetro (400 si falta), no hay
nada que cambiar. Reportar cuál es el caso.
### 4.3 dashboard/page.tsx — agregar link/selector "Todos"
Agregar en el header del dashboard un selector o links de navegación mínimo:
```
[ Todos ] [ InQuality ] [ Fundación Solidaridad ] [ Demo ]
```
Implementación sugerida: links `<a>` simples que navegan a:
- Todos: `/dashboard?survey_id=00000000-0000-0000-0000-000000000000`
- InQuality: `/dashboard?survey_id=30000000-0000-0000-0000-000000000002`
- Fundación Solidaridad: `/dashboard?survey_id=30000000-0000-0000-0000-000000000003`
- Demo: `/dashboard?survey_id=30000000-0000-0000-0000-000000000001`
El survey activo se destaca visualmente (comparar `surveyId` del searchParam
con el UUID de cada link). Estilo `.admin-*` no aplica acá — usar clases
`.db-*` o tokens CSS existentes del dashboard.
El label de cada opción puede ser hardcodeado por ahora — no hacer fetch
a `surveys` para obtener los nombres (Nivel 3, fuera de scope).
---
## 5. Entregables (DoD)
- [ ] Nueva migración `20260611000001_dash_responses_all.sql` creada.
- [ ] `_dash_responses` actualizada con el flag de UUID cero.
- [ ] `route.ts` permite el UUID especial (verificar y reportar).
- [ ] Dashboard muestra selector de empresa con "Todos" + las 3 empresas.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 6. Qué reportar
```
Verificado:
- Migración creada ✓
- route.ts: [necesitó cambio / no necesitó cambio — explicar]
- Selector visible en dashboard ✓
- Build exitoso ✓
- Diff en scope ✓
URL de validación:
- Todos: /dashboard?survey_id=00000000-0000-0000-0000-000000000000
- InQuality: /dashboard?survey_id=30000000-0000-0000-0000-000000000002
```
## 7. Qué NO hacer
- ❌ No modificar las migraciones existentes — solo agregar una nueva.
- ❌ No tocar las 11 RPCs directamente.
- ❌ No hacer fetch a `surveys` para obtener nombres de empresa.
- ❌ No agregar dependencias npm.
- ❌ No tocar el formulario ni `/admin/responses`.

View File

@@ -0,0 +1,33 @@
-- Migración: agregar flag p_all a _dash_responses
-- UUID especial '00000000-0000-0000-0000-000000000000' = todos los surveys
CREATE OR REPLACE FUNCTION public._dash_responses(
p_survey_id uuid,
p_depto text DEFAULT NULL,
p_genero text[] DEFAULT NULL,
p_etario text[] DEFAULT NULL,
p_modalidad text[] DEFAULT NULL
)
RETURNS TABLE(response_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT r.id
FROM public.responses r
WHERE (p_survey_id = '00000000-0000-0000-0000-000000000000'
OR r.survey_id = p_survey_id)
AND (p_depto IS NULL OR r.qi_zone = p_depto)
AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A6'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;

File diff suppressed because it is too large Load Diff