Compare commits

..

No commits in common. "d30e57f006715e6f212496a16b32d85d5379bb67" and "91137a6f40cca83df886a530dd0e8a4ba815725d" have entirely different histories.

17 changed files with 12 additions and 463 deletions

10
.env
View File

@ -18,6 +18,8 @@ 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"
@ -31,11 +33,3 @@ 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

View File

@ -4,10 +4,3 @@ 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"

4
.gitignore vendored
View File

@ -30,7 +30,3 @@ 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

View File

@ -7,10 +7,7 @@
"build": "astro build",
"postbuild": "node scripts/send-to-n8n.js",
"preview": "astro preview",
"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"
"astro": "astro"
},
"dependencies": {
"@astrojs/markdoc": "^2.0.3",

View File

@ -11,6 +11,6 @@ export default defineConfig({
},
engine: "classic",
datasource: {
url: env("DIRECT_URL") || env("DATABASE_URL"),
url: env("DATABASE_URL"),
},
});

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@ -1,17 +0,0 @@
-- 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");

View File

@ -1,174 +0,0 @@
/*
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;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "sqlite"

View File

@ -3,9 +3,8 @@ generator client {
}
datasource db {
provider = "postgresql"
provider = "sqlite"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Contact {
@ -17,115 +16,4 @@ model Contact {
updatedAt DateTime @updatedAt
@@index([email, createdAt])
@@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")
}

View File

@ -1,7 +1,6 @@
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 }) => {
@ -42,22 +41,10 @@ export const POST: APIRoute = async ({ request }) => {
new Date().toLocaleString()
];
// 🔹 Enviar correo (Cloudflare), guardar en Google Sheets y en PostgreSQL
// 🔹 Enviar correo (Cloudflare) y guardar en Google Sheets
await Promise.all([
sendEmailCf({ nombres, apellidos }, email, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION"),
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",
},
}),
appendToSheet(valuesForSheets)
]);
// 🔹 Redirección elegante después de enviar

View File

@ -1,7 +1,6 @@
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 = [
@ -102,11 +101,6 @@ 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);
@ -117,10 +111,7 @@ export const POST: APIRoute = async ({ request }) => {
apellidos: formData.apellido,
}, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION");
return Response.json({
...result,
numero_voluntario: form.numero_voluntario,
});
return Response.json(result);
} catch (error) {
console.error("Error en /api/formulario/send:", error);
return Response.json(

View File

@ -1,103 +0,0 @@
import { prisma } from "./prisma";
type FormData = Record<string, unknown>;
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;
});
}

View File

@ -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;
if (import.meta.env.DEV) globalForPrisma.prisma = prisma;