From a9ca3f59f3e448e53b36a24434f2d731683f2052 Mon Sep 17 00:00:00 2001 From: esteban Date: Sun, 2 Aug 2026 23:51:44 -0500 Subject: [PATCH 1/9] add firts commit for lab data base --- .env | 10 +- .env.example | 7 + prisma.config.ts | 2 +- .../20260220004001_init/migration.sql | 0 .../20260220004224_add_contact/migration.sql | 0 .../migration.sql | 0 .../migration_lock.toml | 3 + .../20260803025550_init/migration.sql | 17 ++ .../migration.sql | 174 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 118 +++++++++++- src/pages/api/formulario/send.ts | 11 +- src/pages/api/lib/formularioDb.ts | 103 +++++++++++ src/pages/api/lib/prisma.ts | 2 +- 14 files changed, 440 insertions(+), 9 deletions(-) rename prisma/{migrations => migrations-sqlite-backup}/20260220004001_init/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-backup}/20260220004224_add_contact/migration.sql (100%) rename prisma/{migrations => migrations-sqlite-backup}/20260220005204_make_email_unique/migration.sql (100%) create mode 100644 prisma/migrations-sqlite-backup/migration_lock.toml create mode 100644 prisma/migrations/20260803025550_init/migration.sql create mode 100644 prisma/migrations/20260803032753_add_form_models/migration.sql create mode 100644 src/pages/api/lib/formularioDb.ts diff --git a/.env b/.env index b8af0aa..eba0594 100644 --- a/.env +++ b/.env @@ -18,8 +18,6 @@ EMAIL_API_KEY=9UShpS8oh5Iun92TJSfevElI3Lp99TCv # server with the prisma dev CLI command, when not choosing any non-default ports or settings. The API key, unlike the # one found in a remote Prisma Postgres URL, does not contain any sensitive information. -DATABASE_URL="file:./dev.db" - N8N_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary" N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH" @@ -33,3 +31,11 @@ TURNSTILE_SITE_KEY=0x4AAAAAAD9IYWC6HPflpneO # Cloudflare Turnstile — secret key (server-side, keep safe!) # In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY TURNSTILE_SECRET_KEY=0x4AAAAAAD9IYXASjQSpmo2iS902SDoXnT4 + +# Supabase PostgreSQL +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" +DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" +SUPABASE_URL="http://localhost:8000" +SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" +POSTGRES_PASSWORD=postgres diff --git a/.env.example b/.env.example index 86e3c4d..335063f 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,10 @@ TURNSTILE_SITE_KEY=0x4AA... # Cloudflare Turnstile — secret key (server-side, keep safe!) # In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY TURNSTILE_SECRET_KEY=0x4AA... + +# Supabase PostgreSQL +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" +DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" +SUPABASE_URL="http://localhost:8000" +SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" diff --git a/prisma.config.ts b/prisma.config.ts index 8ded1a5..8705955 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -11,6 +11,6 @@ export default defineConfig({ }, engine: "classic", datasource: { - url: env("DATABASE_URL"), + url: env("DIRECT_URL") || env("DATABASE_URL"), }, }); diff --git a/prisma/migrations/20260220004001_init/migration.sql b/prisma/migrations-sqlite-backup/20260220004001_init/migration.sql similarity index 100% rename from prisma/migrations/20260220004001_init/migration.sql rename to prisma/migrations-sqlite-backup/20260220004001_init/migration.sql diff --git a/prisma/migrations/20260220004224_add_contact/migration.sql b/prisma/migrations-sqlite-backup/20260220004224_add_contact/migration.sql similarity index 100% rename from prisma/migrations/20260220004224_add_contact/migration.sql rename to prisma/migrations-sqlite-backup/20260220004224_add_contact/migration.sql diff --git a/prisma/migrations/20260220005204_make_email_unique/migration.sql b/prisma/migrations-sqlite-backup/20260220005204_make_email_unique/migration.sql similarity index 100% rename from prisma/migrations/20260220005204_make_email_unique/migration.sql rename to prisma/migrations-sqlite-backup/20260220005204_make_email_unique/migration.sql diff --git a/prisma/migrations-sqlite-backup/migration_lock.toml b/prisma/migrations-sqlite-backup/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations-sqlite-backup/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/migrations/20260803025550_init/migration.sql b/prisma/migrations/20260803025550_init/migration.sql new file mode 100644 index 0000000..b0fd2e1 --- /dev/null +++ b/prisma/migrations/20260803025550_init/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "Contact" ( + "id" SERIAL NOT NULL, + "nombre" TEXT NOT NULL, + "email" TEXT NOT NULL, + "mensaje" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Contact_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Contact_email_key" ON "Contact"("email"); + +-- CreateIndex +CREATE INDEX "Contact_email_createdAt_idx" ON "Contact"("email", "createdAt"); diff --git a/prisma/migrations/20260803032753_add_form_models/migration.sql b/prisma/migrations/20260803032753_add_form_models/migration.sql new file mode 100644 index 0000000..4bef4e8 --- /dev/null +++ b/prisma/migrations/20260803032753_add_form_models/migration.sql @@ -0,0 +1,174 @@ +/* + Warnings: + + - You are about to drop the `Contact` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "Contact"; + +-- CreateTable +CREATE TABLE "contact" ( + "id" SERIAL NOT NULL, + "nombre" TEXT NOT NULL, + "email" TEXT NOT NULL, + "mensaje" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formularios" ( + "id" UUID NOT NULL, + "numero_voluntario" SERIAL NOT NULL, + "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', + "form_version" TEXT, + "email_sent_at" TIMESTAMPTZ, + "submitted_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "formularios_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "respuestas" ( + "id" UUID NOT NULL, + "formulario_id" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "seccion" TEXT NOT NULL DEFAULT '', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "respuestas_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "secciones" ( + "id" UUID NOT NULL, + "title_key" TEXT NOT NULL, + "title" TEXT, + "orden" INTEGER NOT NULL DEFAULT 0, + "columns" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "secciones_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "campos" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "label_key" TEXT NOT NULL, + "tipo" TEXT NOT NULL, + "seccion_id" UUID, + "required" BOOLEAN NOT NULL DEFAULT false, + "orden" INTEGER NOT NULL DEFAULT 0, + "options" JSONB, + "source" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "campos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "form_versions" ( + "id" UUID NOT NULL, + "version" TEXT NOT NULL, + "config" JSONB NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "form_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "admins" ( + "id" UUID NOT NULL, + "auth_user_id" UUID NOT NULL, + "email" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "rol" TEXT NOT NULL DEFAULT 'coordinador', + "activo" BOOLEAN NOT NULL DEFAULT true, + "ultimo_acceso" TIMESTAMPTZ, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "contact_email_key" ON "contact"("email"); + +-- CreateIndex +CREATE INDEX "contact_email_createdAt_idx" ON "contact"("email", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "formularios_numero_voluntario_key" ON "formularios"("numero_voluntario"); + +-- CreateIndex +CREATE INDEX "formularios_correo_idx" ON "formularios"("correo"); + +-- CreateIndex +CREATE INDEX "formularios_status_idx" ON "formularios"("status"); + +-- CreateIndex +CREATE INDEX "formularios_pais_idx" ON "formularios"("pais"); + +-- CreateIndex +CREATE INDEX "formularios_submitted_at_idx" ON "formularios"("submitted_at"); + +-- CreateIndex +CREATE INDEX "respuestas_formulario_id_idx" ON "respuestas"("formulario_id"); + +-- CreateIndex +CREATE INDEX "respuestas_key_idx" ON "respuestas"("key"); + +-- CreateIndex +CREATE INDEX "respuestas_seccion_idx" ON "respuestas"("seccion"); + +-- CreateIndex +CREATE INDEX "secciones_orden_idx" ON "secciones"("orden"); + +-- CreateIndex +CREATE UNIQUE INDEX "campos_key_key" ON "campos"("key"); + +-- CreateIndex +CREATE INDEX "campos_seccion_id_idx" ON "campos"("seccion_id"); + +-- CreateIndex +CREATE INDEX "campos_tipo_idx" ON "campos"("tipo"); + +-- CreateIndex +CREATE UNIQUE INDEX "admins_auth_user_id_key" ON "admins"("auth_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "admins_email_key" ON "admins"("email"); + +-- CreateIndex +CREATE INDEX "admins_email_idx" ON "admins"("email"); + +-- AddForeignKey +ALTER TABLE "respuestas" ADD CONSTRAINT "respuestas_formulario_id_fkey" FOREIGN KEY ("formulario_id") REFERENCES "formularios"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "campos" ADD CONSTRAINT "campos_seccion_id_fkey" FOREIGN KEY ("seccion_id") REFERENCES "secciones"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2a5a444..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "sqlite" +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2aa9ec2..2516d92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,8 +3,9 @@ generator client { } datasource db { - provider = "sqlite" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") } model Contact { @@ -16,4 +17,115 @@ model Contact { updatedAt DateTime @updatedAt @@index([email, createdAt]) -} \ No newline at end of file + @@map("contact") +} + +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? + 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]) + @@index([submitted_at]) + @@map("formularios") +} + +model Respuesta { + id String @id @default(uuid()) @db.Uuid + formulario_id String @db.Uuid + key String + value String + seccion String @default("") + created_at DateTime @default(now()) @db.Timestamptz + + formulario Formulario @relation(fields: [formulario_id], references: [id], onDelete: Cascade) + + @@index([formulario_id]) + @@index([key]) + @@index([seccion]) + @@map("respuestas") +} + +model Seccion { + id String @id @default(uuid()) @db.Uuid + title_key String + title String? + orden Int @default(0) + columns Int @default(1) + created_at DateTime @default(now()) @db.Timestamptz + + campos Campo[] + + @@index([orden]) + @@map("secciones") +} + +model Campo { + id String @id @default(uuid()) @db.Uuid + key String @unique + label_key String + tipo String + seccion_id String? @db.Uuid + required Boolean @default(false) + orden Int @default(0) + options Json? @db.JsonB + source String? + created_at DateTime @default(now()) @db.Timestamptz + + seccion Seccion? @relation(fields: [seccion_id], references: [id], onDelete: SetNull) + + @@index([seccion_id]) + @@index([tipo]) + @@map("campos") +} + +model FormVersion { + id String @id @default(uuid()) @db.Uuid + version String + config Json @db.JsonB + active Boolean @default(false) + created_at DateTime @default(now()) @db.Timestamptz + + @@map("form_versions") +} + +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") + activo Boolean @default(true) + ultimo_acceso DateTime? @db.Timestamptz + created_at DateTime @default(now()) @db.Timestamptz + + @@index([email]) + @@map("admins") +} diff --git a/src/pages/api/formulario/send.ts b/src/pages/api/formulario/send.ts index fdb9b40..394b917 100644 --- a/src/pages/api/formulario/send.ts +++ b/src/pages/api/formulario/send.ts @@ -1,6 +1,7 @@ import type { APIRoute } from "astro"; import { appendRegistrationToSheet } from "../lib/googleSheets"; import { sendEmailCf } from "../lib/email"; +import { saveFormularioToSupabase } from "../lib/formularioDb"; export const prerender = false; const COLUMNS = [ @@ -101,6 +102,11 @@ export const POST: APIRoute = async ({ request }) => { timeZone: "America/Puerto_Rico", }); + const form = await saveFormularioToSupabase( + formData, + body.responses || [] + ); + const row = COLUMNS.map((key) => flattenValue(formData[key])); row.push(timestamp); @@ -111,7 +117,10 @@ export const POST: APIRoute = async ({ request }) => { apellidos: formData.apellido, }, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"); - return Response.json(result); + return Response.json({ + ...result, + numero_voluntario: form.numero_voluntario, + }); } catch (error) { console.error("Error en /api/formulario/send:", error); return Response.json( diff --git a/src/pages/api/lib/formularioDb.ts b/src/pages/api/lib/formularioDb.ts new file mode 100644 index 0000000..ea9ffec --- /dev/null +++ b/src/pages/api/lib/formularioDb.ts @@ -0,0 +1,103 @@ +import { prisma } from "./prisma"; + +type FormData = Record; +type RespuestaPayload = { + key: string; + value: unknown; + section_id?: string; +}; + +const CORE_KEYS = new Set([ + "nombre", + "segundo_nombre", + "apellido", + "documentos", + "correo", + "fecha_nacimiento", + "nacionalidad", + "sexo", + "direccion_completa", + "ciudad", + "estado", + "pais", + "postal", + "telefono", + "whatsapp", + "profesion", + "lugar_trabajo_actual", + "nivel_academico", + "nombre_completo", + "confirmar_nombre", + "confirmar_firma", +]); + +function isBlank(v: unknown): boolean { + return v === undefined || v === null || v === ""; +} + +export async function saveFormularioToSupabase( + formData: FormData, + responses: RespuestaPayload[] +) { + const core = { + nombre: (formData.nombre as string) ?? "", + segundo_nombre: (formData.segundo_nombre as string) || null, + apellido: (formData.apellido as string) ?? "", + documento_nro: (formData.documentos as string) || null, + correo: (formData.correo as string) ?? "", + fecha_nacimiento: formData.fecha_nacimiento + ? new Date(formData.fecha_nacimiento as string) + : null, + nacionalidad: (formData.nacionalidad as string) || null, + sexo: (formData.sexo as string) || null, + direccion: (formData.direccion_completa as string) || null, + ciudad: (formData.ciudad as string) || null, + estado: (formData.estado as string) || null, + pais: (formData.pais as string) || null, + codigo_postal: (formData.postal as string) || null, + telefono: (formData.telefono as string) || null, + whatsapp: (formData.whatsapp as string) || null, + profesion: (formData.profesion as string) || null, + lugar_trabajo: (formData.lugar_trabajo_actual as string) || null, + nivel_academico: (formData.nivel_academico as string) || null, + }; + + const respRows: { + key: string; + value: string; + seccion: string; + }[] = []; + + for (const r of responses || []) { + if (CORE_KEYS.has(r.key)) continue; + if (isBlank(r.value)) continue; + + if (Array.isArray(r.value)) { + for (const v of r.value) { + if (!isBlank(v)) { + respRows.push({ + key: r.key, + value: String(v), + seccion: r.section_id ?? "", + }); + } + } + } else { + respRows.push({ + key: r.key, + value: String(r.value), + seccion: r.section_id ?? "", + }); + } + } + + return prisma.$transaction(async (tx) => { + const form = await tx.formulario.create({ data: core }); + if (respRows.length) { + await tx.respuesta.createMany({ + data: respRows.map((r) => ({ ...r, formulario_id: form.id })), + }); + } + return form; + }); +} diff --git a/src/pages/api/lib/prisma.ts b/src/pages/api/lib/prisma.ts index fa60f1f..b950897 100644 --- a/src/pages/api/lib/prisma.ts +++ b/src/pages/api/lib/prisma.ts @@ -7,4 +7,4 @@ const globalForPrisma = globalThis as unknown as { export const prisma = globalForPrisma.prisma ?? new PrismaClient(); -if (import.meta.env.DEV) globalForPrisma.prisma = prisma; \ No newline at end of file +if (import.meta.env?.DEV) globalForPrisma.prisma = prisma; \ No newline at end of file From d30e57f006715e6f212496a16b32d85d5379bb67 Mon Sep 17 00:00:00 2001 From: esteban Date: Thu, 6 Aug 2026 19:54:50 -0500 Subject: [PATCH 2/9] add migration and config for database --- .gitignore | 4 ++++ package.json | 5 ++++- src/pages/api/emailPostulacion/send.ts | 17 +++++++++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1bf6fd4..dd06050 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ prisma/dev.db opencode/ .opencode/ docs/ + +# scripts de migración (locales, no en producción) +scripts/migrate-sheets-to-db.ts +scripts/seed-form-config.ts diff --git a/package.json b/package.json index 9d34b4c..ebab4fd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "astro build", "postbuild": "node scripts/send-to-n8n.js", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "db:migrate-sheets": "npx tsx scripts/migrate-sheets-to-db.ts", + "db:seed-form-config": "npx tsx scripts/seed-form-config.ts", + "db:setup": "npx tsx scripts/seed-form-config.ts && npx tsx scripts/migrate-sheets-to-db.ts" }, "dependencies": { "@astrojs/markdoc": "^2.0.3", diff --git a/src/pages/api/emailPostulacion/send.ts b/src/pages/api/emailPostulacion/send.ts index 0e3d154..d89e270 100644 --- a/src/pages/api/emailPostulacion/send.ts +++ b/src/pages/api/emailPostulacion/send.ts @@ -1,6 +1,7 @@ import type { APIRoute } from "astro"; import { appendToSheet, emailExists } from "../lib/googleSheets"; import { sendEmailCf } from "../lib/email"; +import { prisma } from "../lib/prisma"; export const prerender = false; export const POST: APIRoute = async ({ request }) => { @@ -41,10 +42,22 @@ export const POST: APIRoute = async ({ request }) => { new Date().toLocaleString() ]; - // 🔹 Enviar correo (Cloudflare) y guardar en Google Sheets + // 🔹 Enviar correo (Cloudflare), guardar en Google Sheets y en PostgreSQL await Promise.all([ sendEmailCf({ nombres, apellidos }, email, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"), - appendToSheet(valuesForSheets) + appendToSheet(valuesForSheets), + prisma.formulario.create({ + data: { + nombre: nombres!, + apellido: apellidos!, + pais: pais || null, + ciudad: lugar || null, + direccion: direccion || null, + telefono: telefono || null, + correo: email!, + status: "legacy", + }, + }), ]); // 🔹 Redirección elegante después de enviar From a5d736a30d3f1b1b3d38ca7893878d87a4b3134b Mon Sep 17 00:00:00 2001 From: esteban Date: Fri, 14 Aug 2026 21:17:53 -0500 Subject: [PATCH 3/9] second commit for data base and prisma shema --- .../migration.sql | 23 + prisma/schema.prisma | 7 + public/admin/index.html | 32 -- src/components/admin/VoluntariosTable.vue | 482 ++++++++++++++++++ src/middleware.ts | 12 + src/pages/[locale]/admin/index.astro | 30 ++ src/pages/api/admin/voluntarios.ts | 230 +++++++++ src/pages/api/admin/voluntarios/options.ts | 56 ++ 8 files changed, 840 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql delete mode 100644 public/admin/index.html create mode 100644 src/components/admin/VoluntariosTable.vue create mode 100644 src/middleware.ts create mode 100644 src/pages/[locale]/admin/index.astro create mode 100644 src/pages/api/admin/voluntarios.ts create mode 100644 src/pages/api/admin/voluntarios/options.ts diff --git a/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql b/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql new file mode 100644 index 0000000..7ca2f3a --- /dev/null +++ b/prisma/migrations/20260813010740_add_trigram_search_indexes/migration.sql @@ -0,0 +1,23 @@ +-- Enable pg_trgm for trigram-based GIN indexes +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- CreateIndex +CREATE INDEX "formularios_nombre_trgm_idx" ON "formularios" USING GIN ("nombre" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_apellido_trgm_idx" ON "formularios" USING GIN ("apellido" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_correo_trgm_idx" ON "formularios" USING GIN ("correo" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_telefono_trgm_idx" ON "formularios" USING GIN ("telefono" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_whatsapp_trgm_idx" ON "formularios" USING GIN ("whatsapp" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_documento_nro_trgm_idx" ON "formularios" USING GIN ("documento_nro" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "respuestas_value_trgm_idx" ON "respuestas" USING GIN ("value" gin_trgm_ops); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2516d92..b028508 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,12 @@ model Formulario { @@index([status]) @@index([pais]) @@index([submitted_at]) + @@index([nombre(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nombre_trgm_idx") + @@index([apellido(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_apellido_trgm_idx") + @@index([correo(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_correo_trgm_idx") + @@index([telefono(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_telefono_trgm_idx") + @@index([whatsapp(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_whatsapp_trgm_idx") + @@index([documento_nro(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_documento_nro_trgm_idx") @@map("formularios") } @@ -70,6 +76,7 @@ model Respuesta { @@index([formulario_id]) @@index([key]) @@index([seccion]) + @@index([value(ops: raw("gin_trgm_ops"))], type: Gin, map: "respuestas_value_trgm_idx") @@map("respuestas") } diff --git a/public/admin/index.html b/public/admin/index.html deleted file mode 100644 index 13d19a5..0000000 --- a/public/admin/index.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - TinaCMS - - - - - - - - -
- - \ No newline at end of file diff --git a/src/components/admin/VoluntariosTable.vue b/src/components/admin/VoluntariosTable.vue new file mode 100644 index 0000000..a03e826 --- /dev/null +++ b/src/components/admin/VoluntariosTable.vue @@ -0,0 +1,482 @@ + + + diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..18c8aeb --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,12 @@ +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware((context, next) => { + const { url, redirect } = context; + const { pathname } = url; + + if (pathname === "/admin" || pathname === "/admin/") { + return redirect("/es/admin", 302); + } + + return next(); +}); diff --git a/src/pages/[locale]/admin/index.astro b/src/pages/[locale]/admin/index.astro new file mode 100644 index 0000000..6709d1b --- /dev/null +++ b/src/pages/[locale]/admin/index.astro @@ -0,0 +1,30 @@ +--- +import "../../../styles/global.css"; +import "@fontsource/poppins/400.css"; +import "@fontsource/poppins/500.css"; +import "@fontsource/poppins/700.css"; +import VoluntariosTable from "../../../components/admin/VoluntariosTable.vue"; +--- + + + + + + Dashboard de Voluntarios + + + +
+
+
+

