From bf963e8894f2c6b36ed2ef49016e0f8437b1b1ca Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Wed, 10 Jun 2026 08:59:50 -0300 Subject: [PATCH] Con el listado de encuestas cargadas/respondidas --- .env.example | 2 + INSTALL_SUPABASE.md | 384 +++++++++++++++++++ app/admin/responses/page.tsx | 126 +++++++ app/globals.css | 37 ++ sprints/sprint-3-infra/ETAPA_3I_PROMPT.md | 437 ++++++++++++++++++++++ sprints/sprint-3-infra/ETAPA_3V_PROMPT.md | 133 +++++++ 6 files changed, 1119 insertions(+) create mode 100644 INSTALL_SUPABASE.md create mode 100644 app/admin/responses/page.tsx create mode 100644 sprints/sprint-3-infra/ETAPA_3I_PROMPT.md create mode 100644 sprints/sprint-3-infra/ETAPA_3V_PROMPT.md diff --git a/.env.example b/.env.example index 0872df1..76c604d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # 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 +# Usada por app/admin/responses (Server Component) para listar respuestas sin RLS +SUPABASE_SERVICE_ROLE_KEY= # IP hash — variable server-only (NUNCA NEXT_PUBLIC_) # Usada para deduplicación anónima. Generá un string aleatorio largo. diff --git a/INSTALL_SUPABASE.md b/INSTALL_SUPABASE.md new file mode 100644 index 0000000..1b94e9c --- /dev/null +++ b/INSTALL_SUPABASE.md @@ -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= +POSTGRES_HOST=db +POSTGRES_DB=postgres +POSTGRES_PORT=5432 +JWT_SECRET= +JWT_EXPIRY=3600 +ANON_KEY= +SERVICE_ROLE_KEY= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_KEY= +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD= +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 +SECRET_KEY_BASE= +VAULT_ENC_KEY= +PG_META_CRYPTO_KEY= +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: " | 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@ "mkdir -p ~/migrations" +scp -r supabase/migrations/ administrator@:~/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= +SUPABASE_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 \ No newline at end of file diff --git a/app/admin/responses/page.tsx b/app/admin/responses/page.tsx new file mode 100644 index 0000000..6f64b09 --- /dev/null +++ b/app/admin/responses/page.tsx @@ -0,0 +1,126 @@ +import { createClient } from '@supabase/supabase-js' + +// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V). +// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente. +export const dynamic = 'force-dynamic' + +const MAX_ROWS = 200 + +interface ResponseRow { + id: string + submitted_at: string + qi_zone: string | null + qi_sector: string | null + qi_age_bucket: string | null + ip_hash: string | null + surveys: { title: string } | { title: string }[] | null + answers: { count: number }[] +} + +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 dash(value: string | null): string { + return value ?? '—' +} + +export default async function AdminResponsesPage() { + const supabase = getSupabaseAdmin() + + const { data, error } = 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(MAX_ROWS) + + if (error) { + return ( +
+

Control de respuestas

+

No fue posible conectarse a la base de datos.

+
{error.message}
+
+ ) + } + + const { count: totalCount } = await supabase + .from('responses') + .select('*', { count: 'exact', head: true }) + + const rows = (data ?? []) as unknown as ResponseRow[] + + return ( +
+
+
+

Control de respuestas

+

+ {totalCount ?? 0} respuestas en total · últimas {rows.length} · + actualizado {formatDate(new Date().toISOString())} +

+
+ Actualizar +
+ +
+ + + + + + + + + + + + + + + {rows.map((row, i) => { + const survey = Array.isArray(row.surveys) ? row.surveys[0] : row.surveys + const answerCount = row.answers?.[0]?.count ?? 0 + return ( + + + + + + + + + + + ) + })} + +
#FechaEncuestaDepartamentoSectorRango etarioRespuestasIP hash
{i + 1}{formatDate(row.submitted_at)}{survey?.title ?? '—'}{dash(row.qi_zone)}{dash(row.qi_sector)}{dash(row.qi_age_bucket)}{answerCount} + {row.ip_hash ? `${row.ip_hash.slice(0, 8)}...` : '—'} +
+ {rows.length === 0 && ( +

Sin respuestas registradas todavía.

+ )} +
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 84f9ff8..2f7cafb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -742,11 +742,48 @@ body { } .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-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: 10px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); } +.admin-table tbody tr:nth-child(even) { background: var(--color-bg); } +.admin-table tbody tr:hover { background: var(--color-primary-light); } +.admin-table tbody tr:last-child td { border-bottom: none; } +.admin-iphash { font-family: 'Courier New', monospace; color: var(--color-text-muted); } +.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 ── */ @media (max-width: 768px) { .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-page { flex-direction: column; } + .admin-col-optional { display: none; } } /* ── Reduced motion ── */ diff --git a/sprints/sprint-3-infra/ETAPA_3I_PROMPT.md b/sprints/sprint-3-infra/ETAPA_3I_PROMPT.md new file mode 100644 index 0000000..7e12a65 --- /dev/null +++ b/sprints/sprint-3-infra/ETAPA_3I_PROMPT.md @@ -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: " +# 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 ` 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:@:5432/postgres" + + # O usar supabase CLI con link: + supabase link --project-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://: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@ + # 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: " +# 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://: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|" --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). \ No newline at end of file diff --git a/sprints/sprint-3-infra/ETAPA_3V_PROMPT.md b/sprints/sprint-3-infra/ETAPA_3V_PROMPT.md new file mode 100644 index 0000000..8926011 --- /dev/null +++ b/sprints/sprint-3-infra/ETAPA_3V_PROMPT.md @@ -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 ``). + +### 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á. \ No newline at end of file