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