1045 lines
36 KiB
Markdown
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)
|