diff --git a/.env b/.env
deleted file mode 100644
index b8af0aa..0000000
--- a/.env
+++ /dev/null
@@ -1,35 +0,0 @@
-# Archivo de variables de entorno para la configuración de Google Sheets
-GOOGLE_SERVICE_ACCOUNT_EMAIL=dbsheets@dbsheets-487314.iam.gserviceaccount.com
-GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCi2DVTbgH5WzE4\nrsT4BLcjplwszwkiZmhUxcVma/xI/6G+LQpzWTmmETmPWGFBpPukcoEDJDIugSFF\nuiKM+cSJIJJTYYWb5sBGXC6bi36AiA3W/zuRYGoFdNP+34iqepWpXpjKY8T0bbJJ\njhIgjW/SN2fYZeVBZqLATEzRqCcPYXI5gs9fyb2bzTEjiB3kCklVCudl/JDoxAWm\nJ9vO/OOXEgt/t97v9cMoo9OZ4Y3txqiQB41UdS97btDvpezc59u+8K9s5FT+yljC\nMyfRiRM+4MpycCoJobiT8NnCaNblMTd8mkfuujrFvnT/k9O794gBOJ2sgF7AYej4\nzm9tXpknAgMBAAECggEAM50bqcngTkydGT4rqAC2m1ILPRiR6JlU0CmvG3t2hxyf\nA0v7V/wbzYsAF3MHwGquZ7zzLy+1yA+doCAS+2Pe6yeruUnbs0I8BxEIpLxd6Bc3\na8GR+833TKtu1gW7p20bKoeVHfDpaB+stquVC0RJAPQWsfv54fTJ+PE2F+2YE8Np\njhWvM4OkUNZVBPZxG+RF0gzylIx2tw3QpLN3DMSr9Z2wGfohp7yryP96qqC1RFGQ\nCsaHsPRfzv+UNinGmA0Yzv79Stuqgsg+J3V/01Pm4ANutHix/kP0YAYCyg29UtVZ\nIJZtPOAfHWICYw+hQMpcfSLWzbkUQpwTTG0Bk6xmwQKBgQDUNf226UzdAm/V2Pag\nc1YF4P6Bru1A5jiFTpyrAYPSN+Wk84Gkl8XtuDDb7zXL+MaVb8aIVXTKhM2b0RDw\nHtDayoCCXci8VfP4sDRzjEGB1O1OPOl/wPD6L94CmNB0QeepNs4Q5xD0HEsoNN6s\nZGafIkIbVVUBCkuZwNxoLVXSKQKBgQDEcnKkgE/ror9vXSj0XY9Rn0wU2W6DreUt\nMizWyH9R0wBbaSJQ/4Wd3F6co2H2DSuZgXxLJvsPlqffUivs/oZnlx8HhWi6vjLL\nvSiqNC1D+Bh5TT7PEonENU99lruKtZfKcMpRtVF6WycW5ZlYok4lRCEBBvbjtGgr\nIHnqc8WazwKBgD0oHilDjQI4fJBtiY3uD0F8ePxYtEk6z9kjMaVat2my8DycbKVJ\nlCIRLguEIcXZuSlB4D5UGmdu5G5eTvpUdy5Go8huTLZyMvBdn5AZQJuxCH6+sTA/\nI3OGrf2jmpeWkrPCdpqKbOlc6g/5RJ78BFMiJVkJO4kkNWtnGF3xisUZAoGAJ8VT\n2GHxzC7CuvUNWYunfErXyFDMwvPttdy2nzUwMYyaQedi/yr8Dh2TOfsJ1hqMfSNt\n2nkl0t4ZVmj4Y56T71z09zzXGxduiTjehrbRgzUzHzu4P4vtQD/au/5MMaTZ+i4j\nh69Bs5fIriYFiaAyWfEVDXQvf6IChNiqVgDiPJkCgYEAooIiW4HHVUNGMnIeB7UI\nBXv5gSs2heHYSmEK+Er7In5cfJQzkcJvwiOMfOPCNuwVPn+ow5IN8le1acDon5I2\n0OsXhWmgyg9zmgguj273mH3h6+o6XTg5GrEYgPVpU3m2dJkd05oWhQvzy7/OxHs8\nE4ZMz97uqmU2r8XJCo//tv8=\n-----END PRIVATE KEY-----\n"
-GOOGLE_SHEET_ID=12X6qeU0W4ZDw00vS4OreyC4nFjKBeBYo7rq3wyRsrNQ
-
-# Archivo de variables de entorno para la configuración de envío de emails
-EMAIL_API_KEY=9UShpS8oh5Iun92TJSfevElI3Lp99TCv
-
-# This was inserted by prisma init:
-# Environment variables declared in this file are NOT automatically loaded by Prisma.
-# Please add import "dotenv/config"; to your prisma.config.ts file, or use the Prisma CLI with Bun
-# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
-
-# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
-# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
-
-# The following prisma+postgres URL is similar to the URL produced by running a local Prisma Postgres
-# 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"
-
-# Cloudflare Email Sending
-CLOUDFLARE_API_TOKEN=cfut_8Y0Tcwiv3v0K1LDK9QSvc3BjlU1lZFs1zikACgTw33977b37
-CLOUDFLARE_ACCOUNT_ID=3e689750123db91e81d3542d2930a907
-
-# Cloudflare Turnstile — public key (visible in frontend)
-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
diff --git a/.env.example b/.env.example
index 1162174..c366ac7 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,12 @@ 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
+
+# Admin auth — API key (para consumo externo de /api/admin/*)
+ADMIN_API_KEY="CHANGE_ME_admin_api_key"
+
+# Admin auth — credenciales del seed (solo se usan en scripts/seed-admin.ts)
+ADMIN_EMAIL="admin@example.com"
+ADMIN_USERNAME="admin"
+ADMIN_PASSWORD="CHANGE_ME_password"
+ADMIN_NOMBRE="Admin"
diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
index c710ad6..264d40f 100644
--- a/.gitea/workflows/deploy.yaml
+++ b/.gitea/workflows/deploy.yaml
@@ -37,7 +37,7 @@ jobs:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
- source: "dist/*,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,ecosystem.config.cjs"
+ source: "dist/*,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,ecosystem.config.cjs,prisma/schema.prisma,prisma/migrations,prisma.config.ts"
target: "${{ env.TARGET_DIR }}"
- name: Restart PM2
@@ -52,10 +52,21 @@ jobs:
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npm install -g pnpm@11 2>&1
cd ${{ env.TARGET_DIR }}
+ cat > .env <<'ENVEOF'
+ DATABASE_URL="${{ secrets.DATABASE_URL }}"
+ DIRECT_URL="${{ secrets.DIRECT_URL }}"
+ GOOGLE_SERVICE_ACCOUNT_EMAIL="${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}"
+ GOOGLE_PRIVATE_KEY="${{ secrets.GOOGLE_PRIVATE_KEY }}"
+ GOOGLE_SHEET_ID="${{ secrets.GOOGLE_SHEET_ID }}"
+ EMAIL_API_KEY="${{ secrets.EMAIL_API_KEY }}"
+ TURNSTILE_SITE_KEY="${{ secrets.TURNSTILE_SITE_KEY }}"
+ TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
+ CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
+ CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
+ ADMIN_API_KEY="${{ secrets.ADMIN_API_KEY }}"
+ ENVEOF
pnpm install --prod 2>&1
- sed -i "s|TURNSTILE_SITE_KEY: \"\"|TURNSTILE_SITE_KEY: \"${{ secrets.TURNSTILE_SITE_KEY }}\"|g" ecosystem.config.cjs
- sed -i "s|TURNSTILE_SECRET_KEY: \"\"|TURNSTILE_SECRET_KEY: \"${{ secrets.TURNSTILE_SECRET_KEY }}\"|g" ecosystem.config.cjs
- sed -i "s|CLOUDFLARE_API_TOKEN: \"\"|CLOUDFLARE_API_TOKEN: \"${{ secrets.CLOUDFLARE_API_TOKEN }}\"|g" ecosystem.config.cjs
- sed -i "s|CLOUDFLARE_ACCOUNT_ID: \"\"|CLOUDFLARE_ACCOUNT_ID: \"${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\"|g" ecosystem.config.cjs
+ pnpm exec prisma generate 2>&1
+ pnpm exec prisma migrate deploy 2>&1
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
diff --git a/.gitignore b/.gitignore
index 8ad93c8..e6be9db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ pnpm-debug.log*
# environment variables
+.env
.env.keys
# macOS-specific files
@@ -31,5 +32,12 @@ data/
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
scripts/
.env.example
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
index 51ab06c..d03dcdb 100644
--- a/ecosystem.config.cjs
+++ b/ecosystem.config.cjs
@@ -2,26 +2,22 @@ module.exports = {
apps: [
{
name: "cdrdpyj",
+ cwd: __dirname,
script: "dist/server/entry.mjs",
+ interpreter_args: "--env-file=.env",
env: {
NODE_ENV: "staging",
PORT: 3310,
- TURNSTILE_SITE_KEY: "",
- TURNSTILE_SECRET_KEY: "",
- CLOUDFLARE_API_TOKEN: "",
- CLOUDFLARE_ACCOUNT_ID: "",
}
},
{
name: "cdrdpyj-live",
+ cwd: __dirname,
script: "dist/server/entry.mjs",
+ interpreter_args: "--env-file=.env",
env: {
NODE_ENV: "production",
PORT: 4321,
- TURNSTILE_SITE_KEY: "",
- TURNSTILE_SECRET_KEY: "",
- CLOUDFLARE_API_TOKEN: "",
- CLOUDFLARE_ACCOUNT_ID: "",
}
}
]
diff --git a/package.json b/package.json
index 9d34b4c..8e56ca6 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",
@@ -42,6 +45,7 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
- "daisyui": "^5.5.18"
+ "daisyui": "^5.5.18",
+ "dotenv": "^17.4.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4932aba..1ed8852 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -102,6 +102,9 @@ importers:
daisyui:
specifier: ^5.5.18
version: 5.5.20
+ dotenv:
+ specifier: ^17.4.2
+ version: 17.4.2
packages:
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/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/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/migrations/20260816105155_add_admin_auth/migration.sql b/prisma/migrations/20260816105155_add_admin_auth/migration.sql
new file mode 100644
index 0000000..3cfa438
--- /dev/null
+++ b/prisma/migrations/20260816105155_add_admin_auth/migration.sql
@@ -0,0 +1,25 @@
+-- AlterTable
+ALTER TABLE "admins" ALTER COLUMN "auth_user_id" DROP NOT NULL;
+
+-- AlterTable
+ALTER TABLE "admins" ADD COLUMN "password_hash" TEXT;
+
+-- CreateTable
+CREATE TABLE "sessions" (
+ "id" UUID NOT NULL,
+ "token_hash" TEXT NOT NULL,
+ "admin_id" UUID NOT NULL,
+ "expires_at" TIMESTAMPTZ NOT NULL,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
+
+-- CreateIndex
+CREATE INDEX "sessions_admin_id_idx" ON "sessions"("admin_id");
+
+-- AddForeignKey
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260816122101_add_admin_username/migration.sql b/prisma/migrations/20260816122101_add_admin_username/migration.sql
new file mode 100644
index 0000000..6fd773d
--- /dev/null
+++ b/prisma/migrations/20260816122101_add_admin_username/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "admins" ADD COLUMN "username" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "admins_username_key" ON "admins"("username");
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..95a8873 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,144 @@ 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])
+ @@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")
+ @@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")
+}
+
+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])
+ @@index([value(ops: raw("gin_trgm_ops"))], type: Gin, map: "respuestas_value_trgm_idx")
+ @@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
+ username String? @unique
+ nombre String
+ password_hash String?
+ rol String @default("coordinador")
+ activo Boolean @default(true)
+ ultimo_acceso DateTime? @db.Timestamptz
+ created_at DateTime @default(now()) @db.Timestamptz
+
+ sessions Session[]
+
+ @@index([email])
+ @@map("admins")
+}
+
+model Session {
+ id String @id @default(uuid()) @db.Uuid
+ token_hash String @unique
+ admin_id String @db.Uuid
+ expires_at DateTime @db.Timestamptz
+ created_at DateTime @default(now()) @db.Timestamptz
+
+ admin Admin @relation(fields: [admin_id], references: [id], onDelete: Cascade)
+
+ @@index([admin_id])
+ @@map("sessions")
+}
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/Header.astro b/src/components/Header.astro
index f210430..da02ec4 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,18 @@ function translatePath(newLocale: string) {
))
}
+ {isAdmin && (
+
+
+
+ )}
+ {isAdmin && (
+
+
+
+ )}
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+ Resumen
+
+
+
+
Total voluntarios
+
{{ fmt(stats.total) }}
+
+
+
Edad promedio
+
{{ stats.edad.promedio ?? "—" }} años
+
+
+
Femenino
+
{{ fmt(sexo.f) }}
+
{{ pct(sexo.f, sexo.total) }}% del total
+
+
+
Masculino
+
{{ fmt(sexo.m) }}
+
{{ pct(sexo.m, sexo.total) }}% del total
+
+
+
+
+
+
+
+ Idiomas
+
+
+
+
{{ lang.label }}
+
{{ fmt(lang.count) }}
+
{{ pct(lang.count, stats.total) }}% del total
+
+
+
+
+
+
+
+ Distribuciones
+
+
+
+
+
Áreas de colaboración
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Días disponibles
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Horario preferido
+
+
+
{{ item.label }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
Países
+
+
+
{{ item.value }}
+
+
{{ fmt(item.count) }}
+
+
+
+
+
+
+
+
+
+
+ Disponibilidad y salud
+
+
+
+
+
+ {{ m.title }}
+ Sí {{ m.pct }}% · No {{ 100 - m.pct }}%
+
+
+
+ Sí: {{ fmt(m.si) }} · No: {{ fmt(m.no) }}
+
+
+
+
+
+ Sexo
+ F {{ pct(sexo.f, sexo.total) }}% · M {{ pct(sexo.m, sexo.total) }}%
+
+
+
+ F: {{ fmt(sexo.f) }} · M: {{ fmt(sexo.m) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/SearchableCombobox.vue b/src/components/admin/SearchableCombobox.vue
new file mode 100644
index 0000000..9b3b2fc
--- /dev/null
+++ b/src/components/admin/SearchableCombobox.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/VoluntarioDetail.vue b/src/components/admin/VoluntarioDetail.vue
new file mode 100644
index 0000000..e3a3ed8
--- /dev/null
+++ b/src/components/admin/VoluntarioDetail.vue
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+ {{ data.nombre }} {{ data.segundo_nombre }} {{ data.apellido }}
+
+ #{{ data.numero_voluntario }}
+ {{ data.status }}
+
+
+
+
+ {{ sec.title }}
+
+
+
+
- {{ f.label }}
+
-
+
+
+ {{ item }}
+
+
+ {{ item }}
+
+
+ {{ f.displayText }}
+
+
+
+
+
+
+
+ Otros datos
+
+
+
+
- {{ r.key }}
+ - {{ r.value }}
+
+
+
+
+
+
+ Sistema
+
+
+
+
- ID
+ - {{ data.id }}
+
+
+
- Versión de formulario
+ - {{ data.form_version || "—" }}
+
+
+
- Correo enviado
+ - {{ formatDateTime(data.email_sent_at) }}
+
+
+
- Registrado
+ - {{ formatDateTime(data.submitted_at) }}
+
+
+
- Actualizado
+ - {{ formatDateTime(data.updated_at) }}
+
+
+
+
+
+
+
+
diff --git a/src/components/admin/VoluntariosTable.vue b/src/components/admin/VoluntariosTable.vue
new file mode 100644
index 0000000..6b5847c
--- /dev/null
+++ b/src/components/admin/VoluntariosTable.vue
@@ -0,0 +1,605 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusLabel }}
+ ▾
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chip.label }}
+
+
+
+
+
+
+
+
+
+
+ Ubicación
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Perfil
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preferencias y condiciones
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Nombre completo |
+ Correo |
+ Teléfono |
+ País |
+ Ciudad |
+ Idiomas |
+ Áreas |
+ Status |
+ Fecha |
+ Acción |
+
+
+
+
+ |
+
+ |
+
+
+ | {{ error }} |
+
+
+ | Sin resultados |
+
+
+ | {{ row.numero_voluntario }} |
+ {{ row.nombre_completo }} |
+ {{ row.correo }} |
+ {{ row.telefono || "—" }} |
+ {{ row.pais || "—" }} |
+ {{ row.ciudad || "—" }} |
+ {{ row.idioma || "—" }} |
+ {{ row.areas_colaborar || "—" }} |
+
+ {{ row.status || "pendiente" }}
+ |
+ {{ formatDate(row.submitted_at) }} |
+
+
+ |
+
+
+
+
+
+
+
+
+ {{ total }} registros · página {{ page }} de {{ totalPages || 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Detalle del voluntario
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/MainLayout.astro b/src/layouts/MainLayout.astro
index 432452b..dbb9bce 100644
--- a/src/layouts/MainLayout.astro
+++ b/src/layouts/MainLayout.astro
@@ -8,7 +8,7 @@ import "@fontsource/poppins/700.css";
import "@fontsource-variable/kameron";
import ShareSticky from "../components/ShareSticky.vue";
import { routeTranslations } from "../i18n";
-const { title, description, image, url, date, noindex } = Astro.props;
+const { title, description, image, url, date, noindex, theme } = Astro.props;
const currentLocale = Astro.currentLocale ?? "es";
const direction = currentLocale === "he" ? "rtl" : "ltr";
@@ -21,7 +21,7 @@ const isNewsPage = contentSegments.some((segment) =>
);
---
-
+
{
+ const ttl = options.ttl ?? 60;
+ const key = PREFIX + url;
+ const now = Date.now();
+
+ try {
+ const raw = sessionStorage.getItem(key);
+ if (raw) {
+ const entry = JSON.parse(raw) as CacheEntry;
+ if (
+ entry &&
+ typeof entry.expiresAt === "number" &&
+ entry.expiresAt > now
+ ) {
+ return new Response(entry.body, {
+ status: entry.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ }
+ } catch {
+ // sessionStorage no disponible o entrada inválida
+ }
+
+ const res = await fetch(url);
+
+ if (res.ok) {
+ try {
+ const body = await res.text();
+ sessionStorage.setItem(
+ key,
+ JSON.stringify({
+ body,
+ status: res.status,
+ expiresAt: now + ttl * 1000,
+ })
+ );
+ return new Response(body, {
+ status: res.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ } catch {
+ // no se pudo cachear, devolver la respuesta original
+ }
+ }
+
+ return res;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..0d6bb1f
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,51 @@
+import { defineMiddleware } from "astro:middleware";
+import { getSession, isApiAuthorized } from "./pages/api/lib/auth";
+
+const LOCALES = new Set(["es", "en", "fr", "he", "uk", "pt", "ru", "rw", "kr"]);
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ const { url, request, redirect } = context;
+ const pathname = url.pathname;
+
+ if (pathname === "/admin" || pathname === "/admin/") {
+ return redirect("/es/admin", 302);
+ }
+
+ if (pathname.startsWith("/api/admin/")) {
+ const isAuthEndpoint =
+ pathname.endsWith("/auth/login") || pathname.endsWith("/auth/logout");
+
+ if (request.method !== "GET" && !isAuthEndpoint) {
+ return new Response(
+ JSON.stringify({ success: false, message: "Method not allowed" }),
+ { status: 405, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ if (!isAuthEndpoint && !(await isApiAuthorized(request))) {
+ return new Response(
+ JSON.stringify({ success: false, message: "Unauthorized" }),
+ { status: 401, headers: { "Content-Type": "application/json" } }
+ );
+ }
+
+ return next();
+ }
+
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length >= 2 && segments[1] === "admin") {
+ const locale = LOCALES.has(segments[0]) ? segments[0] : "es";
+ const isLogin = segments[2] === "login";
+ const session = await getSession(request);
+
+ if (!isLogin && !session) {
+ return redirect(`/${locale}/admin/login`, 302);
+ }
+
+ if (isLogin && session) {
+ return redirect(`/${locale}/admin`, 302);
+ }
+ }
+
+ return next();
+});
diff --git a/src/pages/[locale]/[section]/index.astro b/src/pages/[locale]/[section]/index.astro
index 73d4194..ea1dd16 100644
--- a/src/pages/[locale]/[section]/index.astro
+++ b/src/pages/[locale]/[section]/index.astro
@@ -11,6 +11,7 @@ const tl = createTranslator(Astro.currentLocale);
const { locale, section } = Astro.params;
const routeKey = getRouteKeyFromSlug(section);
+const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
if (routeKey === "formulario") {
if (!["es", "en", "pt"].includes(Astro.currentLocale)) {
@@ -62,7 +63,7 @@ if (routeKey === "formulario") {
{tl("form.page_title")}
-
+
)}
diff --git a/src/pages/[locale]/admin/dashboard.astro b/src/pages/[locale]/admin/dashboard.astro
new file mode 100644
index 0000000..e3342f9
--- /dev/null
+++ b/src/pages/[locale]/admin/dashboard.astro
@@ -0,0 +1,30 @@
+---
+import MainLayout from "@/layouts/MainLayout.astro";
+import Header from "@/components/Header.astro";
+import AdminDashboard from "@/components/admin/AdminDashboard.vue";
+
+const { locale } = Astro.params;
+---
+
+
+
+
+
+
+
+
+ Admin
+
+
+ Dashboard de Voluntarios
+
+
+
+
+
+
diff --git a/src/pages/[locale]/admin/index.astro b/src/pages/[locale]/admin/index.astro
new file mode 100644
index 0000000..2f17098
--- /dev/null
+++ b/src/pages/[locale]/admin/index.astro
@@ -0,0 +1,28 @@
+---
+import MainLayout from "@/layouts/MainLayout.astro";
+import Header from "@/components/Header.astro";
+import VoluntariosTable from "../../../components/admin/VoluntariosTable.vue";
+
+const { locale } = Astro.params;
+---
+
+
+
+
+
+
+
+
+ Admin
+
+
+ Dashboard de Voluntarios
+
+
+
+
+
+
diff --git a/src/pages/[locale]/admin/login.astro b/src/pages/[locale]/admin/login.astro
new file mode 100644
index 0000000..d7f081c
--- /dev/null
+++ b/src/pages/[locale]/admin/login.astro
@@ -0,0 +1,156 @@
+---
+import "../../../styles/global.css";
+import "@fontsource/poppins/400.css";
+import "@fontsource/poppins/500.css";
+import "@fontsource/poppins/700.css";
+import "@fontsource-variable/kameron";
+import { Icon } from "astro-icon/components";
+
+const { locale } = Astro.params;
+const redirectTo = `/${locale}/admin`;
+---
+
+
+
+
+
+
+
+
Acceso administrativo
+
+
+
+
+

+
+
+

+
+ Centro del Reino de Paz y Justicia
+
+
+
+ Panel de administración — acceso restringido
+
+
+
+
+
+

+
+
+

+
+
+
+ Iniciar sesión
+
+
+ Ingresa tus credenciales de administrador
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/[locale]/admin/voluntario/[numero].astro b/src/pages/[locale]/admin/voluntario/[numero].astro
new file mode 100644
index 0000000..82aa40d
--- /dev/null
+++ b/src/pages/[locale]/admin/voluntario/[numero].astro
@@ -0,0 +1,26 @@
+---
+import MainLayout from "@/layouts/MainLayout.astro";
+import Header from "@/components/Header.astro";
+import VoluntarioDetail from "@/components/admin/VoluntarioDetail.vue";
+
+const { numero } = Astro.params;
+---
+
+
+
+
+
+
+
+
+ Admin
+
+
+ Detalle de Voluntario
+
+
+
+
+
+
+
diff --git a/src/pages/api/admin/auth/login.ts b/src/pages/api/admin/auth/login.ts
new file mode 100644
index 0000000..0897031
--- /dev/null
+++ b/src/pages/api/admin/auth/login.ts
@@ -0,0 +1,76 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import {
+ createSession,
+ isSameOrigin,
+ sessionCookieFor,
+ verifyPassword,
+} from "../../lib/auth";
+
+export const prerender = false;
+
+export const POST: APIRoute = async ({ request }) => {
+ try {
+ if (!isSameOrigin(request)) {
+ return Response.json(
+ { success: false, message: "Origen no válido" },
+ { status: 403 }
+ );
+ }
+
+ const body = await request.json();
+ const identifier = String(body?.username || body?.email || "")
+ .trim()
+ .toLowerCase();
+ const password = String(body?.password || "");
+
+ if (!identifier || !password) {
+ return Response.json(
+ { success: false, message: "Credenciales requeridas" },
+ { status: 400 }
+ );
+ }
+
+ const admin = await prisma.admin.findFirst({
+ where: {
+ OR: [
+ { username: { equals: identifier, mode: "insensitive" } },
+ { email: { equals: identifier, mode: "insensitive" } },
+ ],
+ },
+ });
+
+ if (
+ !admin ||
+ !admin.activo ||
+ !admin.password_hash ||
+ !verifyPassword(password, admin.password_hash)
+ ) {
+ return Response.json(
+ { success: false, message: "Credenciales inválidas" },
+ { status: 401 }
+ );
+ }
+
+ await prisma.admin.update({
+ where: { id: admin.id },
+ data: { ultimo_acceso: new Date() },
+ });
+
+ const token = await createSession(admin.id);
+
+ return new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": sessionCookieFor(token),
+ },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/auth/login:", error);
+ return Response.json(
+ { success: false, message: "Error en el login" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/auth/logout.ts b/src/pages/api/admin/auth/logout.ts
new file mode 100644
index 0000000..ee0fd43
--- /dev/null
+++ b/src/pages/api/admin/auth/logout.ts
@@ -0,0 +1,20 @@
+import type { APIRoute } from "astro";
+import { clearSessionCookie, isSameOrigin, revokeSession } from "../../lib/auth";
+
+export const prerender = false;
+
+export const POST: APIRoute = async ({ request }) => {
+ if (!isSameOrigin(request)) {
+ return new Response(null, { status: 403 });
+ }
+
+ await revokeSession(request);
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/",
+ "Set-Cookie": clearSessionCookie(),
+ },
+ });
+};
diff --git a/src/pages/api/admin/voluntarios.ts b/src/pages/api/admin/voluntarios.ts
new file mode 100644
index 0000000..27dd8ab
--- /dev/null
+++ b/src/pages/api/admin/voluntarios.ts
@@ -0,0 +1,250 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../lib/prisma";
+import { 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 = ["documento_nro"] as const;
+
+const FUZZY_TEXT_FILTERS = [
+ "ciudad",
+ "estado",
+ "profesion",
+ "nivel_academico",
+] as const;
+
+const FUZZY_THRESHOLD = 0.35;
+
+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 };
+}
+
+async function fuzzyIds(field: string, value: string): Promise
{
+ const col = Prisma.raw(field);
+ const rows = await prisma.$queryRaw<{ id: string }[]>`
+ SELECT id::text AS id
+ FROM formularios
+ WHERE ${col} IS NOT NULL
+ AND ${col} <> ''
+ AND length(${col}) >= 2
+ AND (
+ similarity(lower(${col}), lower(${value})) > ${FUZZY_THRESHOLD}
+ OR ${col} ILIKE ${`%${value}%`}
+ )
+ LIMIT 5000
+ `;
+ return rows.map((r) => r.id);
+}
+
+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 FUZZY_TEXT_FILTERS) {
+ const v = (sp.get(field) || "").trim();
+ if (v) {
+ const ids = await fuzzyIds(field, v);
+ conditions.push(ids.length ? { id: { in: ids } } : { id: { in: [] } });
+ }
+ }
+
+ 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 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),
+ }, {
+ headers: { "Cache-Control": "private, max-age=30, stale-while-revalidate=120" },
+ });
+ } 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/[numero].ts b/src/pages/api/admin/voluntarios/[numero].ts
new file mode 100644
index 0000000..bdce75c
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/[numero].ts
@@ -0,0 +1,99 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { t, type I18nKey } from "../../../../i18n";
+
+export const prerender = false;
+
+export const GET: APIRoute = async ({ params }) => {
+ try {
+ const numero = Number(params.numero);
+ if (!Number.isInteger(numero) || numero <= 0) {
+ return Response.json(
+ { success: false, message: "Número de voluntario inválido" },
+ { status: 404 }
+ );
+ }
+
+ const [voluntario, secciones] = await Promise.all([
+ prisma.formulario.findUnique({
+ where: { numero_voluntario: numero },
+ include: { respuestas: { orderBy: { created_at: "asc" } } },
+ }),
+ prisma.seccion.findMany({
+ orderBy: { orden: "asc" },
+ include: { campos: { orderBy: { orden: "asc" } } },
+ }),
+ ]);
+
+ if (!voluntario) {
+ return Response.json(
+ { success: false, message: "Voluntario no encontrado" },
+ { status: 404 }
+ );
+ }
+
+ const sections = secciones.map((s) => ({
+ key: s.title_key.replace(/^form\./, ""),
+ title: t("es", s.title_key as I18nKey),
+ fields: s.campos.map((c) => ({
+ key: c.key,
+ label: t("es", c.label_key as I18nKey),
+ tipo: c.tipo,
+ options: Array.isArray(c.options)
+ ? (c.options as { value?: string; label?: string }[])
+ .filter((o) => o && typeof o === "object")
+ .map((o) => ({
+ value: o.value ?? "",
+ label: o.label ? t("es", o.label as I18nKey) : o.value ?? "",
+ }))
+ : [],
+ })),
+ }));
+
+ return Response.json({
+ success: true,
+ data: {
+ id: voluntario.id,
+ numero_voluntario: voluntario.numero_voluntario,
+ nombre: voluntario.nombre,
+ segundo_nombre: voluntario.segundo_nombre,
+ apellido: voluntario.apellido,
+ documento_nro: voluntario.documento_nro,
+ documento_tipo: voluntario.documento_tipo,
+ correo: voluntario.correo,
+ fecha_nacimiento: voluntario.fecha_nacimiento,
+ nacionalidad: voluntario.nacionalidad,
+ sexo: voluntario.sexo,
+ direccion: voluntario.direccion,
+ ciudad: voluntario.ciudad,
+ estado: voluntario.estado,
+ pais: voluntario.pais,
+ codigo_postal: voluntario.codigo_postal,
+ telefono: voluntario.telefono,
+ whatsapp: voluntario.whatsapp,
+ profesion: voluntario.profesion,
+ lugar_trabajo: voluntario.lugar_trabajo,
+ nivel_academico: voluntario.nivel_academico,
+ status: voluntario.status,
+ form_version: voluntario.form_version,
+ email_sent_at: voluntario.email_sent_at,
+ submitted_at: voluntario.submitted_at,
+ updated_at: voluntario.updated_at,
+ respuestas: voluntario.respuestas.map((r) => ({
+ seccion: r.seccion,
+ key: r.key,
+ value: r.value,
+ })),
+ },
+ form: { sections },
+ }, {
+ headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=120" },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/[numero]:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener el voluntario" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/voluntarios/distinct.ts b/src/pages/api/admin/voluntarios/distinct.ts
new file mode 100644
index 0000000..044ef39
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/distinct.ts
@@ -0,0 +1,58 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { Prisma } from "@prisma/client";
+
+export const prerender = false;
+
+const DISTINCT_FIELDS = new Set([
+ "ciudad",
+ "estado",
+ "profesion",
+ "nivel_academico",
+ "nacionalidad",
+ "pais",
+]);
+
+const SIMILARITY_THRESHOLD = 0.35;
+
+export const GET: APIRoute = async ({ url }) => {
+ try {
+ const sp = url.searchParams;
+ const field = (sp.get("field") || "").trim();
+ const q = (sp.get("q") || "").trim();
+ const limit = Math.min(100, Math.max(1, parseInt(sp.get("limit") || "25", 10)));
+
+ if (!DISTINCT_FIELDS.has(field)) {
+ return Response.json(
+ { success: false, message: "Campo no permitido" },
+ { status: 400 }
+ );
+ }
+
+ const col = Prisma.raw(field);
+
+ const rows = await prisma.$queryRaw<{ value: string; count: number }[]>`
+ SELECT ${col} AS value, COUNT(*)::int AS count
+ FROM formularios
+ WHERE ${col} IS NOT NULL AND ${col} <> ''
+ ${q.length >= 2 ? Prisma.sql`AND (similarity(lower(${col}), lower(${q})) > ${SIMILARITY_THRESHOLD} OR ${col} ILIKE ${`%${q}%`})` : Prisma.empty}
+ GROUP BY ${col}
+ ORDER BY count DESC
+ LIMIT ${limit}
+ `;
+
+ return Response.json({
+ success: true,
+ field,
+ data: rows.map((r) => ({ value: r.value, count: r.count })),
+ }, {
+ headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/distinct:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener valores" },
+ { 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..aca4051
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/options.ts
@@ -0,0 +1,58 @@
+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(),
+ }, {
+ headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/options:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener opciones" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/admin/voluntarios/stats.ts b/src/pages/api/admin/voluntarios/stats.ts
new file mode 100644
index 0000000..81ca1e4
--- /dev/null
+++ b/src/pages/api/admin/voluntarios/stats.ts
@@ -0,0 +1,140 @@
+import type { APIRoute } from "astro";
+import { prisma } from "../../lib/prisma";
+import { t, type I18nKey } from "../../../../i18n";
+
+export const prerender = false;
+
+const OPTION_FIELDS = [
+ "idioma",
+ "areas_colaborar",
+ "dias_disponibles",
+ "horario_preferido",
+ "sexo",
+ "fuera_ciudad",
+ "misiones_internacionales",
+ "condicion_medica",
+ "voluntariado_anterior",
+] as const;
+
+async function multiValueDist(key: string): Promise<{ value: string; count: number }[]> {
+ const rows = await prisma.$queryRaw<{ v: string; cnt: number }[]>`
+ SELECT trim(unnest(string_to_array(value, ','))) AS v, count(DISTINCT formulario_id)::int AS cnt
+ FROM respuestas
+ WHERE key = ${key} AND btrim(value) <> ''
+ GROUP BY v
+ ORDER BY cnt DESC
+ `;
+ return rows.map((r) => ({ value: r.v, count: r.cnt }));
+}
+
+async function valueDist(key: string): Promise<{ value: string; count: number }[]> {
+ const rows = await prisma.respuesta.groupBy({
+ by: ["value"],
+ where: { key, value: { not: "" } },
+ _count: { _all: true },
+ });
+ return rows
+ .filter((r) => r.value !== null)
+ .map((r) => ({ value: r.value!, count: r._count._all }))
+ .sort((a, b) => b.count - a.count);
+}
+
+export const GET: APIRoute = async () => {
+ try {
+ const campos = await prisma.campo.findMany({
+ where: { key: { in: OPTION_FIELDS as unknown as string[] } },
+ });
+
+ const optionMaps: Record> = {};
+ for (const c of campos) {
+ optionMaps[c.key] = {};
+ if (Array.isArray(c.options)) {
+ for (const o of c.options as { value?: string; label?: string }[]) {
+ if (o && o.value) {
+ optionMaps[c.key][o.value] = o.label ? t("es", o.label as I18nKey) : o.value;
+ }
+ }
+ }
+ }
+
+ const withLabels = (
+ dist: { value: string; count: number }[],
+ key: string
+ ): { value: string; label: string; count: number }[] =>
+ dist.map((d) => ({
+ value: d.value,
+ label: optionMaps[key]?.[d.value] || d.value,
+ count: d.count,
+ }));
+
+ const [total, statuses, sexo, paises, [idiomas, areas, dias, horarios], condiciones, edad] =
+ await Promise.all([
+ prisma.formulario.count(),
+ prisma.formulario.groupBy({ by: ["status"], _count: { _all: true } }),
+ prisma.formulario.groupBy({ by: ["sexo"], _count: { _all: true } }),
+ prisma.formulario.groupBy({
+ by: ["pais"],
+ _count: { _all: true },
+ where: { pais: { not: null } },
+ orderBy: { _count: { pais: "desc" } },
+ take: 12,
+ }),
+ Promise.all([
+ multiValueDist("idioma"),
+ multiValueDist("areas_colaborar"),
+ multiValueDist("dias_disponibles"),
+ multiValueDist("horario_preferido"),
+ ]),
+ Promise.all(
+ ["fuera_ciudad", "misiones_internacionales", "condicion_medica", "voluntariado_anterior"].map(
+ (k) => valueDist(k)
+ )
+ ),
+ prisma.$queryRaw<{ promedio: number | null }[]>`
+ SELECT round(avg(extract(epoch from (now() - fecha_nacimiento)) / 31557600), 1) AS promedio
+ FROM formularios
+ WHERE fecha_nacimiento IS NOT NULL
+ `,
+ ]);
+
+ const condKeys = [
+ "fuera_ciudad",
+ "misiones_internacionales",
+ "condicion_medica",
+ "voluntariado_anterior",
+ ] as const;
+ const condicionesObj: Record> = {};
+ condKeys.forEach((k, i) => {
+ condicionesObj[k] = Object.fromEntries(condiciones[i].map((c) => [c.value, c.count]));
+ });
+
+ const formatPais = paises
+ .filter((p) => p.pais !== null)
+ .map((p) => ({ value: p.pais!, count: p._count._all }));
+
+ return Response.json({
+ success: true,
+ total,
+ edad: { promedio: edad[0]?.promedio ?? null },
+ idiomas: withLabels(idiomas, "idioma"),
+ areas: withLabels(areas, "areas_colaborar"),
+ dias: withLabels(dias, "dias_disponibles"),
+ horarios: withLabels(horarios, "horario_preferido"),
+ sexo: withLabels(
+ sexo.filter((s) => s.sexo !== null).map((s) => ({ value: s.sexo!, count: s._count._all })),
+ "sexo"
+ ),
+ paises: formatPais,
+ statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
+ condiciones: condicionesObj,
+ }, {
+ headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=300" },
+ });
+ } catch (error) {
+ console.error("Error en /api/admin/voluntarios/stats:", error);
+ return Response.json(
+ { success: false, message: "Error al obtener estadísticas" },
+ { status: 500 }
+ );
+ }
+};
diff --git a/src/pages/api/emailPostulacion/send.ts b/src/pages/api/emailPostulacion/send.ts
index 0e3d154..e7fcd3f 100644
--- a/src/pages/api/emailPostulacion/send.ts
+++ b/src/pages/api/emailPostulacion/send.ts
@@ -1,6 +1,8 @@
import type { APIRoute } from "astro";
-import { appendToSheet, emailExists } from "../lib/googleSheets";
+import { appendToSheet } from "../lib/googleSheets";
import { sendEmailCf } from "../lib/email";
+import { prisma } from "../lib/prisma";
+import { emailExistsInDb } from "../lib/formularioDb";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
@@ -22,7 +24,7 @@ export const POST: APIRoute = async ({ request }) => {
return Response.redirect("/?error=datos", 303);
}
- const exists = await emailExists(email);
+ const exists = await emailExistsInDb(email);
if (exists) {
return Response.json({
success: false,
@@ -41,10 +43,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
diff --git a/src/pages/api/formulario/send.ts b/src/pages/api/formulario/send.ts
index bbd90ca..322870e 100644
--- a/src/pages/api/formulario/send.ts
+++ b/src/pages/api/formulario/send.ts
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { appendRegistrationToSheet } from "../lib/googleSheets";
import { sendEmailCf } from "../lib/email";
-import { emailExistsInRegistry, addEmailToRegistry } from "../lib/emailsRegistry";
+import { saveFormularioToSupabase, emailExistsInDb } from "../lib/formularioDb";
export const prerender = false;
const COLUMNS = [
@@ -79,7 +79,7 @@ export const POST: APIRoute = async ({ request }) => {
}
const turnstileToken = body.turnstileToken || "";
- const turnstileSecret = import.meta.env.TURNSTILE_SECRET_KEY;
+ const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;
if (turnstileSecret && turnstileToken) {
const verify = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
@@ -102,13 +102,19 @@ export const POST: APIRoute = async ({ request }) => {
timeZone: "America/Puerto_Rico",
});
- if (emailExistsInRegistry(formData.correo)) {
+ const exists = await emailExistsInDb(formData.correo);
+ if (exists) {
return Response.json(
{ success: false, message: "Este correo ya está registrado. No se permiten 2 registros." },
{ status: 409 }
);
}
+ const form = await saveFormularioToSupabase(
+ formData,
+ body.responses || []
+ );
+
const row = COLUMNS.map((key) => flattenValue(formData[key]));
row.push(timestamp);
@@ -119,9 +125,10 @@ export const POST: APIRoute = async ({ request }) => {
apellidos: formData.apellido,
}, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION");
- addEmailToRegistry(formData.correo);
-
- 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/auth.ts b/src/pages/api/lib/auth.ts
new file mode 100644
index 0000000..bce0536
--- /dev/null
+++ b/src/pages/api/lib/auth.ts
@@ -0,0 +1,151 @@
+import {
+ createHash,
+ randomBytes,
+ scryptSync,
+ timingSafeEqual,
+} from "node:crypto";
+import { prisma } from "./prisma";
+
+const SESSION_COOKIE = "cdrdpyj_admin";
+const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
+
+export interface AuthSession {
+ adminId: string;
+ email: string;
+ nombre: string;
+ rol: string;
+}
+
+export function sessionCookieName(): string {
+ return SESSION_COOKIE;
+}
+
+export function hashPassword(password: string): string {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(password, salt, 64).toString("hex");
+ return `${salt}:${hash}`;
+}
+
+export function verifyPassword(password: string, stored: string): boolean {
+ const [salt, hash] = stored.split(":");
+ if (!salt || !hash) return false;
+ const test = scryptSync(password, salt, 64);
+ const ref = Buffer.from(hash, "hex");
+ return test.length === ref.length && timingSafeEqual(test, ref);
+}
+
+function sha256(value: string): string {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+function timingSafeEqualStr(a: string, b: string): boolean {
+ const ab = Buffer.from(a);
+ const bb = Buffer.from(b);
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
+}
+
+export function sessionCookieFor(token: string): string {
+ const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
+ return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${Math.floor(
+ SESSION_TTL_MS / 1000
+ )}${secure}`;
+}
+
+export function clearSessionCookie(): string {
+ const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
+ return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${secure}`;
+}
+
+function readSessionToken(request: Request): string | null {
+ const cookie = request.headers.get("cookie");
+ if (!cookie) return null;
+ const entry = cookie
+ .split(";")
+ .map((c) => c.trim())
+ .find((c) => c.startsWith(`${SESSION_COOKIE}=`));
+ if (!entry) return null;
+ return entry.slice(SESSION_COOKIE.length + 1) || null;
+}
+
+export async function createSession(adminId: string): Promise {
+ const token = randomBytes(32).toString("base64url");
+ await prisma.session.deleteMany({
+ where: { expires_at: { lte: new Date() } },
+ });
+ await prisma.session.create({
+ data: {
+ token_hash: sha256(token),
+ admin_id: adminId,
+ expires_at: new Date(Date.now() + SESSION_TTL_MS),
+ },
+ });
+ return token;
+}
+
+export async function getSession(request: Request): Promise {
+ const token = readSessionToken(request);
+ if (!token) return null;
+
+ try {
+ const session = await prisma.session.findUnique({
+ where: { token_hash: sha256(token) },
+ include: { admin: true },
+ });
+
+ if (
+ !session ||
+ session.expires_at.getTime() <= Date.now() ||
+ !session.admin.activo
+ ) {
+ return null;
+ }
+
+ return {
+ adminId: session.admin.id,
+ email: session.admin.email,
+ nombre: session.admin.nombre,
+ rol: session.admin.rol,
+ };
+ } catch (error) {
+ console.error("Error al leer la sesión:", error);
+ return null;
+ }
+}
+
+export async function revokeSession(request: Request): Promise {
+ const token = readSessionToken(request);
+ if (!token) return;
+ try {
+ await prisma.session.delete({ where: { token_hash: sha256(token) } });
+ } catch {
+ // sesión inexistente, no hacer nada
+ }
+}
+
+export async function isApiAuthorized(request: Request): Promise {
+ if (await getSession(request)) return true;
+
+ const apiKey = process.env.ADMIN_API_KEY;
+ if (!apiKey) return false;
+
+ const key =
+ request.headers.get("x-api-key") ??
+ request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
+ if (!key) return false;
+
+ return timingSafeEqualStr(key, apiKey);
+}
+
+export function isSameOrigin(request: Request): boolean {
+ const origin = request.headers.get("origin");
+ const referer = request.headers.get("referer");
+ const host = request.headers.get("host");
+ if (!host) return true;
+ try {
+ if (origin) return new URL(origin).host === host;
+ if (referer) return new URL(referer).host === host;
+ } catch {
+ return false;
+ }
+ return true;
+}
diff --git a/src/pages/api/lib/email.ts b/src/pages/api/lib/email.ts
index 27ab5aa..e55c51f 100644
--- a/src/pages/api/lib/email.ts
+++ b/src/pages/api/lib/email.ts
@@ -3,7 +3,7 @@ import Cloudflare from "cloudflare";
type TypeEmailData = "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO" | "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION";
export async function sendEmail(data: object, to: string, type: TypeEmailData) {
- const emailApiKey = import.meta.env.EMAIL_API_KEY;
+ const emailApiKey = process.env.EMAIL_API_KEY;
const dataFinal = {
...data,
@@ -77,8 +77,13 @@ function stripHtml(html: string): string {
}
export async function sendEmailCf(data: Record, to: string, type: TypeEmailData) {
- const apiToken = import.meta.env.CLOUDFLARE_API_TOKEN;
- const accountId = import.meta.env.CLOUDFLARE_ACCOUNT_ID;
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
+
+ if (!apiToken || !accountId) {
+ await sendEmail(data, to, type);
+ return;
+ }
try {
const client = new Cloudflare({ apiToken });
@@ -122,7 +127,7 @@ export async function sendEmailCf(data: Record, to: string, type
// to: string,
// type: TypeEmailData
// ) {
-// const apiKey = import.meta.env.BREVO_API_KEY;
+// const apiKey = process.env.BREVO_API_KEY;
// const { subject } = TEMPLATE_URLS[type];
// const rawHtml = await fetchTemplate(type);
// const html = renderTemplate(rawHtml, data);
diff --git a/src/pages/api/lib/emailsRegistry.ts b/src/pages/api/lib/emailsRegistry.ts
deleted file mode 100644
index a4e4c4b..0000000
--- a/src/pages/api/lib/emailsRegistry.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import fs from "node:fs";
-import path from "node:path";
-
-const REGISTRY_PATH = path.resolve(process.cwd(), "data", "emails.txt");
-
-export function emailExistsInRegistry(email: string): boolean {
- try {
- if (!fs.existsSync(REGISTRY_PATH)) return false;
- const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
- return content.split("\n").some((line) => line.trim().toLowerCase() === email.trim().toLowerCase());
- } catch {
- return false;
- }
-}
-
-export function addEmailToRegistry(email: string): void {
- const dir = path.dirname(REGISTRY_PATH);
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true });
- }
- fs.appendFileSync(REGISTRY_PATH, email.trim().toLowerCase() + "\n");
-}
diff --git a/src/pages/api/lib/formularioDb.ts b/src/pages/api/lib/formularioDb.ts
new file mode 100644
index 0000000..6fa7131
--- /dev/null
+++ b/src/pages/api/lib/formularioDb.ts
@@ -0,0 +1,113 @@
+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 emailExistsInDb(email: string): Promise {
+ const normalized = (email || "").trim().toLowerCase();
+ if (!normalized) return false;
+ const found = await prisma.formulario.findFirst({
+ where: { correo: { equals: normalized, mode: "insensitive" } },
+ select: { id: true },
+ });
+ return Boolean(found);
+}
+
+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/googleSheets.ts b/src/pages/api/lib/googleSheets.ts
index dcf8271..e4f2c79 100644
--- a/src/pages/api/lib/googleSheets.ts
+++ b/src/pages/api/lib/googleSheets.ts
@@ -2,14 +2,14 @@ import { google } from 'googleapis';
const auth = new google.auth.GoogleAuth({
credentials: {
- client_email: import.meta.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
- private_key: import.meta.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
+ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
+ private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
const sheets = google.sheets({ version: 'v4', auth });
-const SPREADSHEET_ID = import.meta.env.GOOGLE_SHEET_ID;
+const SPREADSHEET_ID = process.env.GOOGLE_SHEET_ID;
export async function emailExists(email: string): Promise {
const formula = `=COUNTIF(G:G,"${email}")`;
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
diff --git a/src/pages/api/voluntarios/count.ts b/src/pages/api/voluntarios/count.ts
index 4cc10b3..5b5bed7 100644
--- a/src/pages/api/voluntarios/count.ts
+++ b/src/pages/api/voluntarios/count.ts
@@ -1,11 +1,11 @@
import type { APIRoute } from "astro";
-import { getVolunteerCount } from "../lib/googleSheets";
+import { prisma } from "../lib/prisma";
export const prerender = false;
export const GET: APIRoute = async () => {
try {
- const count = await getVolunteerCount();
+ const count = await prisma.formulario.count();
return Response.json(
{ success: true, count },
diff --git a/src/styles/global.css b/src/styles/global.css
index 5073308..ae6376b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -2,6 +2,40 @@
@plugin '@tailwindcss/typography';
@plugin "daisyui";
+@plugin "daisyui/theme" {
+ name: "cdrpj";
+ default: false;
+ color-scheme: light;
+ --color-base-100: #ffffff;
+ --color-base-200: #ebe6d2;
+ --color-base-300: #d8d1b9;
+ --color-base-content: #003421;
+ --color-primary: #cba16a;
+ --color-primary-content: #003421;
+ --color-secondary: #003421;
+ --color-secondary-content: #ebe6d2;
+ --color-accent: #22523f;
+ --color-accent-content: #ebe6d2;
+ --color-neutral: #22523f;
+ --color-neutral-content: #ebe6d2;
+ --color-info: #22523f;
+ --color-info-content: #ebe6d2;
+ --color-success: #4a8c6f;
+ --color-success-content: #ffffff;
+ --color-warning: #cba16a;
+ --color-warning-content: #003421;
+ --color-error: #b03a2e;
+ --color-error-content: #ffffff;
+ --radius-selector: 0rem;
+ --radius-field: 0rem;
+ --radius-box: 0rem;
+ --size-selector: .25rem;
+ --size-field: .25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+}
+
@theme {
--font-primary: "Poppins", sans-serif;
--font-secondary: "Kameron Variable", sans-serif;