diff --git a/docs/propuesta-arquitectura-db.md b/docs/propuesta-arquitectura-db.md new file mode 100644 index 0000000..ad9ba5b --- /dev/null +++ b/docs/propuesta-arquitectura-db.md @@ -0,0 +1,1044 @@ +# 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) diff --git a/src/pages/api/formulario/send.ts b/src/pages/api/formulario/send.ts index 7e0e380..fdb9b40 100644 --- a/src/pages/api/formulario/send.ts +++ b/src/pages/api/formulario/send.ts @@ -1,6 +1,6 @@ import type { APIRoute } from "astro"; import { appendRegistrationToSheet } from "../lib/googleSheets"; -import { sendEmail } from "../lib/email"; +import { sendEmailCf } from "../lib/email"; export const prerender = false; const COLUMNS = [ @@ -106,10 +106,9 @@ export const POST: APIRoute = async ({ request }) => { const result = await appendRegistrationToSheet(row); - await sendEmail({ - nombres: formData.nombre, + await sendEmailCf({ + nombres: formData.nombre_completo, apellidos: formData.apellido, - locale: formData.locale, }, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"); return Response.json(result); diff --git a/src/pages/api/lib/email.ts b/src/pages/api/lib/email.ts index fb98027..c825b94 100644 --- a/src/pages/api/lib/email.ts +++ b/src/pages/api/lib/email.ts @@ -1,4 +1,4 @@ -// import Cloudflare from "cloudflare"; +import Cloudflare from "cloudflare"; type TypeEmailData = "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO" | "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"; @@ -32,74 +32,75 @@ export async function sendEmail(data: object, to: string, type: TypeEmailData) { } // ═══════════════════════════════════════════════════════════════ -// CÓDIGO COMENTADO — Cloudflare (Email Workers) -// Descomentar para volver a Cloudflare en lugar de n8n/Brevo +// Cloudflare (Email Sending) +// Usar sendEmailCf() en lugar de sendEmail() (n8n/Brevo) // ═══════════════════════════════════════════════════════════════ -// const TEMPLATE_URLS: Record = { -// CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION: { -// subject: "Confirmación de inscripción al voluntariado", -// url: "https://pub-910ed90860804520b9202194cf43c0a6.r2.dev/centro_del_reino_paz_y_justicia_postulacion_user.html", -// }, -// CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO: { -// subject: "Notificación de contacto institucional", -// url: "https://pub-910ed90860804520b9202194cf43c0a6.r2.dev/centro_del_reino_paz_y_justicia_info.html", -// }, -// }; +const TEMPLATE_URLS: Record = { + CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION: { + subject: "Confirmación de inscripción al voluntariado", + url: "https://pub-910ed90860804520b9202194cf43c0a6.r2.dev/centro_del_reino_paz_y_justicia_postulacion_user.html", + }, + CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO: { + subject: "Notificación de contacto institucional", + url: "https://pub-910ed90860804520b9202194cf43c0a6.r2.dev/centro_del_reino_paz_y_justicia_info.html", + }, +}; -// const TEMPLATE_CACHE = new Map(); +const TEMPLATE_CACHE = new Map(); -// async function fetchTemplate(type: TypeEmailData): Promise { -// const cached = TEMPLATE_CACHE.get(type); -// if (cached) return cached; +async function fetchTemplate(type: TypeEmailData): Promise { + const cached = TEMPLATE_CACHE.get(type); + if (cached) return cached; -// const { url } = TEMPLATE_URLS[type]; -// const res = await fetch(url); -// if (!res.ok) { -// throw new Error(`Error descargando template ${type}: HTTP ${res.status}`); -// } + const { url } = TEMPLATE_URLS[type]; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Error descargando template ${type}: HTTP ${res.status}`); + } -// const html = await res.text(); -// TEMPLATE_CACHE.set(type, html); -// return html; -// } + const html = await res.text(); + TEMPLATE_CACHE.set(type, html); + return html; +} -// function renderTemplate(template: string, data: Record): string { -// let html = template; -// for (const [key, value] of Object.entries(data)) { -// html = html.replaceAll(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value ?? ""); -// } -// return html; -// } +function renderTemplate(template: string, data: Record): string { + let html = template; + for (const [key, value] of Object.entries(data)) { + html = html.replaceAll(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value ?? ""); + } + return html; +} -// function stripHtml(html: string): string { -// return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim(); -// } +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim(); +} -// export async function sendEmailCf(data: Record, to: string, type: TypeEmailData) { -// const apiToken = import.meta.env.CLOUDFLARE_API_TOKEN; -// const accountId = import.meta.env.CLOUDFLARE_ACCOUNT_ID; +export async function sendEmailCf(data: Record, to: string, type: TypeEmailData) { + const apiToken = import.meta.env.CLOUDFLARE_API_TOKEN; + const accountId = import.meta.env.CLOUDFLARE_ACCOUNT_ID; -// const client = new Cloudflare({ apiToken }); -// const { subject } = TEMPLATE_URLS[type]; -// const rawHtml = await fetchTemplate(type); -// const html = renderTemplate(rawHtml, data); -// const text = stripHtml(html); + const client = new Cloudflare({ apiToken }); + const { subject } = TEMPLATE_URLS[type]; + const rawHtml = await fetchTemplate(type); + const html = renderTemplate(rawHtml, data); + const text = stripHtml(html); -// const response = await client.emailSending.send({ -// account_id: accountId, -// from: "welcome@mail.centrodelreinodepazyjusticia.com", -// to, -// subject, -// html, -// text, -// }); + const response = await client.emailSending.send({ + account_id: accountId, + from: "welcome@mail.centrodelreinodepazyjusticia.com", + to, + subject, + html, + text, + }); -// if (!response.delivered) { -// console.error("Cloudflare email not delivered", response); -// throw new Error("El correo no fue entregado"); -// } -// } + const deliveredOrQueued = [...response.delivered, ...response.queued]; + if (!deliveredOrQueued.includes(to) || response.permanent_bounces.includes(to)) { + console.error("Cloudflare email not delivered", response); + throw new Error("El correo no fue entregado"); + } +} // ═══════════════════════════════════════════════════════════════ // ALTERNATIVA: Brevo (SDK directo)