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