36 KiB
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
- #Estado Actual del Proyecto
- #Stack Tecnológico Propuesto
- #Análisis de Opciones — Prisma vs Supabase SDK
- #Conceptos Clave
- #Esquema de Base de Datos
- #Mapeo formData → Base de Datos
- #Número de Voluntario
- #Migración de Google Sheets (3.000 registros)
- #Autenticación de Administradores y Coordinadores
- #Dashboard
- #Plan de Implementación
- #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:
{
"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.
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:
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.
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.
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.
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.
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
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
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
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)
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
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)
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 |
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
// 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
// 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)
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)
// 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
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
// 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)
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:
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:
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_URLde Supabase (Project Settings → Database → Connection string) - Actualizar
.envcon 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.tsfunciona 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
formDataen: core fields (→formularios) + dinámicos (→respuestas) - Insertar via Prisma (transacción: formulario + respuestas)
- Manejar
numero_voluntarioauto-generado - Mantener envío de email (sin cambios)
- Desconectar Google Sheets o mantener como backup
- Separar
Fase 4: Migración de datos
- Crear
scripts/migrar-sheets-to-supabase.ts - Ejecutar migración (3.000 registros)
- Verificar
numero_voluntariocorrelativo - 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
adminsy 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)
# 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)
pnpm add @supabase/supabase-js @supabase/ssr