cdrdpyj/docs/propuesta-arquitectura-db.md

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

  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:

{
  "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.


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

// 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_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)

# 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

Referencias