cdrdpyj/docs/propuesta-arquitectura-db.md

1045 lines
36 KiB
Markdown

# Propuesta de Arquitectura: Base de Datos + API para Formulario de Voluntariado
> **Propósito:** Documentar el diseño de base de datos, la integración con Supabase + Prisma, y el plan de implementación para el sistema de registro de voluntarios, dashboard administrativo y autenticación de coordinadores.
---
## Índice
1. [[#Estado Actual del Proyecto]]
2. [[#Stack Tecnológico Propuesto]]
3. [[#Análisis de Opciones — Prisma vs Supabase SDK]]
4. [[#Conceptos Clave]]
5. [[#Esquema de Base de Datos]]
6. [[#Mapeo formData → Base de Datos]]
7. [[#Número de Voluntario]]
8. [[#Migración de Google Sheets (3.000 registros)]]
9. [[#Autenticación de Administradores y Coordinadores]]
10. [[#Dashboard]]
11. [[#Plan de Implementación]]
12. [[#Archivos y Código Actual que Deben Cambiarse]]
---
## Estado Actual del Proyecto
### Componentes existentes
| Componente | Estado | Descripción |
|---|---|---|
| `DynamicForm.vue` | ✅ Completo | Formulario multi-step (8 pasos) que captura datos del voluntario |
| `POST /api/formulario/send` | ✅ Funcional | Recibe `{ formData, responses, turnstileToken, honeypot }` y escribe a **Google Sheets** |
| `POST /api/emailInfo/send` | ✅ Funcional | Guarda contacto en **SQLite via Prisma** + envía email |
| `POST /api/emailPostulacion/send` | ✅ Funcional | Escribe a **Google Sheets** + envía email |
| `src/pages/api/lib/email.ts` | ✅ Funcional | Envío de emails via n8n webhook (con código comentado para Cloudflare Workers y Brevo) |
| `src/pages/api/lib/googleSheets.ts` | ✅ Funcional | Cliente Google Sheets API |
| `src/pages/api/lib/prisma.ts` | ✅ Funcional | Cliente Prisma singleton apuntando a **SQLite** |
| `prisma/schema.prisma` | ✅ Existente | Modelo `Contact` con SQLite |
### Base de datos actual
```
SQLite (dev.db) Google Sheets
└── Contact (id, nombre, └── Hoja "VOLUNTARIOS"
email, mensaje, createdAt, └── 46 columnas
updatedAt)
```
### Payload del formulario (`POST /api/formulario/send`)
El frontend envía:
```json
{
"formData": {
"nombre": "Esteban",
"segundo_nombre": "Pérez",
"apellido": "González",
"documentos": "1234567890",
"correo": "esteban@email.com",
"fecha_nacimiento": "1990-05-15",
"nacionalidad": "Colombia",
"sexo": "M",
"direccion_completa": "Calle 123",
"ciudad": "Bogotá",
"estado": "Cundinamarca",
"pais": "Colombia",
"postal": "110111",
"telefono": "+57123456789",
"whatsapp": "+573001234567",
"profesion": "Ingeniero",
"lugar_trabajo_actual": "Empresa X",
"nivel_academico": "Universitario",
"idioma": ["Espanol", "Ingles"],
"idioma_nivel_Espanol": "Avanzado",
"idioma_nivel_Ingles": "Intermedio",
"idioma_otro": "",
"voluntariado_anterior": "no",
"areas_colaborar": ["Educacion", "Logistica"],
"dias_disponibles": ["Lunes", "Miercoles"],
"horario_preferido": ["Tarde"],
"fuera_ciudad": "si",
"misiones_internacionales": "no",
"condicion_medica": "no",
"alergias": "",
"medicamentos": "",
"emergencia_nombre": "Maria Gómez",
"emergencia_parentesco": "Madre",
"emergencia_telefono": "3001234567",
"emergencia_correo": "maria@email.com",
"confirmar_firma": "Esteban Pérez González",
"declaracion_voluntario": ["informacion_verdadera", "acepto_reglamento", "compromiso_etica", "autorizo_datos", "confidencialidad", "participacion_voluntaria"],
"confirmacion": ["confirmado"],
"nombre_completo": "Esteban Pérez"
},
"responses": [
{ "key": "nombre", "value": "Esteban Pérez", "section_id": "step_2" },
{ "key": "idioma_nivel_Espanol", "value": "Avanzado", "section_id": "step_3" },
...
],
"turnstileToken": "0.xxx...",
"honeypot": ""
}
```
### Columnas actuales de Google Sheets (`COLUMNS` en send.ts)
```
aceptacion_reglamento, nombre_completo, apellido, documentos, correo,
reglamento_firma, fecha_nacimiento, nacionalidad, sexo, direccion_completa,
ciudad, estado, pais, postal, telefono, whatsapp, profesion,
lugar_trabajo_actual, nivel_academico, idioma, idioma_nivel_Espanol,
idioma_nivel_Ingles, idioma_nivel_Hebreo, idioma_nivel_Portugues,
idioma_nivel_Otro, idioma_otro, voluntariado_anterior, caso_si,
areas_colaborar, areas_otra, dias_disponibles, horario_preferido,
fuera_ciudad, misiones_internacionales, condicion_medica,
condicion_medica_cual, alergias, medicamentos, emergencia_nombre,
emergencia_parentesco, emergencia_telefono, emergencia_correo,
confirmar_nombre, confirmar_firma, declaracion_voluntario, confirmacion
```
---
## Stack Tecnológico Propuesto
```
┌──────────────────────────────────────────────────────────┐
│ Astro SSR (Node.js) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ API Routes │ │
│ │ POST /api/formulario/send → Prisma insert │ │
│ │ GET /api/admin/voluntarios → Prisma query │ │
│ │ POST /api/admin/auth/login → Supabase Auth │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Prisma ORM │ │ Supabase Auth SDK │ │
│ │ (tipado, │ │ (magic links, │ │
│ │ migraciones) │ │ sesiones, RLS) │ │
│ └──────┬───────┘ └──────┬───────────────┘ │
└─────────┼─────────────────────────┼──────────────────────┘
│ │
┌─────────┴─────────────────────────┴──────────────────────┐
│ Supabase PostgreSQL │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ auth.users │ │ formularios │ │
│ │ auth.sessions │ │ respuestas │ │
│ │ (manejado por │ │ secciones │ │
│ │ Supabase Auth) │ │ campos │ │
│ │ │ │ form_versions │ │
│ │ admins (propia) │ │ contact │ │
│ └──────────────────┘ └───────────────────┘ │
│ │
│ PgBouncer (pooler, puerto 6543) │
│ Read replicas (opcional, escala futura) │
└──────────────────────────────────────────────────────────┘
```
### Tabla de tecnologías
| Tecnología | Rol | ¿Por qué? |
|---|---|---|
| **Astro SSR** | Framework web | Ya está en el proyecto, server-side seguro |
| **Prisma ORM** | Capa de datos | Schema declarativo, migraciones, type safety, ya se usa |
| **Supabase Auth** | Autenticación | Login listo (magic links, OAuth), sesiones, sin implementar desde cero |
| **Supabase PostgreSQL** | Base de datos | Escalable, incluye PgBouncer, backups, 99.95% SLA |
| **Googleapis** | Migración | Solo para leer los 3.000 registros actuales de Google Sheets |
---
## Análisis de Opciones — Prisma vs Supabase SDK
### Opción A: Prisma → PostgreSQL de Supabase (solo DB)
```
App → Prisma ORM → Supabase PostgreSQL (solo hosting de DB)
Ignora: Auth, Realtime, Storage, RLS, Edge Functions
```
| Pros | Contras |
|---|---|
| Schema declarativo con migraciones automáticas | No usa RLS (no necesario con SSR, pero disponible) |
| Type safety total (tipos generados automáticamente) | Capa extra de abstracción (mínimo overhead) |
| Cliente familiar (ya se usa en el proyecto) | No hay Realtime |
| Auto-incremento nativo (`@default(autoincrement())`) | No hay Auth building |
| Seed scripts fáciles para migración | |
### Opción B: Supabase SDK completo
```
App → Supabase SDK (@supabase/supabase-js) → Supabase API Gateway
→ Auth + DB + Realtime + Storage + RLS
```
| Pros | Contras |
|---|---|
| Auth listo (signInWithPassword, magic links, OAuth) | Sin schema declarativo (migraciones SQL manuales) |
| RLS nativo (permisos a nivel DB) | Sin tipos nativos (toca `supabase gen types` o interfaces manuales) |
| Realtime (cambios en DB en vivo) | Auto-incremento manual (sequence + nextval()) |
| Storage (fotos/documentos) | Otro cliente que aprender |
### Opción C: Combinación (RECOMENDADA)
```
API (server-side SSR):
→ Prisma para guardar/leer datos (tipos seguros, migraciones)
→ Supabase Auth para login de admins/coordinadores
→ Ambos coexisten sin problema
Dashboard:
→ Consultas via Prisma (server-side seguro, nada expuesto al cliente)
→ O Supabase SDK con anon key + RLS si se necesita desde frontend
```
**Veredicto para este proyecto:**
| Necesidad | Solución |
|---|---|
| Guardar formularios | Prisma (tipos, migraciones) |
| Login admin/coordinador | Supabase Auth (magic links, sesiones) |
| Dashboard | Prisma (server-side render) |
| Migración 3k registros | Script con Prisma |
---
## Conceptos Clave
### EAV (Entity-Attribute-Value)
Patrón donde en vez de crear una columna por cada campo del formulario, se guardan pares `key → value` en una tabla separada.
```
Formulario #3001
├── key: "idioma_nivel_Ingles" value: "Avanzado"
├── key: "areas_colaborar" value: "Educacion"
├── key: "areas_colaborar" value: "Logistica"
└── key: "alergias" value: "Ninguna"
```
| Ventaja | Desventaja |
|---|---|
| Agregar campos al JSON no requiere migrar DB | Consultas con filtros requieren pivoteo (`MAX(CASE WHEN ...)`) |
| Esquema flexible ante cambios frecuentes | Más lento que columnas fijas en volumen alto |
**Solución híbrida:** columnas fijas en `formularios` para lo común, EAV en `respuestas` para lo variable.
---
### Astro SSR (Server-Side Rendering)
El proyecto corre en modo servidor (`output: "server"`). Cada request ejecuta código en Node.js, genera HTML dinámicamente y lo envía al cliente. Las APIs y la DB viven del lado del servidor.
**Implicación:** nunca se exponen credenciales de DB al navegador. Todo acceso a datos es server-side.
---
### Vistas Materializadas (Materialized Views)
"Foto" pre-computada de una consulta compleja que PostgreSQL mantiene almacenada.
```sql
CREATE MATERIALIZED VIEW vista_voluntarios_flat AS
SELECT f.id, f.numero_voluntario, f.nombre, f.apellido,
MAX(CASE WHEN r.key='idioma_nivel_Ingles' THEN r.value END) as nivel_ingles
FROM formularios f
LEFT JOIN respuestas r ON r.formulario_id = f.id
GROUP BY f.id;
```
El dashboard consulta la vista como si fuera una tabla (rápido). Se refresca con `REFRESH MATERIALIZED VIEW`. Útil cuando el EAV se vuelve lento de consultar.
---
### `gen types` (Supabase)
Comando que escanea tu DB PostgreSQL y genera tipos TypeScript automáticamente:
```bash
supabase gen types typescript --linked > src/types/supabase.ts
```
Así `supabase.from('formularios').select('*')` devuelve objetos tipados.
---
### Pooler / PgBouncer
Intermediario que mantiene un grupo de conexiones abiertas a PostgreSQL y las reusa entre requests. Sin pooler, cada conexión nueva consume recursos.
```
100 requests simultáneas
→ PgBouncer (solo 20 conexiones reales, las recicla)
→ PostgreSQL
```
Supabase provee dos URLs:
- **Pooled** (`puerto 6543`) — con PgBouncer, para queries normales (API, dashboard)
- **Direct** (`puerto 5432`) — sin PgBouncer, para migraciones pesadas (insertar 3k registros)
---
### Read Replicas
Copias de solo lectura de la DB. Consultas SELECT van a réplicas, INSERT/UPDATE van a la DB principal. Distribuye la carga.
Supabase Pro incluye read replicas. Se usan cambiando la URL de conexión para consultas de solo lectura.
---
### RLS (Row Level Security)
Políticas SQL a nivel de fila en PostgreSQL. Definen qué filas puede ver/modificar cada usuario según su identidad.
```sql
CREATE POLICY coordinador_pais ON formularios
FOR SELECT
USING (pais = (SELECT pais FROM admins WHERE auth_user_id = auth.uid()));
```
No es necesario con SSR (todo pasa por el servidor), pero es útil si en el futuro el dashboard se conecta directamente desde el frontend.
---
### RBAC (Role-Based Access Control)
Control de acceso por roles al nivel de la DB:
```
admin → CRUD completo sobre todos los registros
coordinador → solo voluntarios de su región
viewer → solo lectura de datos anonimizados
```
Combinado con RLS, los permisos se declaran una vez en SQL y aplican globalmente.
---
### Magic Links
Login sin contraseña. El usuario ingresa su email y recibe un enlace mágico. Al hacer clic, se autentica automáticamente.
```typescript
await supabase.auth.signInWithOtp({ email: "coord@carpa.com" })
// → Envía email con link mágico. Al hacer clic, la sesión se crea.
```
Útil para coordinadores no técnicos — no necesitan gestionar contraseñas.
---
## Esquema de Base de Datos
### Modelo `Formulario` — Datos fijos del registro
Campos que se consultan frecuentemente y son comunes a la mayoría de formularios.
```prisma
model Formulario {
id String @id @default(uuid()) @db.Uuid
numero_voluntario Int @unique @default(autoincrement())
nombre String
segundo_nombre String?
apellido String
documento_nro String?
documento_tipo String? // CC, CE, Pasaporte (para futuro)
correo String
fecha_nacimiento DateTime? @db.Date
nacionalidad String?
sexo String?
direccion String?
ciudad String?
estado String?
pais String?
codigo_postal String?
telefono String?
whatsapp String?
profesion String?
lugar_trabajo String?
nivel_academico String?
status String @default("pendiente")
form_version String?
email_sent_at DateTime? @db.Timestamptz
submitted_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @updatedAt @db.Timestamptz
respuestas Respuesta[]
@@index([correo])
@@index([status])
@@index([pais])
}
```
### Modelo `Respuesta` — Datos dinámicos (EAV)
Almacena todo lo demás. Cada opción de checkbox va en una fila separada.
```prisma
model Respuesta {
id String @id @default(uuid()) @db.Uuid
formulario_id String @db.Uuid
key String
value String
seccion String @default("") // "step_1" .. "step_8"
created_at DateTime @default(now()) @db.Timestamptz
formulario Formulario @relation(fields: [formulario_id], references: [id], onDelete: Cascade)
@@index([formulario_id])
@@index([key])
}
```
**Ejemplo de cómo se guarda un checkbox con niveles:**
Para un voluntario que seleccionó "Espanol" e "Ingles", con niveles "Avanzado" e "Intermedio":
```
Respuesta #1: { formulario_id: x, key: "idioma", value: "Espanol", seccion: "step_3" }
Respuesta #2: { formulario_id: x, key: "idioma", value: "Ingles", seccion: "step_3" }
Respuesta #3: { formulario_id: x, key: "idioma_nivel_Espanol", value: "Avanzado", seccion: "step_3" }
Respuesta #4: { formulario_id: x, key: "idioma_nivel_Ingles", value: "Intermedio", seccion: "step_3" }
```
### Modelo `Seccion` — Metadatos de pasos
```prisma
model Seccion {
id String @id @default(uuid()) @db.Uuid
title_key String // "form.step2"
title String?
orden Int @default(0)
columns Int @default(1)
created_at DateTime @default(now()) @db.Timestamptz
campos Campo[]
}
```
### Modelo `Campo` — Metadatos de fields
```prisma
model Campo {
id String @id @default(uuid()) @db.Uuid
key String @unique
label_key String // "form.nombre"
tipo String // text, email, phone, checkbox, radio, select, autocomplete
seccion_id String? @db.Uuid
required Boolean @default(false)
orden Int @default(0)
options Json? @db.JsonB // [{label, value}] para radio/select/checkbox
source String? // URL para autocomplete/select ("/forms/paises.json")
created_at DateTime @default(now()) @db.Timestamptz
seccion Seccion? @relation(fields: [seccion_id], references: [id], onDelete: SetNull)
}
```
### Modelo `FormVersion` — Versiones del formulario
```prisma
model FormVersion {
id String @id @default(uuid()) @db.Uuid
version String
config Json @db.JsonB // El JSON completo del formulario
active Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamptz
}
```
### Modelo `Contact` — Formulario de contacto (heredado)
```prisma
model Contact {
id Int @id @default(autoincrement())
nombre String
email String @unique
mensaje String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email, createdAt])
}
```
### Modelo `Admin` — Administradores y coordinadores
```prisma
model Admin {
id String @id @default(uuid()) @db.Uuid
auth_user_id String @unique @db.Uuid // FK a auth.users de Supabase
email String @unique
nombre String
rol String @default("coordinador") // admin | coordinador | viewer
activo Boolean @default(true)
ultimo_acceso DateTime? @db.Timestamptz
created_at DateTime @default(now()) @db.Timestamptz
@@index([email])
}
```
### SQL para crear en Supabase (alternativo a Prisma migrate)
```sql
CREATE SEQUENCE seq_voluntario START WITH 1;
CREATE TABLE formularios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_voluntario INTEGER NOT NULL DEFAULT nextval('seq_voluntario'),
nombre TEXT NOT NULL,
segundo_nombre TEXT,
apellido TEXT NOT NULL,
documento_nro TEXT,
documento_tipo TEXT,
correo TEXT NOT NULL,
fecha_nacimiento DATE,
nacionalidad TEXT,
sexo TEXT,
direccion TEXT,
ciudad TEXT,
estado TEXT,
pais TEXT,
codigo_postal TEXT,
telefono TEXT,
whatsapp TEXT,
profesion TEXT,
lugar_trabajo TEXT,
nivel_academico TEXT,
status TEXT NOT NULL DEFAULT 'pendiente'
CHECK (status IN ('pendiente','aprobado','rechazado','archivado')),
form_version TEXT,
email_sent_at TIMESTAMPTZ,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_numero_voluntario ON formularios(numero_voluntario);
CREATE INDEX idx_form_correo ON formularios(correo);
CREATE INDEX idx_form_status ON formularios(status);
CREATE TABLE respuestas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
formulario_id UUID NOT NULL REFERENCES formularios(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
seccion TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_resp_formulario ON respuestas(formulario_id);
CREATE INDEX idx_resp_key ON respuestas(key);
CREATE TABLE secciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title_key TEXT NOT NULL,
title TEXT,
orden INTEGER NOT NULL DEFAULT 0,
columns INTEGER DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE campos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
label_key TEXT NOT NULL,
tipo TEXT NOT NULL,
seccion_id UUID REFERENCES secciones(id) ON DELETE SET NULL,
required BOOLEAN NOT NULL DEFAULT false,
orden INTEGER NOT NULL DEFAULT 0,
options JSONB,
source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE form_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version TEXT NOT NULL,
config JSONB NOT NULL,
active BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE admins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auth_user_id UUID NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
nombre TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'coordinador'
CHECK (rol IN ('admin','coordinador','viewer')),
activo BOOLEAN DEFAULT true,
ultimo_acceso TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
---
## Mapeo formData → Base de Datos
### Columnas fijas → `formularios`
| Key del formData | Columna en DB | Notas |
|---|---|---|
| `nombre` | `nombre` | Solo el primer nombre (sin segundo_nombre) |
| `segundo_nombre` | `segundo_nombre` | Viene separado en formData |
| `apellido` | `apellido` | |
| `documentos` | `documento_nro` | Tipo phone en el form (numérico) |
| *(no existe)* | `documento_tipo` | NULL hasta que se agregue al form |
| `correo` | `correo` | |
| `fecha_nacimiento` | `fecha_nacimiento` | `YYYY-MM-DD` compuesto por 3 selects |
| `nacionalidad` | `nacionalidad` | |
| `sexo` | `sexo` | |
| `direccion_completa` | `direccion` | |
| `ciudad` | `ciudad` | |
| `estado` | `estado` | |
| `pais` | `pais` | |
| `postal` | `codigo_postal` | |
| `telefono` | `telefono` | Incluye código de país + número |
| `whatsapp` | `whatsapp` | |
| `profesion` | `profesion` | |
| `lugar_trabajo_actual` | `lugar_trabajo` | |
| `nivel_academico` | `nivel_academico` | |
### Datos dinámicos → `respuestas`
| Key(s) | Tipo | Almacenamiento |
|---|---|---|
| `aceptacion_reglamento` | checkbox (array) | 1 fila por opción seleccionada |
| `reglamento_firma` | text | 1 fila |
| `idioma` | checkbox (array) | 1 fila por idioma |
| `idioma_nivel_*` | level (string) | 1 fila por nivel |
| `idioma_otro` | text | 1 fila |
| `voluntariado_anterior` | radio | 1 fila |
| `caso_si` | textarea | 1 fila |
| `areas_colaborar` | checkbox (array) | 1 fila por área |
| `areas_otra` | text | 1 fila |
| `dias_disponibles` | checkbox (array) | 1 fila por día |
| `horario_preferido` | checkbox (array) | 1 fila por horario |
| `fuera_ciudad` | radio | 1 fila |
| `misiones_internacionales` | radio | 1 fila |
| `condicion_medica` | radio | 1 fila |
| `condicion_medica_cual` | textarea | 1 fila |
| `alergias` | textarea | 1 fila |
| `medicamentos` | textarea | 1 fila |
| `emergencia_nombre` | text | 1 fila |
| `emergencia_parentesco` | text | 1 fila |
| `emergencia_telefono` | phone | 1 fila |
| `emergencia_correo` | email | 1 fila |
| `declaracion_voluntario` | checkbox (array) | 1 fila por opción |
| `confirmacion` | checkbox (array) | 1 fila |
### Lo que NO se almacena
| Key | Razón |
|---|---|
| `nombre_completo` | Se reconstruye con `CONCAT(nombre, ' ', segundo_nombre, ' ', apellido)` |
| `confirmar_nombre` | Readonly, duplicado de `nombre + segundo_nombre + apellido` |
| `confirmar_firma` | Readonly, duplicado de `reglamento_firma` |
---
## Número de Voluntario
### Diseño
| Aspecto | Decisión |
|---|---|
| **Columna** | `numero_voluntario INTEGER NOT NULL UNIQUE` |
| **Tipo** | Solo número (sin prefijo). El prefijo `V-` o `VOL-` se agrega al mostrar |
| **Generación** | `@default(autoincrement())` en Prisma → se traduce a `GENERATED BY DEFAULT AS IDENTITY` en PostgreSQL |
| **Override** | `BY DEFAULT` permite insertar valores explícitos durante la migración |
### Flujo en producción
```typescript
// Nuevo registro: no se envía numero_voluntario → se auto-asigna
const { data } = await prisma.formulario.create({
data: {
nombre: "Esteban",
apellido: "González",
correo: "e@mail.com",
// ... sin numero_voluntario
}
})
// data.numero_voluntario = 3001 (siguiente de la secuencia)
```
### Flujo en migración de Sheets
```typescript
// 1. Ordenar registros existing por fecha (asc)
// 2. Asignar numero_voluntario = 1, 2, 3... n
// 3. Insertar con numero_voluntario EXPLÍCITO
await prisma.formulario.create({
data: {
numero_voluntario: 1, // override explícito
nombre: "...",
// ...
}
})
// 4. Después de la migración, resetear secuencia
await prisma.$executeRaw`SELECT setval('"Formulario_numero_voluntario_seq"', 3000);`
```
---
## Migración de Google Sheets (3.000 registros)
### Proceso
```
1. Leer Google Sheets via googleapis (código existente)
2. Ordenar por fecha de registro (ascendente → el más antiguo es #1)
3. Batch insert via Prisma (100 registros por lote)
4. Resetear secuencia a 3000
5. Verificar integridad
```
### Script conceptual (`scripts/migrar-sheets-to-supabase.ts`)
```typescript
import { google } from 'googleapis'
import { prisma } from '../src/pages/api/lib/prisma'
async function migrar() {
// 1. Leer Google Sheets
const sheets = google.sheets({ version: 'v4', auth })
const raw = await sheets.spreadsheets.values.get({
spreadsheetId: process.env.GOOGLE_SHEET_ID,
range: 'VOLUNTARIOS!A:AR',
})
// 2. Convertir a objetos (saltando header row)
const rows = raw.data.values?.slice(1) || []
const registros = rows.map(colsToObject).sort(porFecha)
// 3. Batch insert con numero_voluntario explícito
for (let i = 0; i < registros.length; i += 100) {
const batch = registros.slice(i, i + 100).map((r, idx) => ({
numero_voluntario: i + idx + 1, // correlativo
// ... mapeo de columnas
}))
await prisma.formulario.createMany({ data: batch })
console.log(`Insertados ${i + batch.length} de ${registros.length}`)
}
// 4. Resetear secuencia
await prisma.$executeRaw`SELECT setval('"Formulario_numero_voluntario_seq"', ${registros.length})`
}
```
---
## Autenticación de Administradores y Coordinadores
### Diagrama de flujo
```
Usuario (admin/coordinador)
├── 1. Ingresa email en /admin/login
├── 2. POST /api/admin/auth/login → Supabase Auth signInWithOtp()
│ └── Supabase envía email con magic link
├── 3. Usuario hace clic en el magic link
│ └── Supabase verifica token y crea sesión
├── 4. Astro SSR recibe la sesión, consulta tabla `admins`
│ └── Si el email existe en admins: acceso concedido
│ └── Si no existe: redirigir a "acceso denegado"
└── 5. Middleware en Astro verifica sesión en cada request
└── Guard: comprueba cookie de sesión + rol
└── Si no es válida: redirigir a login
```
### Middleware de protección (conceptual)
```typescript
// src/middleware.ts
import { defineMiddleware } from 'astro/middleware'
import { createServerClient } from '@supabase/ssr'
export const onRequest = defineMiddleware(async (ctx, next) => {
const supabase = createServerClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
cookies: { get: ctx.cookies.get, set: ctx.cookies.set }
})
const { data: { session } } = await supabase.auth.getSession()
if (ctx.url.pathname.startsWith('/admin') && !session) {
return ctx.redirect('/admin/login')
}
ctx.locals.supabase = supabase
ctx.locals.session = session
return next()
})
```
### Tabla `admins`
```prisma
model Admin {
id String @id @default(uuid()) @db.Uuid
auth_user_id String @unique @db.Uuid
email String @unique
nombre String
rol String @default("coordinador") // admin | coordinador | viewer
activo Boolean @default(true)
ultimo_acceso DateTime? @db.Timestamptz
created_at DateTime @default(now()) @db.Timestamptz
}
```
Los admins se crean manualmente desde la consola de Supabase o un seed. No hay registro público de admins.
---
## Dashboard
### Consideraciones de consultas EAV a escala
Para mostrar una tabla de voluntarios con filtros, hay dos enfoques:
#### Enfoque 1: Consulta directa con pivoteo
```typescript
// Obtener voluntarios con nivel de inglés y áreas de colaboración
// para mostrar en una tabla del dashboard
const voluntarios = await prisma.formulario.findMany({
where: { status: 'activo' },
include: {
respuestas: {
where: {
OR: [
{ key: 'idioma_nivel_Ingles' },
{ key: 'areas_colaborar' },
{ key: 'dias_disponibles' },
]
}
}
},
orderBy: { submitted_at: 'desc' },
take: 50
})
// Luego en JS: agrupar respuestas por key
```
Funcional hasta ~10k registros. Después, considerar vistas materializadas.
#### Enfoque 2: Vista materializada (escala >50k)
```sql
CREATE MATERIALIZED VIEW vista_voluntarios_flat AS
SELECT
f.id, f.numero_voluntario, f.nombre, f.apellido, f.correo, f.pais, f.status,
MAX(CASE WHEN r.key='idioma_nivel_Ingles' THEN r.value END) as nivel_ingles,
jsonb_agg(DISTINCT r.value) FILTER (WHERE r.key='areas_colaborar') as areas,
jsonb_agg(DISTINCT r.value) FILTER (WHERE r.key='dias_disponibles') as dias
FROM formularios f
LEFT JOIN respuestas r ON r.formulario_id = f.id
GROUP BY f.id;
-- Crear índice para búsquedas rápidas
CREATE INDEX idx_vista_flat_pais ON vista_voluntarios_flat(pais);
-- Refrescar periódicamente (cron, o después de cada inserción)
REFRESH MATERIALIZED VIEW vista_voluntarios_flat;
```
Luego el dashboard consulta directamente la vista:
```typescript
const data = await prisma.$queryRawUnsafe(`
SELECT * FROM vista_voluntarios_flat
WHERE pais = $1 AND nivel_ingles = $2
ORDER BY numero_voluntario DESC
LIMIT 50
`, 'Colombia', 'Avanzado')
```
### Roles y permisos en el dashboard
| Rol | Permisos |
|---|---|
| `admin` | CRUD completo, exportar datos, gestionar admins |
| `coordinador` | Ver voluntarios de su región, editar status |
| `viewer` | Solo lectura, datos anonimizados |
Implementación vía middleware que checkea `ctx.locals.session` + query a `admins`:
```typescript
const admin = await prisma.admin.findUnique({
where: { auth_user_id: session.user.id }
})
if (!admin || admin.rol === 'viewer' && method !== 'GET') {
return new Response('Forbidden', { status: 403 })
}
```
---
## Plan de Implementación
### Fase 1: Preparación de la base de datos
- [ ] Obtener `DATABASE_URL` de Supabase (Project Settings → Database → Connection string)
- [ ] Actualizar `.env` con las nuevas variables
- [ ] Actualizar `prisma/schema.prisma` (provider + modelos nuevos)
- [ ] Eliminar migraciones antiguas de SQLite (`prisma/migrations/`)
- [ ] Ejecutar `prisma migrate dev --name init` (crea tablas en Supabase)
### Fase 2: Cliente de base de datos
- [ ] No requiere cambios: `src/pages/api/lib/prisma.ts` funciona igual apuntando a PostgreSQL
- [ ] Instalar `@supabase/supabase-js` + `@supabase/ssr` (para manejo de cookies en Astro)
### Fase 3: API de formulario (send.ts)
- [ ] Modificar `src/pages/api/formulario/send.ts`:
- [ ] Separar `formData` en: core fields (→ `formularios`) + dinámicos (→ `respuestas`)
- [ ] Insertar via Prisma (transacción: formulario + respuestas)
- [ ] Manejar `numero_voluntario` auto-generado
- [ ] Mantener envío de email (sin cambios)
- [ ] Desconectar Google Sheets o mantener como backup
### Fase 4: Migración de datos
- [ ] Crear `scripts/migrar-sheets-to-supabase.ts`
- [ ] Ejecutar migración (3.000 registros)
- [ ] Verificar `numero_voluntario` correlativo
- [ ] Resetear secuencia
### Fase 5: Autenticación de administradores
- [ ] Crear página `/admin/login` (Astro + Vue)
- [ ] Crear `POST /api/admin/auth/login` (envía magic link via Supabase Auth)
- [ ] Crear callback handler para magic link
- [ ] Crear tabla `admins` y seed de primer admin
- [ ] Implementar middleware de autenticación
- [ ] Proteger rutas `/admin/*`
### Fase 6: Dashboard básico
- [ ] Crear página `/admin/voluntarios` (tabla con datos)
- [ ] Implementar filtros básicos (por status, pais, fecha)
- [ ] Implementar búsqueda por nombre/correo/numero_voluntario
- [ ] Exportar a CSV
### Fase 7: Dashboard avanzado (opcional, escala futura)
- [ ] Crear vista materializada para queries complejas
- [ ] Agregar filtros EAV (nivel de inglés, áreas, etc.)
- [ ] Agregar RBAC completo en el middleware
- [ ] Agregar logging de actividad
---
## Archivos y Código Actual que Deben Cambiarse
### Archivos a modificar
| Archivo | Cambio | Prioridad |
|---|---|---|
| `.env` | Agregar `DATABASE_URL` de Supabase, `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` | Fase 1 |
| `prisma/schema.prisma` | Cambiar provider a `postgresql`, agregar modelos `Formulario`, `Respuesta`, `Seccion`, `Campo`, `FormVersion`, `Admin`. Mantener `Contact` | Fase 1 |
| `prisma.config.ts` | Verificar que usa `DATABASE_URL` del .env (probablemente sin cambios) | Fase 1 |
| `src/pages/api/formulario/send.ts` | Reemplazar lógica de Google Sheets con Prisma inserts. Mantener honeypot, turnstile y email | Fase 3 |
| `src/pages/api/emailInfo/send.ts` | Sin cambios si Contact se queda en el mismo schema (postgresql) | — |
| `src/pages/api/emailPostulacion/send.ts` | Opcional: migrar a Prisma también (baja prioridad) | Futuro |
### Archivos a crear
| Archivo | Propósito | Fase |
|---|---|---|
| `src/pages/api/lib/supabase.ts` | Cliente Supabase Auth para SSR | Fase 5 |
| `src/middleware.ts` | Protección de rutas `/admin/*` | Fase 5 |
| `src/pages/admin/login.astro` | Página de login | Fase 5 |
| `src/pages/api/admin/auth/login.ts` | API para enviar magic link | Fase 5 |
| `src/pages/api/admin/auth/callback.ts` | Callback de magic link | Fase 5 |
| `src/pages/admin/voluntarios.astro` | Dashboard de voluntarios | Fase 6 |
| `src/pages/admin/voluntarios/api.ts` | API de consulta con filtros | Fase 6 |
| `scripts/migrar-sheets-to-supabase.ts` | Script de migración de Google Sheets | Fase 4 |
| `prisma/seed.ts` | Seed de datos iniciales (admins, secciones, campos, form_versions) | Fase 1+5 |
### Archivos que NO deben cambiar
| Archivo | Razón |
|---|---|
| `src/components/forms/DynamicForm.vue` | El frontend no cambia, el payload es el mismo |
| `public/forms/formulario-inscripcion.json` | El JSON del formulario no cambia |
| `src/i18n/` | Las traducciones no cambian |
| `src/pages/api/lib/email.ts` | El envío de email no cambia |
| `src/pages/api/lib/googleSheets.ts` | Solo se usa para migración (fase 4) |
### Variables de entorno necesarias (`.env`)
```bash
# Supabase
SUPABASE_URL=https://[project].supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # Para operaciones server-side (bypass RLS)
# Base de datos (PostgreSQL via Supabase)
DATABASE_URL="postgresql://postgres:[PASS]@[HOST]:6543/postgres?schema=public"
DIRECT_URL="postgresql://postgres:[PASS]@[HOST]:5432/postgres?schema=public" # Para migraciones
# Prisma config
PRISMA_CLIENT_ENGINE_TYPE="dataproxy" # O desactivado si se usa directo
# Existentes (se mantienen)
GOOGLE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_PRIVATE_KEY=...
GOOGLE_SHEET_ID=...
N8N_WEBHOOK_URL=...
TURNSTILE_SITE_KEY=...
TURNSTILE_SECRET_KEY=...
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...
```
### Dependencias a agregar (`package.json`)
```bash
pnpm add @supabase/supabase-js @supabase/ssr
```
---
## Referencias
- [Documentación de Astro SSR](https://docs.astro.build/en/guides/server-side-rendering/)
- [Documentación de Prisma con PostgreSQL](https://www.prisma.io/docs/orm/overview/databases/postgresql)
- [Documentación de Supabase Auth](https://supabase.com/docs/guides/auth)
- [Supabase + Astro SSR](https://supabase.com/docs/guides/auth/server-side/astro)
- [EAV Pattern en PostgreSQL](https://www.postgresql.org/docs/current/ddl-schemas.html)
- [Materialized Views en PostgreSQL](https://www.postgresql.org/docs/current/rules-materializedviews.html)