From a5d736a30d3f1b1b3d38ca7893878d87a4b3134b Mon Sep 17 00:00:00 2001 From: esteban Date: Fri, 14 Aug 2026 21:17:53 -0500 Subject: [PATCH] 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 } + ); + } +};