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