From c599ca829b4db3a82e6b62e3f54f978bce630a23 Mon Sep 17 00:00:00 2001 From: IsaacAburto1548 Date: Tue, 5 May 2026 20:22:13 -0600 Subject: [PATCH] feat: implementar historial consolidado y sistema de auditoria (bitacora) v2 --- src/app/api/eventos/[id]/route.ts | 21 +- src/app/api/eventos/route.ts | 6 + src/app/api/historial/route.ts | 57 ++ src/app/api/historial/sistema/route.ts | 14 + src/app/historial/page.tsx | 797 +++++++++++++++++++++++++ src/components/SideBar.tsx | 9 +- src/dao/asociado.dao.ts | 249 +++++--- src/dao/auditoria.dao.ts | 15 + src/dao/congregado.dao.ts | 489 ++++++++------- src/dao/historial.dao.ts | 298 +++++++++ src/dto/historial.dto.ts | 32 + src/lib/db.ts | 54 +- src/services/historial.service.ts | 18 + src/validators/historial.validator.ts | 65 ++ 14 files changed, 1814 insertions(+), 310 deletions(-) create mode 100644 src/app/api/historial/route.ts create mode 100644 src/app/api/historial/sistema/route.ts create mode 100644 src/app/historial/page.tsx create mode 100644 src/dao/auditoria.dao.ts create mode 100644 src/dao/historial.dao.ts create mode 100644 src/dto/historial.dto.ts create mode 100644 src/services/historial.service.ts create mode 100644 src/validators/historial.validator.ts diff --git a/src/app/api/eventos/[id]/route.ts b/src/app/api/eventos/[id]/route.ts index 0c09f14..bc1f854 100644 --- a/src/app/api/eventos/[id]/route.ts +++ b/src/app/api/eventos/[id]/route.ts @@ -150,10 +150,19 @@ export async function PUT( valores.push(id); const result = await db.query(sql, valores); + const eventoActualizado = result.rows[0]; + + // Auditoría + try { + const { AuditoriaDAO } = require("@/dao/auditoria.dao"); + const desc = data.activo === false ? `Evento desactivado: ${eventoActualizado.nombre}` : `Evento actualizado: ${eventoActualizado.nombre}`; + await AuditoriaDAO.registrar('eventos', id, data.activo === false ? 'eliminacion' : 'edicion', desc); + } catch (e) { console.error("Error auditando evento", e); } + return NextResponse.json({ success: true, message: "Evento actualizado exitosamente", - data: result.rows[0], + data: eventoActualizado, }); } catch (error: any) { const msg = error?.message || ""; @@ -210,10 +219,18 @@ export async function DELETE( [id] ); + const eventoEliminado = result.rows[0]; + + // Auditoría + try { + const { AuditoriaDAO } = require("@/dao/auditoria.dao"); + await AuditoriaDAO.registrar('eventos', id, 'eliminacion', `Evento eliminado (Inactivo): ${eventoEliminado.nombre}`); + } catch (e) { console.error("Error auditando evento", e); } + return NextResponse.json({ success: true, message: "Evento eliminado exitosamente", - data: result.rows[0], + data: eventoEliminado, }); } catch (error) { console.error("Error al eliminar evento:", error); diff --git a/src/app/api/eventos/route.ts b/src/app/api/eventos/route.ts index d441da6..ece63eb 100644 --- a/src/app/api/eventos/route.ts +++ b/src/app/api/eventos/route.ts @@ -140,6 +140,12 @@ export async function POST(request: NextRequest) { const result = await db.query(insertSQL, values); const evento: EventoResponse = result.rows[0]; + // Auditoría + try { + const { AuditoriaDAO } = require("@/dao/auditoria.dao"); + await AuditoriaDAO.registrar('eventos', evento.id, 'creacion', `Evento creado: ${evento.nombre}`); + } catch (e) { console.error("Error auditando evento", e); } + return NextResponse.json( { success: true, message: "Evento creado exitosamente", data: evento }, { status: 201 } diff --git a/src/app/api/historial/route.ts b/src/app/api/historial/route.ts new file mode 100644 index 0000000..ac8b7e3 --- /dev/null +++ b/src/app/api/historial/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HistorialService } from '@/services/historial.service'; +import { ConsultaHistorialRequest, TipoRegistroHistorial } from '@/dto/historial.dto'; +import { validateConsultaHistorialInput } from '@/validators/historial.validator'; + +const historialService = new HistorialService(); + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const personaIdParam = searchParams.get('personaId'); + const tipoPersonaParam = searchParams.get('tipoPersona'); + const fechaDesde = searchParams.get('fechaDesde'); + const fechaHasta = searchParams.get('fechaHasta'); + const tipoRegistro = searchParams.get('tipoRegistro') as TipoRegistroHistorial | 'todos' | null; + + const validation = validateConsultaHistorialInput({ + personaId: personaIdParam, + tipoPersona: tipoPersonaParam, + fechaDesde, + fechaHasta, + tipoRegistro + }); + + if (!validation.ok) { + return NextResponse.json( + { error: 'Errores de validación', issues: validation.issues }, + { status: 400 } + ); + } + + const personaId = parseInt(personaIdParam as string, 10); + + const request: ConsultaHistorialRequest = { + personaId, + tipoPersona: tipoPersonaParam as 'usuario' | 'asociado' | 'congregado', + filtros: { + ...(fechaDesde && { fechaDesde }), + ...(fechaHasta && { fechaHasta }), + ...(tipoRegistro && { tipoRegistro }), + } + }; + + const response = await historialService.obtenerHistorial(request); + + return NextResponse.json(response, { status: 200 }); + } catch (error: any) { + console.error('Error GET /api/historial:', error); + if (error.message.includes('Persona no encontrada')) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + return NextResponse.json( + { error: 'Error interno del servidor al obtener historial' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/historial/sistema/route.ts b/src/app/api/historial/sistema/route.ts new file mode 100644 index 0000000..eb55db8 --- /dev/null +++ b/src/app/api/historial/sistema/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { HistorialDAO } from '@/dao/historial.dao'; + +const historialDAO = new HistorialDAO(); + +export async function GET() { + try { + const hits = await historialDAO.obtenerHitosGlobales(); + return NextResponse.json({ historial: hits }, { status: 200 }); + } catch (error: any) { + console.error('Error GET /api/historial/sistema:', error); + return NextResponse.json({ error: 'Error al obtener hitos del sistema' }, { status: 500 }); + } +} diff --git a/src/app/historial/page.tsx b/src/app/historial/page.tsx new file mode 100644 index 0000000..1cb2796 --- /dev/null +++ b/src/app/historial/page.tsx @@ -0,0 +1,797 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { FaHistory, FaUser, FaFilter, FaFilePdf, FaFileExcel, FaCalendarAlt } from "react-icons/fa"; +import { HistorialResponseDTO, TipoRegistroHistorial } from "@/dto/historial.dto"; +import Sidebar from "@/components/SideBar"; + +type PersonaSimple = { + id: number; + nombre: string; +}; + +const inputClass = + 'shadow-sm border rounded-lg w-full py-2 px-3 text-gray-700 text-sm leading-tight ' + + 'focus:outline-none focus:ring-2 focus:ring-[#003366]/30 focus:border-[#003366] border-gray-300 transition-colors'; + +export default function HistorialPage() { + const [tipoPersona, setTipoPersona] = useState<"usuario" | "asociado" | "congregado" | "todos">("asociado"); + const [personas, setPersonas] = useState([]); + const [personaId, setPersonaId] = useState(0); + const [cargandoPersonas, setCargandoPersonas] = useState(false); + + const [historialData, setHistorialData] = useState(null); + const [cargandoHistorial, setCargandoHistorial] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + // Filtros + const [fechaDesde, setFechaDesde] = useState(""); + const [fechaHasta, setFechaHasta] = useState(""); + const [tipoRegistro, setTipoRegistro] = useState("todos"); + + // Paginación + const [paginaActual, setPaginaActual] = useState(1); + const [registrosPorPagina, setRegistrosPorPagina] = useState(20); + + useEffect(() => { + // En modo "todos" absoluto no cargamos lista de personas + if (tipoPersona === "todos") { + setPersonas([]); + setPersonaId(0); + setHistorialData(null); + return; + } + + const fetchPersonas = async () => { + setCargandoPersonas(true); + try { + let endpoint = ""; + if (tipoPersona === "usuario") endpoint = "/api/usuarios"; + else if (tipoPersona === "asociado") endpoint = "/api/asociados"; + else if (tipoPersona === "congregado") endpoint = "/api/congregados"; + + const res = await fetch(endpoint); + const data = await res.json(); + + let list: any[] = []; + if (Array.isArray(data)) { + list = data; + } else if (data && data.data && Array.isArray(data.data)) { + list = data.data; + } + + const listMapped = list.map(p => ({ + id: p.id, + nombre: p.nombreCompleto || p.nombre_completo || p.nombre || p.username || "Sin nombre" + })); + + // Añadimos la opción "Todos los [Tipo]s" + let labelAgregada = ""; + if (tipoPersona === "asociado") labelAgregada = "Todos los Asociados"; + if (tipoPersona === "usuario") labelAgregada = "Todos los Usuarios Staff"; + if (tipoPersona === "congregado") labelAgregada = "Todos los Congregados"; + + setPersonas([ + { id: -1, nombre: labelAgregada }, + ...listMapped + ]); + setPersonaId(0); + setHistorialData(null); + } catch (err) { + console.error("Error al cargar personas", err); + setPersonas([]); + } finally { + setCargandoPersonas(false); + } + }; + fetchPersonas(); + }, [tipoPersona]); + + const fetchHistorial = async () => { + // Si es "todos" absoluto o si se seleccionó "Todos los [Tipo]s" (id -1) + if (tipoPersona === "todos" || personaId === -1) { + await fetchHistorialGeneral(); + return; + } + if (!personaId) return; + setCargandoHistorial(true); + setErrorMsg(null); + setHistorialData(null); + setPaginaActual(1); // reset al nueva búsqueda + + try { + const params = new URLSearchParams({ + personaId: personaId.toString(), + tipoPersona + }); + + if (fechaDesde) params.append("fechaDesde", fechaDesde); + if (fechaHasta) params.append("fechaHasta", fechaHasta); + if (tipoRegistro && tipoRegistro !== "todos") params.append("tipoRegistro", tipoRegistro); + + const res = await fetch(`/api/historial?${params.toString()}`); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Error al obtener historial"); + } + + setHistorialData(data); + } catch (err: any) { + setErrorMsg(err.message); + } finally { + setCargandoHistorial(false); + } + }; + + // Historial general: une los historiales de todas las personas de todos los tipos + const fetchHistorialGeneral = async () => { + setCargandoHistorial(true); + setErrorMsg(null); + setHistorialData(null); + try { + // Obtenemos solo lo necesario según la selección + const esFiltradoPorTipo = personaId === -1; + + const fetchAsociados = (tipoPersona === "todos" || (esFiltradoPorTipo && tipoPersona === "asociado")) ? fetch("/api/asociados").then(r => r.json()) : Promise.resolve([]); + const fetchUsuarios = (tipoPersona === "todos" || (esFiltradoPorTipo && tipoPersona === "usuario")) ? fetch("/api/usuarios").then(r => r.json()) : Promise.resolve([]); + const fetchCongregados = (tipoPersona === "todos" || (esFiltradoPorTipo && tipoPersona === "congregado")) ? fetch("/api/congregados").then(r => r.json()) : Promise.resolve([]); + const fetchSistema = (tipoPersona === "todos") ? fetch("/api/historial/sistema").then(r => r.json()) : Promise.resolve({ historial: [] }); + + const [resAso, resUsr, resCon, resSys] = await Promise.all([fetchAsociados, fetchUsuarios, fetchCongregados, fetchSistema]); + + const extraerLista = (d: any): any[] => + Array.isArray(d) ? d : Array.isArray(d?.data) ? d.data : []; + + const asociados = extraerLista(resAso).map((p: any) => ({ id: p.id, tipo: 'asociado' })); + const usuarios = extraerLista(resUsr).map((p: any) => ({ id: p.id, tipo: 'usuario' })); + const congregados = extraerLista(resCon).map((p: any) => ({ id: p.id, tipo: 'congregado' })); + + const todas = [...asociados, ...usuarios, ...congregados]; + + // Consultamos el historial de cada persona en paralelo (máx 10 a la vez) + const resultados: HistorialResponseDTO[] = []; + const chunkSize = 10; + for (let i = 0; i < todas.length; i += chunkSize) { + const chunk = todas.slice(i, i + chunkSize); + const parciales = await Promise.allSettled( + chunk.map(p => + fetch(`/api/historial?personaId=${p.id}&tipoPersona=${p.tipo}`).then(r => r.json()) + ) + ); + parciales.forEach(r => { + if (r.status === 'fulfilled' && r.value?.historial) resultados.push(r.value); + }); + } + + // Consolidamos en un único "HistorialResponseDTO" genérico + const todosItems = resultados.flatMap(r => + r.historial.map(item => ({ ...item, _persona: r.persona.nombre })) + ); + + // Añadimos hitos del sistema + if (resSys && resSys.historial) { + todosItems.push(...resSys.historial); + } + + // Deduplicamos por id_registro + const unicos = new Map(); + todosItems.forEach(item => { + if (!unicos.has(item.id_registro)) { + unicos.set(item.id_registro, item); + } + }); + const todosUnicos = Array.from(unicos.values()); + + // Aplicamos filtros de fecha si los hay + const filtrados = todosUnicos.filter(item => { + const t = new Date(item.fecha).getTime(); + if (fechaDesde && t < new Date(fechaDesde).getTime()) return false; + if (fechaHasta && t > new Date(fechaHasta).getTime()) return false; + if (tipoRegistro !== 'todos' && item.tipo !== tipoRegistro) return false; + return true; + }); + + // Ordenar por fecha descendente + filtrados.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime()); + + let labelPersona = "Todos los registros"; + if (personaId === -1) { + if (tipoPersona === "asociado") labelPersona = "Todos los Asociados"; + if (tipoPersona === "usuario") labelPersona = "Todos los Usuarios Staff"; + if (tipoPersona === "congregado") labelPersona = "Todos los Congregados"; + } + + setHistorialData({ + persona: { id: 0, nombre: labelPersona, tipo: tipoPersona }, + historial: filtrados, + } as any); + setPaginaActual(1); // reset al obtener datos nuevos + } catch (err: any) { + setErrorMsg(err.message || "Error al obtener historial general"); + } finally { + setCargandoHistorial(false); + } + }; + + const formatearFecha = (fechaStr: string | Date) => { + const fecha = new Date(fechaStr); + return new Intl.DateTimeFormat('es-CR', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }).format(fecha); + }; + + // ── Paginación calculada ────────────────────────────────────────────────── + const totalRegistros = historialData?.historial.length ?? 0; + const totalPaginas = Math.max(1, Math.ceil(totalRegistros / registrosPorPagina)); + const inicio = (paginaActual - 1) * registrosPorPagina; + const fin = inicio + registrosPorPagina; + const registrosPagina = historialData?.historial.slice(inicio, fin) ?? []; + + const irAPagina = (n: number) => setPaginaActual(Math.max(1, Math.min(n, totalPaginas))); + + // ── Exportar PDF: plantilla A4 con badge fijo centrado ──────────────────── + + const exportarPDF = async () => { + if (!historialData) return; + + const { jsPDF } = await import("jspdf"); + const autoTable = (await import("jspdf-autotable")).default; + + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + + // ── Encabezado ── + doc.setFillColor(0, 51, 102); + doc.rect(0, 0, 210, 28, "F"); + doc.setTextColor(255, 255, 255); + doc.setFontSize(16); doc.setFont("helvetica", "bold"); + doc.text("SCRCR — Iglesia Bíblica Emanuel", 14, 11); + doc.setFontSize(10); doc.setFont("helvetica", "normal"); + doc.text("Historial de Personas", 14, 18); + const hoy = new Intl.DateTimeFormat("es-CR", { + year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" + }).format(new Date()); + doc.setFontSize(8); + doc.text(`Generado: ${hoy}`, 14, 24); + + // ── Sujeto ── + doc.setTextColor(0, 51, 102); doc.setFontSize(13); doc.setFont("helvetica", "bold"); + doc.text(historialData.persona.nombre, 14, 38); + doc.setTextColor(100, 100, 100); doc.setFontSize(9); doc.setFont("helvetica", "normal"); + const tipoLabel = historialData.persona.tipo.charAt(0).toUpperCase() + historialData.persona.tipo.slice(1); + const idLabel = historialData.persona.identificacion ? ` · ${historialData.persona.identificacion}` : ""; + doc.text(`${tipoLabel}${idLabel}`, 14, 44); + doc.setDrawColor(0, 51, 102); doc.setLineWidth(0.5); doc.line(14, 47, 196, 47); + + // ── Tabla ── + // La columna Tipo renderizamos solo el texto blanco; el badge lo dibujamos después + // con willDrawCell para que no se pise con el autoTable text + const BADGE_W = 20; // ancho fijo del badge en mm + const BADGE_H = 6; // alto fijo del badge en mm + const tipoColors: Record = { + asistencia: [22, 163, 74], + permiso: [37, 99, 235], + modificacion: [147, 51, 234], + }; + + autoTable(doc, { + startY: 51, + head: [["Fecha y Hora", "Tipo", "Descripción", "Estado"]], + body: historialData.historial.map(item => [ + formatearFecha(item.fecha), + item.tipo.toUpperCase(), + item.descripcion + (item.detalles?.observaciones ? `\nNota: ${item.detalles.observaciones}` : ""), + item.estado || "—", + ]), + headStyles: { + fillColor: [0, 51, 102], + textColor: 255, + fontStyle: "bold", + fontSize: 9, + cellPadding: 4, + }, + columnStyles: { + 0: { cellWidth: 38 }, + 1: { cellWidth: BADGE_W + 8, halign: "center" }, + 2: { cellWidth: "auto" }, + 3: { cellWidth: 26, halign: "center" }, + }, + alternateRowStyles: { fillColor: [245, 247, 250] }, + bodyStyles: { + fontSize: 8.5, + textColor: [50, 50, 50], + cellPadding: { top: 4, bottom: 4, left: 3, right: 3 }, + minCellHeight: 10, + }, + // 1) Vaciar el texto de la celda "Tipo" para que autoTable no lo renderice + didParseCell: (data: any) => { + if (data.section === "body" && data.column.index === 1) { + data.cell.text = []; + } + }, + // 2) Dibujar el badge una vez terminado el dibujo de la celda + didDrawCell: (data: any) => { + if (data.section === "body" && data.column.index === 1) { + const raw = (data.cell.raw as string).toLowerCase(); + const [r, g, b] = tipoColors[raw] ?? [100, 100, 100]; + + const bx = data.cell.x + (data.cell.width - BADGE_W) / 2; + const by = data.cell.y + (data.cell.height - BADGE_H) / 2; + + data.doc.setFillColor(r, g, b); + data.doc.roundedRect(bx, by, BADGE_W, BADGE_H, 1.5, 1.5, "F"); + + data.doc.setTextColor(255, 255, 255); + data.doc.setFontSize(6.5); + data.doc.setFont("helvetica", "bold"); + data.doc.text( + data.cell.raw as string, + bx + BADGE_W / 2, + by + BADGE_H / 2 + 0.5, + { align: "center", baseline: "middle" } + ); + } + }, + margin: { left: 14, right: 14 }, + }); + + // ── Pie de página ── + const pageCount = (doc as any).internal.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(7); doc.setTextColor(160, 160, 160); + doc.text(`Página ${i} de ${pageCount} · SCRCR — Documento confidencial`, 105, 291, { align: "center" }); + } + + doc.save(`Historial_${historialData.persona.nombre.replace(/\s+/g, "_")}.pdf`); + }; + + // ── Exportar Excel profesional con ExcelJS ──────────────────────────────── + const exportarExcel = async () => { + if (!historialData || historialData.historial.length === 0) return; + + const ExcelJS = (await import("exceljs")).default; + const wb = new ExcelJS.Workbook(); + wb.creator = "SCRCR"; + wb.created = new Date(); + + const ws = wb.addWorksheet("Historial", { + pageSetup: { paperSize: 9, orientation: "landscape" }, + }); + + // ── Ancho de columnas ── + ws.columns = [ + { key: "fecha", width: 24 }, + { key: "tipo", width: 16 }, + { key: "desc", width: 55 }, + { key: "obs", width: 35 }, + { key: "estado", width: 16 }, + ]; + + // ── Fila 1: título grande ── + ws.mergeCells("A1:E1"); + const titleCell = ws.getCell("A1"); + titleCell.value = "SCRCR — Iglesia Bíblica Emanuel"; + titleCell.font = { name: "Calibri", bold: true, size: 16, color: { argb: "FFFFFFFF" } }; + titleCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF003366" } }; + titleCell.alignment = { vertical: "middle", horizontal: "left", indent: 1 }; + ws.getRow(1).height = 28; + + // ── Fila 2: subtítulo ── + ws.mergeCells("A2:E2"); + const subCell = ws.getCell("A2"); + subCell.value = "Historial de Personas"; + subCell.font = { name: "Calibri", italic: true, size: 11, color: { argb: "FFFFFFFF" } }; + subCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF003366" } }; + subCell.alignment = { vertical: "middle", horizontal: "left", indent: 1 }; + ws.getRow(2).height = 18; + + // ── Fila 3: espacio ── + ws.getRow(3).height = 6; + + // ── Filas de metadatos (4-7) ── + const metaStyle = { + font: { name: "Calibri", size: 10 }, + alignment: { vertical: "middle" as const }, + }; + const metaLabelStyle = { + font: { name: "Calibri", bold: true, size: 10, color: { argb: "FF003366" } }, + alignment: { vertical: "middle" as const }, + }; + + const metaRows: [string, string][] = [ + ["Nombre:", historialData.persona.nombre], + ["Tipo:", historialData.persona.tipo.charAt(0).toUpperCase() + historialData.persona.tipo.slice(1)], + ...(historialData.persona.identificacion ? [["Identificación:", historialData.persona.identificacion] as [string, string]] : []), + ["Generado:", new Intl.DateTimeFormat("es-CR", { dateStyle: "long", timeStyle: "short" }).format(new Date())], + ]; + metaRows.forEach(([label, val]) => { + const row = ws.addRow([label, val]); + row.height = 16; + row.getCell(1).font = metaLabelStyle.font; + row.getCell(1).alignment = metaLabelStyle.alignment; + row.getCell(2).font = metaStyle.font; + row.getCell(2).alignment = metaStyle.alignment; + }); + + // Espacio antes de la tabla + ws.addRow([]); + + // ── Fila de encabezados de la tabla ── + const headerRow = ws.addRow(["Fecha y Hora", "Tipo", "Descripción", "Observaciones", "Estado"]); + headerRow.height = 20; + headerRow.eachCell(cell => { + cell.font = { name: "Calibri", bold: true, size: 10, color: { argb: "FFFFFFFF" } }; + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF003366" } }; + cell.alignment = { vertical: "middle", horizontal: "center", wrapText: false }; + cell.border = { + top: { style: "thin", color: { argb: "FFFFFFFF" } }, + bottom: { style: "thin", color: { argb: "FFFFFFFF" } }, + left: { style: "thin", color: { argb: "FFFFFFFF" } }, + right: { style: "thin", color: { argb: "FFFFFFFF" } }, + }; + }); + + // ── Colores de badge por tipo ── + const tipoBg: Record = { + permiso: "FFdbeafe", // azul suave + asistencia: "FFdcfce7", // verde suave + modificacion: "FFf3e8ff", // morado suave + }; + const tipoFg: Record = { + permiso: "FF1e40af", + asistencia: "FF15803d", + modificacion: "FF6b21a8", + }; + + // ── Filas de datos ── + historialData.historial.forEach((item, i) => { + const dataRow = ws.addRow([ + formatearFecha(item.fecha), + item.tipo.toUpperCase(), + item.descripcion, + item.detalles?.observaciones ?? "", + item.estado ?? "", + ]); + dataRow.height = 18; + const isAlt = i % 2 === 1; + + dataRow.eachCell((cell, colNum) => { + cell.font = { name: "Calibri", size: 9.5 }; + cell.alignment = { vertical: "middle", wrapText: colNum === 3 }; + cell.border = { + top: { style: "hair", color: { argb: "FFe2e8f0" } }, + bottom: { style: "hair", color: { argb: "FFe2e8f0" } }, + left: { style: "hair", color: { argb: "FFe2e8f0" } }, + right: { style: "hair", color: { argb: "FFe2e8f0" } }, + }; + // Fondo alternado o blanco + if (colNum === 2) { + // Columna Tipo → color semántico + const tipo = item.tipo.toLowerCase(); + cell.fill = { type: "pattern", pattern: "solid", + fgColor: { argb: tipoBg[tipo] ?? "FFF1F5F9" } }; + cell.font = { name: "Calibri", bold: true, size: 9.5, + color: { argb: tipoFg[tipo] ?? "FF334155" } }; + cell.alignment = { vertical: "middle", horizontal: "center" }; + } else { + cell.fill = { type: "pattern", pattern: "solid", + fgColor: { argb: isAlt ? "FFF8FAFC" : "FFFFFFFF" } }; + } + }); + }); + + // ── Descargar ── + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `Historial_${historialData.persona.nombre.replace(/\s+/g, "_")}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + }; + + const BadgeEstado = ({ tipo }: { tipo: string }) => { + let colorClass = 'bg-gray-100 text-gray-700'; + if (tipo === 'asistencia') colorClass = 'bg-green-100 text-green-700'; + if (tipo === 'permiso') colorClass = 'bg-blue-100 text-blue-700'; + if (tipo === 'modificacion') colorClass = 'bg-purple-100 text-purple-700'; + return ( + + {tipo} + + ); + }; + + return ( +
+ + +
+
+
+ +
+
+

Historial de Personas

+
+
+
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ + {/* ── Panel Lateral: Selección y Filtros ── */} +
+ +
+

+ Selección +

+
+
+ + +
+ {/* Ocultar selector de persona solo en modo "todos" absoluto */} + {tipoPersona !== "todos" && ( +
+ + +
+ )} +
+
+ +
+

+ Filtros +

+
+
+ + setFechaDesde(e.target.value)} /> +
+
+ + setFechaHasta(e.target.value)} /> +
+
+ + +
+ +
+
+
+ + {/* ── Panel Principal: Resultados ── */} +
+
+ {!historialData && !cargandoHistorial ? ( +
+ +

+ {(tipoPersona === "todos" || personaId === -1) + ? "Presione \"Consultar Historial\" para ver los registros agrupados." + : "Seleccione una persona y presione consultar."} +

+
+ ) : cargandoHistorial ? ( +
+
+

Cargando...

+
+ ) : ( + <> +
+
+

+ {historialData?.persona.nombre} +

+

+ {historialData?.persona.tipo} + {historialData?.persona.identificacion ? ` · ${historialData.persona.identificacion}` : ""} +

+
+
+ + +
+
+ +
+ {historialData?.historial.length === 0 ? ( +
+ +

No se encontraron registros para los criterios seleccionados.

+
+ ) : ( + + + + + {(tipoPersona === "todos" || personaId === -1) && } + + + + + + + {registrosPagina.map((item: any, idx: number) => ( + + + {(tipoPersona === "todos" || personaId === -1) && ( + + )} + + + + + ))} + +
Fecha y HoraPersonaTipoDescripciónEstado
+ {formatearFecha(item.fecha)} + + {item._persona ?? "—"} + + + + {item.descripcion} + {item.tipo === 'modificacion' && ( + + * Cambios en campos generales. + + )} + {item.detalles?.observaciones && ( + + Nota: {item.detalles.observaciones} + + )} + + {item.estado && ( + {item.estado} + )} +
+ )} +
+ + {/* ── Controles de Paginación ── */} + {totalRegistros > 0 && ( +
+ {/* Selector de registros por página + total */} +
+ Mostrar + + registros por página  ·  {totalRegistros} en total +
+ + {/* Navegación de páginas */} +
+ + + + {/* Números de página — mostramos máx 5 */} + {Array.from({ length: totalPaginas }, (_, i) => i + 1) + .filter(n => n === 1 || n === totalPaginas || Math.abs(n - paginaActual) <= 1) + .reduce<(number | "…")[]>((acc, n, i, arr) => { + if (i > 0 && n - (arr[i - 1] as number) > 1) acc.push("…"); + acc.push(n); + return acc; + }, []) + .map((item, i) => + item === "…" + ? + : + ) + } + + + +
+
+ )} + + )} +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 0d96974..cbb79f7 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation'; import { FaHome, FaUserPlus, FaList, FaSignOutAlt, FaBars, FaTimes, FaChurch, - FaCalendarAlt, FaUsers, FaChartLine, FaCog, FaClipboardList + FaCalendarAlt, FaUsers, FaChartLine, FaCog, FaClipboardList, FaHistory } from 'react-icons/fa'; import { useAuth } from '@/contexts/AuthContext'; @@ -83,6 +83,13 @@ const NAV_ITEMS: Omit[] = [ label: 'Permisos', roles: ['admin', 'tesorero', 'pastorGeneral'] as Role[], }, + { + id: 'historial', + href: '/historial', + icon: FaHistory, + label: 'Historial', + roles: ['admin', 'tesorero', 'pastorGeneral'] as Role[], + }, { id: 'configuracion', href: '/configuracion', diff --git a/src/dao/asociado.dao.ts b/src/dao/asociado.dao.ts index efcc132..f41f2c6 100644 --- a/src/dao/asociado.dao.ts +++ b/src/dao/asociado.dao.ts @@ -1,10 +1,11 @@ -import { prisma } from '@/lib/prisma'; +import { neon } from '@neondatabase/serverless'; import { CrearAsociadoRequest, ActualizarAsociadoRequest, FiltrosAsociadoRequest, } from '@/dto/asociado.dto'; import { Asociado, AsociadoModel } from '@/models/Asociado'; +import { AuditoriaDAO } from './auditoria.dao'; export interface PaginacionResultado { data: T[]; @@ -25,39 +26,53 @@ export class AsociadoDAOError extends Error { } } -function mapToAsociado(row: any): Asociado { - return new AsociadoModel({ - id: row.id, - nombreCompleto: row.nombreCompleto, - cedula: row.cedula, - correo: row.correo ?? undefined, - telefono: row.telefono ?? undefined, - ministerio: row.ministerio ?? undefined, - direccion: row.direccion ?? undefined, - fechaIngreso: row.fechaIngreso, - estado: row.estado, - }); -} - export class AsociadoDAO { + private sql: ReturnType; + + constructor(connectionString?: string) { + const url = connectionString || process.env.POSTGRES_URL || ''; + this.sql = neon(url); + } + + private mapRowToAsociado(row: any): Asociado { + return new AsociadoModel({ + id: row.id, + nombreCompleto: row.nombre_completo, + cedula: row.cedula, + correo: row.correo, + telefono: row.telefono, + ministerio: row.ministerio, + direccion: row.direccion, + fechaIngreso: row.fecha_ingreso, + estado: row.estado, + }); + } + async crear(data: CrearAsociadoRequest): Promise { try { - const row = await prisma.asociado.create({ - data: { - nombreCompleto: data.nombreCompleto, - cedula: data.cedula, - correo: data.correo ?? null, - telefono: data.telefono ?? null, - ministerio: data.ministerio ?? null, - direccion: data.direccion ?? null, - fechaIngreso: data.fechaIngreso ? new Date(data.fechaIngreso) : new Date(), - estado: data.estado ?? 1, - }, - }); - return mapToAsociado(row); + const result = await this.sql` + INSERT INTO asociados ( + nombre_completo, cedula, correo, telefono, + ministerio, direccion, fecha_ingreso, estado + ) VALUES ( + ${data.nombreCompleto}, + ${data.cedula}, + ${data.correo || null}, + ${data.telefono || null}, + ${data.ministerio || null}, + ${data.direccion || null}, + ${data.fechaIngreso ? new Date(data.fechaIngreso) : new Date()}, + ${data.estado ?? 1} + ) + RETURNING * + ` as any[]; + + const asociado = this.mapRowToAsociado(result[0]); + await AuditoriaDAO.registrar('asociados', asociado.id, 'creacion', 'Registro inicial del asociado'); + return asociado; } catch (error: any) { if (error instanceof AsociadoDAOError) throw error; - if (error.code === 'P2002') { + if (error.code === '23505') { throw new AsociadoDAOError('Ya existe un asociado con esta cédula', 'DUPLICATE_KEY', error); } throw new AsociadoDAOError('Error al crear el asociado en la base de datos', 'DATABASE_ERROR', error); @@ -66,8 +81,9 @@ export class AsociadoDAO { async obtenerPorId(id: number): Promise { try { - const row = await prisma.asociado.findUnique({ where: { id } }); - return row ? mapToAsociado(row) : null; + // this.sql es la instancia correcta; sin this. falla en runtime + const result = await this.sql`SELECT * FROM asociados WHERE id = ${id}` as any[]; + return result.length ? this.mapRowToAsociado(result[0]) : null; } catch (error) { throw new AsociadoDAOError('Error al obtener el asociado por ID', 'DATABASE_ERROR', error); } @@ -75,8 +91,10 @@ export class AsociadoDAO { async obtenerPorCedula(cedula: string): Promise { try { - const row = await prisma.asociado.findUnique({ where: { cedula } }); - return row ? mapToAsociado(row) : null; + const result = await this.sql` + SELECT * FROM asociados WHERE cedula = ${cedula} + ` as any[]; + return result.length ? this.mapRowToAsociado(result[0]) : null; } catch (error) { throw new AsociadoDAOError('Error al obtener el asociado por cédula', 'DATABASE_ERROR', error); } @@ -91,33 +109,54 @@ export class AsociadoDAO { try { const offset = (page - 1) * limit; - const where: any = {}; - if (estado !== undefined) where.estado = estado; - if (filtros?.nombreCompleto) where.nombreCompleto = { contains: filtros.nombreCompleto, mode: 'insensitive' }; - if (filtros?.cedula) where.cedula = { contains: filtros.cedula, mode: 'insensitive' }; - if (filtros?.ministerio) where.ministerio = { contains: filtros.ministerio, mode: 'insensitive' }; - if (filtros?.fechaIngresoDesde || filtros?.fechaIngresoHasta) { - where.fechaIngreso = {}; - if (filtros.fechaIngresoDesde) where.fechaIngreso.gte = new Date(filtros.fechaIngresoDesde); - if (filtros.fechaIngresoHasta) where.fechaIngreso.lte = new Date(filtros.fechaIngresoHasta); + const conditions: string[] = []; + const values: any[] = []; + + if (estado !== undefined) { + values.push(estado); + conditions.push(`estado = $${values.length}`); + } + if (filtros?.nombreCompleto) { + values.push(`%${filtros.nombreCompleto}%`); + conditions.push(`nombre_completo ILIKE $${values.length}`); + } + if (filtros?.cedula) { + values.push(`%${filtros.cedula}%`); + conditions.push(`cedula ILIKE $${values.length}`); + } + if (filtros?.ministerio) { + values.push(`%${filtros.ministerio}%`); + conditions.push(`ministerio ILIKE $${values.length}`); + } + if (filtros?.fechaIngresoDesde) { + values.push(new Date(filtros.fechaIngresoDesde)); + conditions.push(`fecha_ingreso >= $${values.length}`); } + if (filtros?.fechaIngresoHasta) { + values.push(new Date(filtros.fechaIngresoHasta)); + conditions.push(`fecha_ingreso <= $${values.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const dataResult = await this.sql.query( + `SELECT * FROM asociados ${where} ORDER BY id DESC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, + [...values, limit, offset] + ) as { rows: any[] }; - const [total, rows] = await Promise.all([ - prisma.asociado.count({ where }), - prisma.asociado.findMany({ - where, - orderBy: { id: 'desc' }, - skip: offset, - take: limit, - }), - ]); + const countResult = await this.sql.query( + `SELECT COUNT(*) as count FROM asociados ${where}`, + values + ) as { rows: any[] }; + const total = parseInt(countResult.rows[0].count); + const totalPages = Math.max(1, Math.ceil(total / limit)); return { - data: rows.map(mapToAsociado), + data: dataResult.rows.map((row: any) => this.mapRowToAsociado(row)), total, page, limit, - totalPages: Math.max(1, Math.ceil(total / limit)), + totalPages, }; } catch (error) { throw new AsociadoDAOError('Error al obtener la lista de asociados', 'DATABASE_ERROR', error); @@ -127,25 +166,32 @@ export class AsociadoDAO { async actualizar(id: number, data: ActualizarAsociadoRequest): Promise { try { const existente = await this.obtenerPorId(id); - if (!existente) throw new AsociadoDAOError('Asociado no encontrado', 'NOT_FOUND'); - - const row = await prisma.asociado.update({ - where: { id }, - data: { - nombreCompleto: data.nombreCompleto ?? existente.nombreCompleto, - cedula: data.cedula ?? existente.cedula, - correo: data.correo ?? existente.correo ?? null, - telefono: data.telefono ?? existente.telefono ?? null, - ministerio: data.ministerio ?? existente.ministerio ?? null, - direccion: data.direccion ?? existente.direccion ?? null, - fechaIngreso: data.fechaIngreso ? new Date(data.fechaIngreso) : existente.fechaIngreso, - estado: data.estado ?? existente.estado, - }, - }); - return mapToAsociado(row); + if (!existente) { + throw new AsociadoDAOError('Asociado no encontrado', 'NOT_FOUND'); + } + + const result = await this.sql` + UPDATE asociados SET + nombre_completo = ${data.nombreCompleto ?? existente.nombreCompleto}, + cedula = ${data.cedula ?? existente.cedula}, + correo = ${data.correo ?? existente.correo}, + telefono = ${data.telefono ?? existente.telefono}, + ministerio = ${data.ministerio ?? existente.ministerio}, + direccion = ${data.direccion ?? existente.direccion}, + fecha_ingreso = ${data.fechaIngreso + ? new Date(data.fechaIngreso) + : existente.fechaIngreso}, + estado = ${data.estado ?? existente.estado} + WHERE id = ${id} + RETURNING * + ` as any[]; + + const asociado = this.mapRowToAsociado(result[0]); + await AuditoriaDAO.registrar('asociados', asociado.id, 'edicion', 'Actualización de información del asociado'); + return asociado; } catch (error: any) { if (error instanceof AsociadoDAOError) throw error; - if (error.code === 'P2002') { + if (error.code === '23505') { throw new AsociadoDAOError('Ya existe un asociado con esta cédula', 'DUPLICATE_KEY', error); } throw new AsociadoDAOError('Error al actualizar el asociado', 'DATABASE_ERROR', error); @@ -154,11 +200,14 @@ export class AsociadoDAO { async eliminar(id: number): Promise { try { - const result = await prisma.asociado.updateMany({ - where: { id }, - data: { estado: 0 }, - }); - return result.count > 0; + const result = await this.sql` + UPDATE asociados SET estado = 0 WHERE id = ${id} RETURNING id + ` as any[]; + if (result.length > 0) { + await AuditoriaDAO.registrar('asociados', id, 'eliminacion', 'Desactivación del asociado (Inactivo)'); + return true; + } + return false; } catch (error) { throw new AsociadoDAOError('Error al eliminar el asociado', 'DATABASE_ERROR', error); } @@ -166,18 +215,21 @@ export class AsociadoDAO { async eliminarPermanente(id: number): Promise { try { - await prisma.asociado.delete({ where: { id } }); - return true; - } catch (error: any) { - if (error.code === 'P2025') return false; + const result = await this.sql` + DELETE FROM asociados WHERE id = ${id} RETURNING id + ` as any[]; + return result.length > 0; + } catch (error) { throw new AsociadoDAOError('Error al eliminar permanentemente el asociado', 'DATABASE_ERROR', error); } } async listarTodos(): Promise { try { - const rows = await prisma.asociado.findMany({ orderBy: { nombreCompleto: 'asc' } }); - return rows.map(mapToAsociado); + const result = await this.sql` + SELECT * FROM asociados ORDER BY nombre_completo + ` as any[]; + return result.map((row: any) => this.mapRowToAsociado(row)); } catch (error) { throw new AsociadoDAOError('Error al listar todos los asociados', 'DATABASE_ERROR', error); } @@ -185,15 +237,13 @@ export class AsociadoDAO { async buscarPorNombre(nombre: string, limit: number = 10): Promise { try { - const rows = await prisma.asociado.findMany({ - where: { - nombreCompleto: { contains: nombre, mode: 'insensitive' }, - estado: 1, - }, - orderBy: { nombreCompleto: 'asc' }, - take: limit, - }); - return rows.map(mapToAsociado); + const result = await this.sql` + SELECT * FROM asociados + WHERE nombre_completo ILIKE ${'%' + nombre + '%'} AND estado = 1 + ORDER BY nombre_completo + LIMIT ${limit} + ` as any[]; + return result.map((row: any) => this.mapRowToAsociado(row)); } catch (error) { throw new AsociadoDAOError('Error al buscar asociados por nombre', 'DATABASE_ERROR', error); } @@ -201,13 +251,20 @@ export class AsociadoDAO { async obtenerEstadisticas(): Promise<{ total: number; activos: number; inactivos: number }> { try { - const [total, activos] = await Promise.all([ - prisma.asociado.count(), - prisma.asociado.count({ where: { estado: 1 } }), - ]); - return { total, activos, inactivos: total - activos }; + const result = await this.sql` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE estado = 1) as activos, + COUNT(*) FILTER (WHERE estado = 0) as inactivos + FROM asociados + ` as any[]; + return { + total: parseInt(result[0].total), + activos: parseInt(result[0].activos), + inactivos: parseInt(result[0].inactivos), + }; } catch (error) { throw new AsociadoDAOError('Error al obtener estadísticas', 'DATABASE_ERROR', error); } } -} +} \ No newline at end of file diff --git a/src/dao/auditoria.dao.ts b/src/dao/auditoria.dao.ts new file mode 100644 index 0000000..32a47cf --- /dev/null +++ b/src/dao/auditoria.dao.ts @@ -0,0 +1,15 @@ +import { db } from '@/lib/db'; + +export class AuditoriaDAO { + static async registrar(tabla: string, registroId: number, accion: string, detalles: string) { + try { + await db.query( + 'INSERT INTO auditoria (tabla, registro_id, accion, detalles) VALUES ($1, $2, $3, $4)', + [tabla, registroId, accion, detalles] + ); + } catch (error) { + console.error('Error al registrar auditoría:', error); + // No lanzamos error para no bloquear la operación principal + } + } +} diff --git a/src/dao/congregado.dao.ts b/src/dao/congregado.dao.ts index ca922ff..81ddc2d 100644 --- a/src/dao/congregado.dao.ts +++ b/src/dao/congregado.dao.ts @@ -1,227 +1,302 @@ -import { prisma } from '@/lib/prisma'; +import { db } from '@/lib/db'; import { - CrearCongregadoRequest, - ActualizarCongregadoRequest, - FiltrosCongregadoRequest, + CrearCongregadoRequest, + ActualizarCongregadoRequest, + FiltrosCongregadoRequest, } from '@/dto/congregado.dto'; -import { Congregado, CongregadoModel, EstadoCongregado, EstadoCivil } from '@/models/Congregado'; +import { Congregado, CongregadoModel, EstadoCongregado } from '@/models/Congregado'; +import { AuditoriaDAO } from './auditoria.dao'; export interface PaginacionResultado { - data: T[]; - total: number; - page: number; - limit: number; - totalPages: number; + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; } export class CongregadoDAOError extends Error { - constructor( - message: string, - public code?: string, - public originalError?: unknown - ) { - super(message); - this.name = 'CongregadoDAOError'; - } -} - -function mapToCongregado(row: any): Congregado { - return new CongregadoModel({ - id: row.id, - nombre: row.nombre, - cedula: row.cedula, - fechaIngreso: row.fechaIngreso, - telefono: row.telefono, - segundoTelefono: row.segundoTelefono ?? undefined, - estadoCivil: row.estadoCivil as EstadoCivil, - ministerio: row.ministerio, - segundoMinisterio: row.segundoMinisterio ?? undefined, - urlFotoCedula: row.urlFotoCedula, - estado: row.estado as EstadoCongregado, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); + constructor( + message: string, + public code?: string, + public originalError?: unknown + ) { + super(message); + this.name = 'CongregadoDAOError'; + } } export class CongregadoDAO { - async crear(data: CrearCongregadoRequest): Promise { - try { - const row = await prisma.congregado.create({ - data: { - nombre: data.nombre, - cedula: data.cedula, - fechaIngreso: data.fechaIngreso ? new Date(data.fechaIngreso) : new Date(), - telefono: data.telefono, - segundoTelefono: data.segundoTelefono ?? null, - estadoCivil: data.estadoCivil, - ministerio: data.ministerio, - segundoMinisterio: data.segundoMinisterio ?? null, - urlFotoCedula: data.urlFotoCedula, - estado: data.estado ?? EstadoCongregado.ACTIVO, - }, - }); - return mapToCongregado(row); - } catch (error: any) { - if (error instanceof CongregadoDAOError) throw error; - if (error.code === 'P2002') { - throw new CongregadoDAOError('Ya existe un congregado con esta cédula', 'DUPLICATE_KEY', error); - } - throw new CongregadoDAOError('Error al crear el congregado', 'DATABASE_ERROR', error); + + private mapRowToCongregado(row: any): Congregado { + return new CongregadoModel({ + id: row.id, + nombre: row.nombre, + cedula: row.cedula, + fechaIngreso: row.fecha_ingreso, + telefono: row.telefono, + segundoTelefono: row.segundo_telefono, + estadoCivil: row.estado_civil, + ministerio: row.ministerio, + segundoMinisterio: row.segundo_ministerio, + urlFotoCedula: row.url_foto_cedula, + estado: row.estado, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); } - } - - async obtenerPorId(id: number): Promise { - try { - const row = await prisma.congregado.findUnique({ where: { id } }); - return row ? mapToCongregado(row) : null; - } catch (error) { - throw new CongregadoDAOError('Error al obtener el congregado por ID', 'DATABASE_ERROR', error); + + async crear(data: CrearCongregadoRequest): Promise { + try { + const result = await db.query( + `INSERT INTO congregados ( + nombre, cedula, fecha_ingreso, telefono, + segundo_telefono, estado_civil, ministerio, + segundo_ministerio, url_foto_cedula, estado + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + data.nombre, + data.cedula, + data.fechaIngreso ? new Date(data.fechaIngreso) : new Date(), + data.telefono, + data.segundoTelefono || null, + data.estadoCivil, + data.ministerio, + data.segundoMinisterio || null, + data.urlFotoCedula, + data.estado ?? EstadoCongregado.ACTIVO, + ] + ); + + const congregado = this.mapRowToCongregado(result.rows[0]); + await AuditoriaDAO.registrar('congregados', congregado.id, 'creacion', 'Registro inicial del congregado'); + return congregado; + } catch (error: any) { + if (error instanceof CongregadoDAOError) throw error; + if (error.code === '23505') { + throw new CongregadoDAOError('Ya existe un congregado con esta cédula', 'DUPLICATE_KEY', error); + } + throw new CongregadoDAOError('Error al crear el congregado', 'DATABASE_ERROR', error); + } } - } - - async obtenerPorCedula(cedula: string): Promise { - try { - const row = await prisma.congregado.findUnique({ where: { cedula } }); - return row ? mapToCongregado(row) : null; - } catch (error) { - throw new CongregadoDAOError('Error al obtener el congregado por cédula', 'DATABASE_ERROR', error); + + async obtenerPorId(id: number): Promise { + try { + const result = await db.query( + 'SELECT * FROM congregados WHERE id = $1', + [id] + ); + return result.rows.length ? this.mapRowToCongregado(result.rows[0]) : null; + } catch (error) { + throw new CongregadoDAOError('Error al obtener el congregado por ID', 'DATABASE_ERROR', error); + } } - } - - async obtenerTodos( - page: number = 1, - limit: number = 10, - estado?: EstadoCongregado, - filtros?: Pick - ): Promise> { - try { - const offset = (page - 1) * limit; - - const where: any = {}; - if (estado !== undefined) where.estado = estado; - if (filtros?.nombre) where.nombre = { contains: filtros.nombre, mode: 'insensitive' }; - if (filtros?.cedula) where.cedula = { contains: filtros.cedula, mode: 'insensitive' }; - if (filtros?.estadoCivil) where.estadoCivil = filtros.estadoCivil; - if (filtros?.ministerio) { - where.OR = [ - { ministerio: { contains: filtros.ministerio, mode: 'insensitive' } }, - { segundoMinisterio: { contains: filtros.ministerio, mode: 'insensitive' } }, - ]; - } - if (filtros?.fechaIngresoDesde || filtros?.fechaIngresoHasta) { - where.fechaIngreso = {}; - if (filtros.fechaIngresoDesde) where.fechaIngreso.gte = new Date(filtros.fechaIngresoDesde); - if (filtros.fechaIngresoHasta) where.fechaIngreso.lte = new Date(filtros.fechaIngresoHasta); - } - - const [total, rows] = await Promise.all([ - prisma.congregado.count({ where }), - prisma.congregado.findMany({ - where, - orderBy: { nombre: 'asc' }, - skip: offset, - take: limit, - }), - ]); - - return { - data: rows.map(mapToCongregado), - total, - page, - limit, - totalPages: Math.max(1, Math.ceil(total / limit)), - }; - } catch (error) { - throw new CongregadoDAOError('Error al obtener la lista de congregados', 'DATABASE_ERROR', error); + + async obtenerPorCedula(cedula: string): Promise { + try { + const result = await db.query( + 'SELECT * FROM congregados WHERE cedula = $1', + [cedula] + ); + return result.rows.length ? this.mapRowToCongregado(result.rows[0]) : null; + } catch (error) { + throw new CongregadoDAOError('Error al obtener el congregado por cédula', 'DATABASE_ERROR', error); + } } - } - - async actualizar(id: number, data: ActualizarCongregadoRequest): Promise { - try { - const existente = await this.obtenerPorId(id); - if (!existente) throw new CongregadoDAOError('Congregado no encontrado', 'NOT_FOUND'); - - const row = await prisma.congregado.update({ - where: { id }, - data: { - nombre: data.nombre ?? existente.nombre, - cedula: data.cedula ?? existente.cedula, - fechaIngreso: data.fechaIngreso ? new Date(data.fechaIngreso) : existente.fechaIngreso, - telefono: data.telefono ?? existente.telefono, - segundoTelefono: data.segundoTelefono === null ? null : (data.segundoTelefono ?? existente.segundoTelefono ?? null), - estadoCivil: data.estadoCivil ?? existente.estadoCivil, - ministerio: data.ministerio ?? existente.ministerio, - segundoMinisterio: data.segundoMinisterio === null ? null : (data.segundoMinisterio ?? existente.segundoMinisterio ?? null), - urlFotoCedula: data.urlFotoCedula ?? existente.urlFotoCedula, - estado: data.estado ?? existente.estado, - }, - }); - return mapToCongregado(row); - } catch (error: any) { - if (error instanceof CongregadoDAOError) throw error; - if (error.code === 'P2002') { - throw new CongregadoDAOError('Ya existe un congregado con esta cédula', 'DUPLICATE_KEY', error); - } - throw new CongregadoDAOError('Error al actualizar el congregado', 'DATABASE_ERROR', error); + + async obtenerTodos( + page: number = 1, + limit: number = 10, + estado?: EstadoCongregado, + filtros?: Pick + ): Promise> { + try { + const offset = (page - 1) * limit; + const conditions: string[] = []; + const values: any[] = []; + + if (estado !== undefined) { + values.push(estado); + conditions.push(`estado = $${values.length}`); + } + if (filtros?.nombre) { + values.push(`%${filtros.nombre}%`); + conditions.push(`nombre ILIKE $${values.length}`); + } + if (filtros?.cedula) { + values.push(`%${filtros.cedula}%`); + conditions.push(`cedula ILIKE $${values.length}`); + } + if (filtros?.estadoCivil) { + values.push(filtros.estadoCivil); + conditions.push(`estado_civil = $${values.length}`); + } + if (filtros?.ministerio) { + values.push(`%${filtros.ministerio}%`); + const idx1 = values.length; + values.push(`%${filtros.ministerio}%`); + const idx2 = values.length; + conditions.push(`(ministerio ILIKE $${idx1} OR segundo_ministerio ILIKE $${idx2})`); + } + if (filtros?.fechaIngresoDesde) { + values.push(new Date(filtros.fechaIngresoDesde)); + conditions.push(`fecha_ingreso >= $${values.length}`); + } + if (filtros?.fechaIngresoHasta) { + values.push(new Date(filtros.fechaIngresoHasta)); + conditions.push(`fecha_ingreso <= $${values.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const countResult = await db.query( + `SELECT COUNT(*) as count FROM congregados ${where}`, + values + ); + const total = parseInt(countResult.rows[0].count, 10); + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const dataResult = await db.query( + `SELECT * FROM congregados ${where} ORDER BY nombre ASC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, + [...values, limit, offset] + ); + + return { + data: dataResult.rows.map((row: any) => this.mapRowToCongregado(row)), + total, + page, + limit, + totalPages, + }; + } catch (error) { + throw new CongregadoDAOError('Error al obtener la lista de congregados', 'DATABASE_ERROR', error); + } + } + + async actualizar(id: number, data: ActualizarCongregadoRequest): Promise { + try { + const existente = await this.obtenerPorId(id); + if (!existente) { + throw new CongregadoDAOError('Congregado no encontrado', 'NOT_FOUND'); + } + + const segundoTelefono = data.segundoTelefono === null ? null : (data.segundoTelefono ?? existente.segundoTelefono); + const segundoMinisterio = data.segundoMinisterio === null ? null : (data.segundoMinisterio ?? existente.segundoMinisterio); + + const result = await db.query( + `UPDATE congregados SET + nombre = $1, + cedula = $2, + fecha_ingreso = $3, + telefono = $4, + segundo_telefono = $5, + estado_civil = $6, + ministerio = $7, + segundo_ministerio = $8, + url_foto_cedula = $9, + estado = $10, + updated_at = CURRENT_TIMESTAMP + WHERE id = $11 + RETURNING *`, + [ + data.nombre ?? existente.nombre, + data.cedula ?? existente.cedula, + data.fechaIngreso ? new Date(data.fechaIngreso) : existente.fechaIngreso, + data.telefono ?? existente.telefono, + segundoTelefono, + data.estadoCivil ?? existente.estadoCivil, + data.ministerio ?? existente.ministerio, + segundoMinisterio, + data.urlFotoCedula ?? existente.urlFotoCedula, + data.estado ?? existente.estado, + id, + ] + ); + + const congregado = this.mapRowToCongregado(result.rows[0]); + await AuditoriaDAO.registrar('congregados', congregado.id, 'edicion', 'Actualización de información del congregado'); + return congregado; + } catch (error: any) { + if (error instanceof CongregadoDAOError) throw error; + if (error.code === '23505') { + throw new CongregadoDAOError('Ya existe un congregado con esta cédula', 'DUPLICATE_KEY', error); + } + throw new CongregadoDAOError('Error al actualizar el congregado', 'DATABASE_ERROR', error); + } } - } - - async eliminar(id: number): Promise { - try { - const result = await prisma.congregado.updateMany({ - where: { id }, - data: { estado: EstadoCongregado.INACTIVO }, - }); - return result.count > 0; - } catch (error) { - throw new CongregadoDAOError('Error al eliminar el congregado', 'DATABASE_ERROR', error); + + async eliminar(id: number): Promise { + try { + const result = await db.query( + 'UPDATE congregados SET estado = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id', + [EstadoCongregado.INACTIVO, id] + ); + if (result.rows.length > 0) { + await AuditoriaDAO.registrar('congregados', id, 'eliminacion', 'Desactivación del congregado (Inactivo)'); + return true; + } + return false; + } catch (error) { + throw new CongregadoDAOError('Error al eliminar el congregado', 'DATABASE_ERROR', error); + } } - } - - async eliminarPermanente(id: number): Promise { - try { - await prisma.congregado.delete({ where: { id } }); - return true; - } catch (error: any) { - if (error.code === 'P2025') return false; - throw new CongregadoDAOError('Error al eliminar permanentemente el congregado', 'DATABASE_ERROR', error); + + async eliminarPermanente(id: number): Promise { + try { + const result = await db.query( + 'DELETE FROM congregados WHERE id = $1 RETURNING id', + [id] + ); + return result.rows.length > 0; + } catch (error) { + throw new CongregadoDAOError('Error al eliminar permanentemente el congregado', 'DATABASE_ERROR', error); + } } - } - - async listarTodos(): Promise { - try { - const rows = await prisma.congregado.findMany({ orderBy: { nombre: 'asc' } }); - return rows.map(mapToCongregado); - } catch (error) { - throw new CongregadoDAOError('Error al listar todos los congregados', 'DATABASE_ERROR', error); + + async listarTodos(): Promise { + try { + const result = await db.query( + 'SELECT * FROM congregados ORDER BY nombre ASC' + ); + return result.rows.map((row: any) => this.mapRowToCongregado(row)); + } catch (error) { + throw new CongregadoDAOError('Error al listar todos los congregados', 'DATABASE_ERROR', error); + } } - } - - async buscarPorNombre(nombre: string, limit: number = 10): Promise { - try { - const rows = await prisma.congregado.findMany({ - where: { - nombre: { contains: nombre, mode: 'insensitive' }, - estado: EstadoCongregado.ACTIVO, - }, - orderBy: { nombre: 'asc' }, - take: limit, - }); - return rows.map(mapToCongregado); - } catch (error) { - throw new CongregadoDAOError('Error al buscar congregados por nombre', 'DATABASE_ERROR', error); + + async buscarPorNombre(nombre: string, limit: number = 10): Promise { + try { + const result = await db.query( + `SELECT * FROM congregados + WHERE nombre ILIKE $1 AND estado = $2 + ORDER BY nombre ASC + LIMIT $3`, + [`%${nombre}%`, EstadoCongregado.ACTIVO, limit] + ); + return result.rows.map((row: any) => this.mapRowToCongregado(row)); + } catch (error) { + throw new CongregadoDAOError('Error al buscar congregados por nombre', 'DATABASE_ERROR', error); + } } - } - - async obtenerEstadisticas(): Promise<{ total: number; activos: number; inactivos: number }> { - try { - const [total, activos] = await Promise.all([ - prisma.congregado.count(), - prisma.congregado.count({ where: { estado: EstadoCongregado.ACTIVO } }), - ]); - return { total, activos, inactivos: total - activos }; - } catch (error) { - throw new CongregadoDAOError('Error al obtener estadísticas', 'DATABASE_ERROR', error); + + async obtenerEstadisticas(): Promise<{ total: number; activos: number; inactivos: number }> { + try { + const result = await db.query( + `SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE estado = $1) as activos, + COUNT(*) FILTER (WHERE estado = $2) as inactivos + FROM congregados`, + [EstadoCongregado.ACTIVO, EstadoCongregado.INACTIVO] + ); + return { + total: parseInt(result.rows[0].total, 10), + activos: parseInt(result.rows[0].activos, 10), + inactivos: parseInt(result.rows[0].inactivos, 10), + }; + } catch (error) { + throw new CongregadoDAOError('Error al obtener estadísticas', 'DATABASE_ERROR', error); + } } - } -} +} \ No newline at end of file diff --git a/src/dao/historial.dao.ts b/src/dao/historial.dao.ts new file mode 100644 index 0000000..1982598 --- /dev/null +++ b/src/dao/historial.dao.ts @@ -0,0 +1,298 @@ +import { db } from '@/lib/db'; +import { + ConsultaHistorialRequest, + HistorialItemDTO, + HistorialResponseDTO, +} from '@/dto/historial.dto'; + +export class HistorialDAOError extends Error { + constructor(message: string, public originalError?: unknown) { + super(message); + this.name = 'HistorialDAOError'; + } +} + +export class HistorialDAO { + + async obtenerPersona(id: number, tipo: string): Promise<{ id: number, nombre: string, identificacion?: string } | null> { + try { + if (tipo === 'usuario') { + const res = await db.query('SELECT id, nombre_completo as nombre, email as identificacion FROM usuarios WHERE id = $1', [id]); + return res.rows.length ? res.rows[0] : null; + } else if (tipo === 'asociado') { + const res = await db.query('SELECT id, nombre_completo as nombre, cedula as identificacion FROM asociados WHERE id = $1', [id]); + return res.rows.length ? res.rows[0] : null; + } else if (tipo === 'congregado') { + const res = await db.query('SELECT id, nombre, cedula as identificacion FROM congregados WHERE id = $1', [id]); + return res.rows.length ? res.rows[0] : null; + } + return null; + } catch (error) { + throw new HistorialDAOError('Error al obtener la persona', error); + } + } + + async obtenerHistorialCompleto(req: ConsultaHistorialRequest): Promise { + const { personaId, tipoPersona, filtros } = req; + + let persona = await this.obtenerPersona(personaId, tipoPersona); + + // Si la persona ya no existe (eliminación permanente), devolvemos un objeto básico + if (!persona) { + persona = { id: personaId, nombre: `Persona Eliminada (ID: ${personaId})`, identificacion: 'N/D' }; + } + + let historial: HistorialItemDTO[] = []; + + // Dependiendo del tipo de persona, buscamos en diferentes tablas. + // Si la persona es un Usuario, mostramos sus permisos + if (tipoPersona === 'usuario' && (!filtros?.tipoRegistro || filtros.tipoRegistro === 'todos' || filtros.tipoRegistro === 'permiso')) { + historial = historial.concat(await this.obtenerPermisosUsuario(personaId)); + } + + // Si la persona es un Asociado, mostramos sus asistencias + if (tipoPersona === 'asociado' && (!filtros?.tipoRegistro || filtros.tipoRegistro === 'todos' || filtros.tipoRegistro === 'asistencia')) { + historial = historial.concat(await this.obtenerAsistenciasAsociado(personaId)); + } + + // Para cualquier tipo de persona, mostramos las "modificaciones" reales de la bitácora + if (!filtros?.tipoRegistro || filtros.tipoRegistro === 'todos' || filtros.tipoRegistro === 'modificacion') { + const modalesAuditoria = await this.obtenerEventosAuditoria(personaId, tipoPersona); + + // Fallback: Si no hay NADA en auditoría (registros viejos), mostramos la simulación básica + if (modalesAuditoria.length === 0) { + historial = historial.concat(await this.obtenerModificaciones(personaId, tipoPersona)); + } else { + historial = historial.concat(modalesAuditoria); + } + } + + // Filtrar por fechas + if (filtros?.fechaDesde) { + const desde = new Date(filtros.fechaDesde).getTime(); + historial = historial.filter(h => new Date(h.fecha).getTime() >= desde); + } + + if (filtros?.fechaHasta) { + const hasta = new Date(filtros.fechaHasta).getTime(); + historial = historial.filter(h => new Date(h.fecha).getTime() <= hasta); + } + + // Ordenar cronológicamente descendente (más reciente primero) + historial.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime()); + + return { + persona: { + id: persona.id, + nombre: persona.nombre, + tipo: tipoPersona, + identificacion: persona.identificacion + }, + historial + }; + } + + private async obtenerPermisosUsuario(usuarioId: number): Promise { + try { + const res = await db.query( + `SELECT id, fecha_inicio, fecha_fin, motivo, estado, created_at, updated_at, observaciones_resolucion + FROM permisos WHERE usuario_id = $1 ORDER BY created_at DESC`, + [usuarioId] + ); + + const items: HistorialItemDTO[] = []; + + res.rows.forEach(row => { + // Solo mostramos resoluciones (Aprobado/Rechazado) en el historial + if (row.estado !== 'PENDIENTE') { + items.push({ + id_registro: 'p-res-' + row.id, + tipo: 'permiso', + fecha: row.updated_at, + descripcion: `Resolución de permiso: ${row.estado}`, + estado: row.estado, + detalles: { + motivo: row.motivo, + observaciones: row.observaciones_resolucion + } + }); + } + }); + + return items; + } catch (error) { + throw new HistorialDAOError('Error al obtener permisos de usuario', error); + } + } + + private async obtenerAsistenciasAsociado(asociadoId: number): Promise { + try { + // Usamos reportes_asistencia que contiene el estado real (presente/ausente/justificado) + const res = await db.query( + `SELECT ra.id, ra.fecha, ra.estado, ra.justificacion, ra.observaciones, + e.nombre as nombre_evento + FROM reportes_asistencia ra + JOIN eventos e ON ra.evento_id = e.id + WHERE ra.asociado_id = $1 ORDER BY ra.fecha DESC`, + [asociadoId] + ); + return res.rows.map(row => { + const estadoLabel = + row.estado === 'presente' ? 'Presente' : + row.estado === 'ausente' ? 'Ausente' : + row.estado === 'justificado' ? 'Justificado' : row.estado; + + return { + id_registro: row.id, + tipo: 'asistencia' as const, + fecha: row.fecha, + descripcion: `${estadoLabel} — ${row.nombre_evento}`, + estado: estadoLabel, + detalles: { + observaciones: row.justificacion || row.observaciones + } + }; + }); + } catch (error) { + throw new HistorialDAOError('Error al obtener asistencias de asociado', error); + } + } + + private async obtenerModificaciones(id: number, tipo: string): Promise { + try { + // Devolvemos el registro de modificación (updated_at y created_at) + const items: HistorialItemDTO[] = []; + let res; + + if (tipo === 'usuario') { + res = await db.query('SELECT created_at, updated_at, estado FROM usuarios WHERE id = $1', [id]); + } else if (tipo === 'asociado') { + res = await db.query('SELECT created_at, updated_at, estado FROM asociados WHERE id = $1', [id]); + } else if (tipo === 'congregado') { + res = await db.query('SELECT created_at, updated_at, estado FROM congregados WHERE id = $1', [id]); + } + + if (res && res.rows.length > 0) { + const { created_at, updated_at, estado } = res.rows[0]; + const statusText = estado === 1 ? 'Activo' : 'Inactivo'; + + const d1 = new Date(created_at).getTime(); + const d2 = new Date(updated_at).getTime(); + + // Debug para ver qué llega de la BD + console.log(`[HistorialDAO] ID:${id} - Created:${d1} - Updated:${d2} - Diff:${d2 - d1}`); + + // Registro de Actualización (si d2 > d1 con margen de 100ms) + const isModified = (d2 - d1) > 100; + + if (isModified) { + items.push({ + id_registro: 'mod-' + id, + tipo: 'modificacion', + fecha: updated_at, + descripcion: `Actualización de perfil (Estado: ${statusText})`, + estado: 'Completado' + }); + } + + // Registro de Creación/Registro Inicial + items.push({ + id_registro: 'creacion-' + id, + tipo: 'modificacion', + fecha: created_at, + descripcion: `Registro inicial de ${tipo} en el sistema`, + estado: 'Completado' + }); + } + + return items; + } catch (error) { + throw new HistorialDAOError('Error al obtener modificaciones de persona', error); + } + } + + private async obtenerEventosAuditoria(id: number, tipo: string): Promise { + try { + const tabla = tipo === 'asociado' ? 'asociados' : tipo === 'congregado' ? 'congregados' : 'usuarios'; + const res = await db.query( + 'SELECT id, accion, detalles, fecha FROM auditoria WHERE tabla = $1 AND registro_id = $2 ORDER BY fecha DESC', + [tabla, id] + ); + + return res.rows.map(row => ({ + id_registro: 'aud-' + row.id, + tipo: 'modificacion', + fecha: row.fecha, + descripcion: row.detalles, + estado: row.accion.toUpperCase(), + })); + } catch (error) { + console.error('Error al obtener eventos de auditoría:', error); + return []; + } + } + + public async obtenerHitosGlobales(): Promise { + try { + const items: HistorialItemDTO[] = []; + + // A. Hitos de la tabla auditoría con nombres reales (Asociados, Congregados, Usuarios y Eventos) + const resAud = await db.query(` + SELECT a.*, + COALESCE(aso.nombre_completo, con.nombre, usu.nombre_completo, e.nombre, 'Sistema') as nombre_persona + FROM auditoria a + LEFT JOIN asociados aso ON a.tabla = 'asociados' AND a.registro_id = aso.id + LEFT JOIN congregados con ON a.tabla = 'congregados' AND a.registro_id = con.id + LEFT JOIN usuarios usu ON a.tabla = 'usuarios' AND a.registro_id = usu.id + LEFT JOIN eventos e ON a.tabla = 'eventos' AND a.registro_id = e.id + ORDER BY a.fecha DESC LIMIT 100 + `); + + resAud.rows.forEach(a => { + // Limpiamos la descripción si viene con el formato viejo "(tabla)" + let desc = a.detalles || ''; + if (desc.includes('(' + a.tabla + ')')) { + desc = desc.split(' (' + a.tabla + ')')[0]; + } + + items.push({ + id_registro: 'aud-' + a.id, + tipo: 'modificacion', + fecha: a.fecha, + descripcion: desc, + estado: a.accion.toUpperCase(), + _persona: a.nombre_persona + } as any); + }); + + // B. Fallback de Eventos (para los que se crearon antes de la tabla auditoría) + const resEventos = await db.query('SELECT id, nombre, created_at FROM eventos ORDER BY created_at DESC LIMIT 50'); + resEventos.rows.forEach(e => { + // Solo añadir si NO hay una auditoría que ya mencione este evento (por ID o por nombre) + const yaExisteEnAuditoria = items.some(i => + (String(i.id_registro).includes('aud-global-') && i.descripcion.includes(e.nombre)) + ); + + if (!yaExisteEnAuditoria) { + items.push({ + id_registro: 'evt-old-' + e.id, + tipo: 'modificacion', + fecha: e.created_at, + descripcion: `Registro de evento: ${e.nombre}`, + estado: 'ACTIVO', + _persona: 'Sistema / Organización' + } as any); + } + }); + + // Ordenamos por fecha descendente + const finalItems = items.sort((a, b) => + new Date(b.fecha).getTime() - new Date(a.fecha).getTime() + ); + + return finalItems; + } catch (error) { + throw new HistorialDAOError('Error al obtener hitos globales', error); + } + } +} diff --git a/src/dto/historial.dto.ts b/src/dto/historial.dto.ts new file mode 100644 index 0000000..851da66 --- /dev/null +++ b/src/dto/historial.dto.ts @@ -0,0 +1,32 @@ +export type TipoRegistroHistorial = 'asistencia' | 'permiso' | 'modificacion'; + +export interface HistorialItemDTO { + id_registro: number | string; + tipo: TipoRegistroHistorial; + fecha: Date | string; + descripcion: string; + estado?: string; + detalles?: any; +} + +export interface HistorialFiltrosRequest { + fechaDesde?: string; + fechaHasta?: string; + tipoRegistro?: TipoRegistroHistorial | 'todos'; +} + +export interface ConsultaHistorialRequest { + personaId: number; + tipoPersona: 'usuario' | 'asociado' | 'congregado'; + filtros?: HistorialFiltrosRequest; +} + +export interface HistorialResponseDTO { + persona: { + id: number; + nombre: string; + tipo: string; + identificacion?: string; + }; + historial: HistorialItemDTO[]; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 03827a0..1ea6641 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,51 @@ -export { prisma } from '@/lib/prisma'; +import { config } from 'dotenv'; +config({ path: '.env.local' }); -// Kept for backward compatibility with code that imports `db` or `pool` -export const db = null as any; -export const pool = undefined as any; +import { sql } from '@vercel/postgres'; + +class DatabaseConnection { + private static instance: DatabaseConnection; + + private constructor() { + if (!process.env.POSTGRES_URL) { + console.error('CRITICAL: POSTGRES_URL is undefined in db.ts'); + } + } + + public static getInstance(): DatabaseConnection { + if (!DatabaseConnection.instance) { + DatabaseConnection.instance = new DatabaseConnection(); + } + return DatabaseConnection.instance; + } + + public async query(text: string, params?: any[]) { + try { + return await sql.query(text, params || []); + } catch (error) { + console.error('Error en la consulta SERVERLESS:', { text, error }); + throw error; + } + } + + public async getClient(): Promise { + throw new Error('getClient() no soportado con el driver Neon Serverless HTTP directo. Usa query()'); + } + + public async close(): Promise { + // El cliente neon HTTP no tiene conexiones persistentes para cerrar + } + + public async testConnection(): Promise { + try { + const result = await sql.query('SELECT NOW()'); + return !!result.rows[0]; + } catch (error) { + console.error('Error al conectar con la base de datos:', error); + return false; + } + } +} + +export const db = DatabaseConnection.getInstance(); +export const pool = undefined as any; \ No newline at end of file diff --git a/src/services/historial.service.ts b/src/services/historial.service.ts new file mode 100644 index 0000000..0bcb736 --- /dev/null +++ b/src/services/historial.service.ts @@ -0,0 +1,18 @@ +import { HistorialDAO } from '@/dao/historial.dao'; +import { ConsultaHistorialRequest, HistorialResponseDTO } from '@/dto/historial.dto'; + +export class HistorialService { + private historialDAO: HistorialDAO; + + constructor() { + this.historialDAO = new HistorialDAO(); + } + + async obtenerHistorial(req: ConsultaHistorialRequest): Promise { + try { + return await this.historialDAO.obtenerHistorialCompleto(req); + } catch (error: any) { + throw new Error(`Error en HistorialService: ${error.message}`); + } + } +} diff --git a/src/validators/historial.validator.ts b/src/validators/historial.validator.ts new file mode 100644 index 0000000..b9cf64a --- /dev/null +++ b/src/validators/historial.validator.ts @@ -0,0 +1,65 @@ +import { ConsultaHistorialRequest, TipoRegistroHistorial } from '@/dto/historial.dto'; + +export type ValidationIssue = { + field: "personaId" | "tipoPersona" | "fechaDesde" | "fechaHasta" | "tipoRegistro"; + message: string; +}; + +export type ValidationResult = { + ok: boolean; + issues: ValidationIssue[]; +}; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export function isValidDateYYYYMMDD(value: string): boolean { + if (!DATE_RE.test(value)) return false; + const [y, m, d] = value.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() === m - 1 && + dt.getUTCDate() === d + ); +} + +export function validateConsultaHistorialInput( + input: any +): ValidationResult { + const issues: ValidationIssue[] = []; + + const personaId = Number(input.personaId); + if (!Number.isFinite(personaId) || personaId <= 0) { + issues.push({ field: "personaId", message: "personaId debe ser un número entero positivo." }); + } + + const tiposValidos = ['usuario', 'asociado', 'congregado']; + if (!input.tipoPersona || !tiposValidos.includes(input.tipoPersona)) { + issues.push({ field: "tipoPersona", message: "tipoPersona debe ser usuario, asociado o congregado." }); + } + + if (input.fechaDesde) { + if (!isValidDateYYYYMMDD(input.fechaDesde)) { + issues.push({ field: "fechaDesde", message: "fechaDesde debe tener formato YYYY-MM-DD y ser válida." }); + } + } + + if (input.fechaHasta) { + if (!isValidDateYYYYMMDD(input.fechaHasta)) { + issues.push({ field: "fechaHasta", message: "fechaHasta debe tener formato YYYY-MM-DD y ser válida." }); + } + } + + if (input.fechaDesde && input.fechaHasta && isValidDateYYYYMMDD(input.fechaDesde) && isValidDateYYYYMMDD(input.fechaHasta)) { + if (new Date(input.fechaDesde) > new Date(input.fechaHasta)) { + issues.push({ field: "fechaDesde", message: "fechaDesde no puede ser mayor que fechaHasta." }); + } + } + + const tiposRegistroValidos = ['todos', 'asistencia', 'permiso', 'modificacion']; + if (input.tipoRegistro && !tiposRegistroValidos.includes(input.tipoRegistro)) { + issues.push({ field: "tipoRegistro", message: "tipoRegistro es inválido." }); + } + + return { ok: issues.length === 0, issues }; +}