Con el listado de encuestas cargadas/respondidas
This commit is contained in:
@@ -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
|
||||
# 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.
|
||||
|
||||
384
INSTALL_SUPABASE.md
Normal file
384
INSTALL_SUPABASE.md
Normal 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
|
||||
126
app/admin/responses/page.tsx
Normal file
126
app/admin/responses/page.tsx
Normal file
@@ -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 (
|
||||
<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">{error.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { count: totalCount } = await supabase
|
||||
.from('responses')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
const rows = (data ?? []) as unknown as ResponseRow[]
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Control de respuestas</h1>
|
||||
<p className="admin-subtitle">
|
||||
{totalCount ?? 0} respuestas en total · últimas {rows.length} ·
|
||||
actualizado {formatDate(new Date().toISOString())}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
</header>
|
||||
|
||||
<div className="admin-table-wrapper">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fecha</th>
|
||||
<th>Encuesta</th>
|
||||
<th>Departamento</th>
|
||||
<th className="admin-col-optional">Sector</th>
|
||||
<th className="admin-col-optional">Rango etario</th>
|
||||
<th>Respuestas</th>
|
||||
<th className="admin-col-optional">IP hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const survey = Array.isArray(row.surveys) ? row.surveys[0] : row.surveys
|
||||
const answerCount = row.answers?.[0]?.count ?? 0
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{formatDate(row.submitted_at)}</td>
|
||||
<td>{survey?.title ?? '—'}</td>
|
||||
<td>{dash(row.qi_zone)}</td>
|
||||
<td className="admin-col-optional">{dash(row.qi_sector)}</td>
|
||||
<td className="admin-col-optional">{dash(row.qi_age_bucket)}</td>
|
||||
<td>{answerCount}</td>
|
||||
<td className="admin-col-optional admin-iphash">
|
||||
{row.ip_hash ? `${row.ip_hash.slice(0, 8)}...` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ── */
|
||||
|
||||
437
sprints/sprint-3-infra/ETAPA_3I_PROMPT.md
Normal file
437
sprints/sprint-3-infra/ETAPA_3I_PROMPT.md
Normal 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).
|
||||
133
sprints/sprint-3-infra/ETAPA_3V_PROMPT.md
Normal file
133
sprints/sprint-3-infra/ETAPA_3V_PROMPT.md
Normal 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á.
|
||||
Reference in New Issue
Block a user