Dashboard de Voluntarios

+

Registros migrados desde Google Sheets

+
+ sin auth +
+
+
+ +
+ + diff --git a/src/pages/api/admin/voluntarios.ts b/src/pages/api/admin/voluntarios.ts new file mode 100644 index 0000000..849482e --- /dev/null +++ b/src/pages/api/admin/voluntarios.ts @@ -0,0 +1,230 @@ +import type { APIRoute } from "astro"; +import { prisma } from "../lib/prisma"; +import type { Prisma } from "@prisma/client"; + +export const prerender = false; + +const SORT_FIELDS = new Set([ + "numero_voluntario", + "submitted_at", + "nombre", + "apellido", + "correo", + "status", + "pais", +]); + +const SEARCHABLE_FIELDS = [ + "nombre", + "segundo_nombre", + "apellido", + "correo", + "telefono", + "whatsapp", + "documento_nro", + "ciudad", + "profesion", + "nivel_academico", + "nacionalidad", +] as const; + +const CORE_TEXT_FILTERS = [ + "ciudad", + "estado", + "profesion", + "nivel_academico", + "documento_nro", +] as const; + +const CORE_EXACT_FILTERS = [ + "sexo", + "nacionalidad", + "form_version", +] as const; + +const RESPONSE_FILTERS = [ + "idioma", + "areas_colaborar", + "dias_disponibles", + "horario_preferido", + "fuera_ciudad", + "misiones_internacionales", + "condicion_medica", + "voluntariado_anterior", +] as const; + +function splitList(v: string): string[] { + return v + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function tokenToOr(token: string): Prisma.FormularioWhereInput { + const or: Prisma.FormularioWhereInput[] = SEARCHABLE_FIELDS.map((field) => ({ + [field]: { contains: token, mode: "insensitive" }, + })); + or.push({ + respuestas: { + some: { value: { contains: token, mode: "insensitive" } }, + }, + }); + if (/^\d+$/.test(token)) { + or.push({ numero_voluntario: { equals: Number(token) } }); + } + return { OR: or }; +} + +function buildSearchWhere(q: string): Prisma.FormularioWhereInput { + const tokens = q.split(/\s+/).filter(Boolean); + if (tokens.length === 1) return tokenToOr(tokens[0]); + return { AND: tokens.map(tokenToOr) }; +} + +function buildDateRange(desde: string, hasta: string): Prisma.FormularioWhereInput | null { + const filter: Prisma.DateTimeFilter = {}; + if (desde) filter.gte = new Date(desde); + if (hasta) { + const h = new Date(hasta); + h.setHours(23, 59, 59, 999); + filter.lte = h; + } + if (Object.keys(filter).length === 0) return null; + return { submitted_at: filter }; +} + +function buildAgeRange(min: string, max: string): Prisma.FormularioWhereInput | null { + const filter: Prisma.DateTimeFilter = {}; + const now = new Date(); + if (min) { + const maxBirth = new Date(now); + maxBirth.setFullYear(now.getFullYear() - Number(min)); + filter.lte = maxBirth; + } + if (max) { + const minBirth = new Date(now); + minBirth.setFullYear(now.getFullYear() - Number(max) - 1); + minBirth.setDate(minBirth.getDate() + 1); + filter.gte = minBirth; + } + if (Object.keys(filter).length === 0) return null; + return { fecha_nacimiento: filter }; +} + +function buildResponseFilter(key: string, values: string[]): Prisma.FormularioWhereInput { + const or: Prisma.FormularioWhereInput[] = values.map((v) => ({ + respuestas: { + some: { key, value: { contains: v, mode: "insensitive" } }, + }, + })); + return or.length === 1 ? or[0] : { OR: or }; +} + +export const GET: APIRoute = async ({ url }) => { + try { + const sp = url.searchParams; + const page = Math.max(1, parseInt(sp.get("page") || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt(sp.get("limit") || "25", 10))); + const q = (sp.get("q") || "").trim(); + const status = (sp.get("status") || "").trim(); + const pais = (sp.get("pais") || "").trim(); + const sort = SORT_FIELDS.has(sp.get("sort") || "") ? sp.get("sort")! : "submitted_at"; + const order = sp.get("order") === "asc" ? "asc" : "desc"; + + const conditions: Prisma.FormularioWhereInput[] = []; + + if (q) conditions.push(buildSearchWhere(q)); + + const dateRange = buildDateRange((sp.get("desde") || "").trim(), (sp.get("hasta") || "").trim()); + if (dateRange) conditions.push(dateRange); + + const ageRange = buildAgeRange((sp.get("edad_min") || "").trim(), (sp.get("edad_max") || "").trim()); + if (ageRange) conditions.push(ageRange); + + if (status) conditions.push({ status: { in: splitList(status) } }); + if (pais) conditions.push({ pais: { in: splitList(pais) } }); + + for (const field of CORE_TEXT_FILTERS) { + const v = (sp.get(field) || "").trim(); + if (v) conditions.push({ [field]: { contains: v, mode: "insensitive" } }); + } + + for (const field of CORE_EXACT_FILTERS) { + const v = (sp.get(field) || "").trim(); + if (v) conditions.push({ [field]: { in: splitList(v) } }); + } + + for (const key of RESPONSE_FILTERS) { + const v = (sp.get(key) || "").trim(); + if (v) conditions.push(buildResponseFilter(key, splitList(v))); + } + + const hasWhatsapp = (sp.get("has_whatsapp") || "").trim(); + if (hasWhatsapp) { + conditions.push(hasWhatsapp === "true" ? { whatsapp: { not: null } } : { whatsapp: null }); + } + + const emailSent = (sp.get("email_sent") || "").trim(); + if (emailSent) { + conditions.push(emailSent === "true" ? { email_sent_at: { not: null } } : { email_sent_at: null }); + } + + const where: Prisma.FormularioWhereInput = conditions.length ? { AND: conditions } : {}; + + const [total, rows] = await Promise.all([ + prisma.formulario.count({ where }), + prisma.formulario.findMany({ + where, + include: { respuestas: { select: { key: true, value: true } } }, + orderBy: { [sort]: order }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + const data = rows.map((f) => { + const respuestas: Record = {}; + for (const r of f.respuestas) { + respuestas[r.key] = respuestas[r.key] + ? `${respuestas[r.key]}, ${r.value}` + : r.value; + } + return { + id: f.id, + numero_voluntario: f.numero_voluntario, + nombre_completo: [f.nombre, f.segundo_nombre, f.apellido] + .filter(Boolean) + .join(" "), + nombre: f.nombre, + segundo_nombre: f.segundo_nombre, + apellido: f.apellido, + correo: f.correo, + telefono: f.telefono, + whatsapp: f.whatsapp, + pais: f.pais, + ciudad: f.ciudad, + estado: f.estado, + profesion: f.profesion, + nivel_academico: f.nivel_academico, + status: f.status, + submitted_at: f.submitted_at, + ...respuestas, + }; + }); + + return Response.json({ + success: true, + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }); + } catch (error) { + console.error("Error en /api/admin/voluntarios:", error); + return Response.json( + { success: false, message: "Error al obtener voluntarios" }, + { status: 500 } + ); + } +}; diff --git a/src/pages/api/admin/voluntarios/options.ts b/src/pages/api/admin/voluntarios/options.ts new file mode 100644 index 0000000..bf8ce33 --- /dev/null +++ b/src/pages/api/admin/voluntarios/options.ts @@ -0,0 +1,56 @@ +import type { APIRoute } from "astro"; +import { prisma } from "../../lib/prisma"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + try { + const [paises, statuses, nacionalidades, form_versions] = await Promise.all([ + prisma.formulario.findMany({ + distinct: ["pais"], + select: { pais: true }, + where: { pais: { not: null } }, + }), + prisma.formulario.findMany({ + distinct: ["status"], + select: { status: true }, + }), + prisma.formulario.findMany({ + distinct: ["nacionalidad"], + select: { nacionalidad: true }, + where: { nacionalidad: { not: null } }, + }), + prisma.formulario.findMany({ + distinct: ["form_version"], + select: { form_version: true }, + where: { form_version: { not: null } }, + }), + ]); + + return Response.json({ + success: true, + paises: paises + .map((p) => p.pais) + .filter((p): p is string => Boolean(p)) + .sort((a, b) => a.localeCompare(b, "es")), + statuses: statuses + .map((s) => s.status) + .filter((s): s is string => Boolean(s)) + .sort(), + nacionalidades: nacionalidades + .map((n) => n.nacionalidad) + .filter((n): n is string => Boolean(n)) + .sort((a, b) => a.localeCompare(b, "es")), + form_versions: form_versions + .map((f) => f.form_version) + .filter((f): f is string => Boolean(f)) + .sort(), + }); + } catch (error) { + console.error("Error en /api/admin/voluntarios/options:", error); + return Response.json( + { success: false, message: "Error al obtener opciones" }, + { status: 500 } + ); + } +}; From 3351fb3330f3966b6ba77c0274c267eb1f248fbd Mon Sep 17 00:00:00 2001 From: esteban Date: Sat, 15 Aug 2026 21:11:40 -0500 Subject: [PATCH 4/9] multiple fixes, create dashboard finish table TODO: middleware and auth --- .gitignore | 3 + .../migration.sql | 14 + prisma/schema.prisma | 5 + src/components/Header.astro | 21 ++ src/components/admin/AdminDashboard.vue | 209 +++++++++++ src/components/admin/SearchableCombobox.vue | 178 +++++++++ src/components/admin/VoluntarioDetail.vue | 284 ++++++++++++++ src/components/admin/VoluntariosTable.vue | 354 ++++++++++++------ src/layouts/MainLayout.astro | 4 +- src/pages/[locale]/admin/dashboard.astro | 30 ++ src/pages/[locale]/admin/index.astro | 50 ++- .../[locale]/admin/voluntario/[numero].astro | 26 ++ src/pages/api/admin/voluntarios.ts | 44 ++- src/pages/api/admin/voluntarios/[numero].ts | 97 +++++ src/pages/api/admin/voluntarios/distinct.ts | 56 +++ src/pages/api/admin/voluntarios/stats.ts | 138 +++++++ src/pages/api/emailPostulacion/send.ts | 5 +- src/pages/api/formulario/send.ts | 17 +- src/pages/api/lib/emailsRegistry.ts | 22 -- src/pages/api/lib/formularioDb.ts | 10 + src/pages/api/voluntarios/count.ts | 4 +- src/styles/global.css | 34 ++ 22 files changed, 1413 insertions(+), 192 deletions(-) create mode 100644 prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql create mode 100644 src/components/admin/AdminDashboard.vue create mode 100644 src/components/admin/SearchableCombobox.vue create mode 100644 src/components/admin/VoluntarioDetail.vue create mode 100644 src/pages/[locale]/admin/dashboard.astro create mode 100644 src/pages/[locale]/admin/voluntario/[numero].astro create mode 100644 src/pages/api/admin/voluntarios/[numero].ts create mode 100644 src/pages/api/admin/voluntarios/distinct.ts create mode 100644 src/pages/api/admin/voluntarios/stats.ts delete mode 100644 src/pages/api/lib/emailsRegistry.ts diff --git a/.gitignore b/.gitignore index 3148277..291f341 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ opencode/ .opencode/ docs/ +# guía técnica local +docs/GUIA-TECNICA.md + # scripts de migración (locales, no en producción) scripts/migrate-sheets-to-db.ts scripts/seed-form-config.ts diff --git a/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql b/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql new file mode 100644 index 0000000..9295739 --- /dev/null +++ b/prisma/migrations/20260815180714_add_trigram_fuzzy_indexes/migration.sql @@ -0,0 +1,14 @@ +-- CreateIndex +CREATE INDEX "formularios_ciudad_trgm_idx" ON "formularios" USING GIN ("ciudad" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_estado_trgm_idx" ON "formularios" USING GIN ("estado" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_profesion_trgm_idx" ON "formularios" USING GIN ("profesion" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_nivel_academico_trgm_idx" ON "formularios" USING GIN ("nivel_academico" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "formularios_nacionalidad_trgm_idx" ON "formularios" USING GIN ("nacionalidad" gin_trgm_ops); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b028508..6358ffc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,11 @@ model Formulario { @@index([telefono(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_telefono_trgm_idx") @@index([whatsapp(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_whatsapp_trgm_idx") @@index([documento_nro(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_documento_nro_trgm_idx") + @@index([ciudad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_ciudad_trgm_idx") + @@index([estado(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_estado_trgm_idx") + @@index([profesion(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_profesion_trgm_idx") + @@index([nivel_academico(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nivel_academico_trgm_idx") + @@index([nacionalidad(ops: raw("gin_trgm_ops"))], type: Gin, map: "formularios_nacionalidad_trgm_idx") @@map("formularios") } diff --git a/src/components/Header.astro b/src/components/Header.astro index f210430..679de8a 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -12,6 +12,7 @@ const tl = createTranslator(Astro.currentLocale); const currentLocale = Astro.currentLocale; const currentPath = Astro.url.pathname; const { locale } = Astro.params; +const isAdmin = currentPath.split("/").includes("admin"); const languages = [ { code: "es", icon: "icon_flag_es", label: "Español" }, @@ -156,6 +157,16 @@ function translatePath(newLocale: string) { )) } + {isAdmin && ( +
  • + + Acceder + +
  • + )}
    + {isAdmin && ( + + )} {isAdmin && ( )}