From cf07c32eb4d8d0aff96422928c42828375b127aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 19:07:17 +0000 Subject: [PATCH 1/3] Add Prisma ORM migration, new Asociado fields, and project documentation - Migrate all DAOs from raw SQL to Prisma ORM v7 - Add prisma.config.ts and src/lib/prisma.ts singleton with Neon adapter - Extend Asociado model with 9 new fields (fechaNacimiento, estadoCivil, profesion, telefonoContacto, anosCongregarse, fechaAceptacion, perteneceJuntaDirectiva, puestoJuntaDirectiva, URL fields) - Update DTO, service layer, and API routes for new fields - Extend registro-asociados form with new fields and multi-file upload - Fix EstadoAsistencia build error in asistencia/reporteAsistencia DAOs - Add postinstall prisma generate to package.json - Add project documentation generator script and generated docx Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_019xoahf9Vb5VkuXibvFTjyS --- documentacion-scrcr.docx | Bin 0 -> 19710 bytes generar-doc.cjs | 527 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 94 +++++++ package.json | 1 + 4 files changed, 622 insertions(+) create mode 100644 documentacion-scrcr.docx create mode 100644 generar-doc.cjs diff --git a/documentacion-scrcr.docx b/documentacion-scrcr.docx new file mode 100644 index 0000000000000000000000000000000000000000..30c107299535898a43b58fa0e516c2f3074c7c30 GIT binary patch literal 19710 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 literal 0 HcmV?d00001 diff --git a/generar-doc.cjs b/generar-doc.cjs new file mode 100644 index 0000000..244b5d7 --- /dev/null +++ b/generar-doc.cjs @@ -0,0 +1,527 @@ +const { + Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, + WidthType, BorderStyle, AlignmentType, ShadingType, UnderlineType, +} = require('docx'); +const fs = require('fs'); + +const AZUL = '003366'; +const AZUL_CLARO = 'E8EFF7'; +const GRIS = 'F5F5F5'; +const NEGRO = '1A1A1A'; + +function h1(text) { + return new Paragraph({ + text, + heading: HeadingLevel.HEADING_1, + spacing: { before: 400, after: 200 }, + children: [new TextRun({ text, bold: true, color: AZUL, size: 32 })], + }); +} + +function h2(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_2, + spacing: { before: 300, after: 150 }, + children: [new TextRun({ text, bold: true, color: AZUL, size: 26 })], + }); +} + +function h3(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_3, + spacing: { before: 200, after: 100 }, + children: [new TextRun({ text, bold: true, color: NEGRO, size: 22 })], + }); +} + +function p(text, opts = {}) { + return new Paragraph({ + spacing: { before: 80, after: 80 }, + children: [new TextRun({ text, size: 20, color: NEGRO, ...opts })], + }); +} + +function bullet(text, level = 0) { + return new Paragraph({ + bullet: { level }, + spacing: { before: 60, after: 60 }, + children: [new TextRun({ text, size: 20, color: NEGRO })], + }); +} + +function bold(text) { + return new TextRun({ text, bold: true, size: 20, color: NEGRO }); +} + +function separator() { + return new Paragraph({ + border: { bottom: { color: AZUL, space: 1, value: BorderStyle.SINGLE, size: 6 } }, + spacing: { before: 200, after: 200 }, + children: [], + }); +} + +function tableRow(cells, isHeader = false) { + return new TableRow({ + children: cells.map(cell => + new TableCell({ + shading: isHeader ? { type: ShadingType.CLEAR, color: AZUL, fill: AZUL } : { type: ShadingType.CLEAR, fill: 'FFFFFF' }, + margins: { top: 80, bottom: 80, left: 120, right: 120 }, + children: [ + new Paragraph({ + alignment: AlignmentType.LEFT, + children: [new TextRun({ text: cell, bold: isHeader, color: isHeader ? 'FFFFFF' : NEGRO, size: 18 })], + }), + ], + }) + ), + }); +} + +function makeTable(headers, rows) { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + tableRow(headers, true), + ...rows.map(r => tableRow(r, false)), + ], + }); +} + +const doc = new Document({ + creator: 'Sistema SCRCR', + title: 'Documentación del Sistema SCRCR', + description: 'Estado actual del proyecto, arquitectura y módulos', + sections: [ + { + children: [ + + // ── PORTADA ────────────────────────────────────────────────────────── + new Paragraph({ spacing: { before: 1200 } }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: 'SISTEMA SCRCR', bold: true, size: 52, color: AZUL })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 200 }, + children: [new TextRun({ text: 'Sistema de Control y Registro de', size: 28, color: '555555' })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 100 }, + children: [new TextRun({ text: 'Congregados y Recursos', size: 28, color: '555555' })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 400 }, + children: [new TextRun({ text: 'Iglesia Bíblica Emanuel — Liberia', size: 24, italics: true, color: '777777' })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 200 }, + children: [new TextRun({ text: 'Documentación Técnica del Sistema', size: 22, color: '777777' })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 200 }, + children: [new TextRun({ text: `Fecha: ${new Date().toLocaleDateString('es-CR', { year: 'numeric', month: 'long', day: 'numeric' })}`, size: 20, color: '999999' })], + }), + new Paragraph({ pageBreakBefore: true }), + + // ── 1. DESCRIPCIÓN GENERAL ─────────────────────────────────────────── + h1('1. Descripción General del Proyecto'), + separator(), + p('SCRCR es una aplicación web desarrollada para la Iglesia Bíblica Emanuel de Liberia. Su propósito es digitalizar y centralizar la gestión de los miembros de la iglesia, el control de asistencia, la administración de personal, permisos, actas y reportes.'), + p(''), + p('El sistema permite administrar dos tipos de miembros: asociados (miembros formales con derechos plenos) y congregados (miembros regulares de la congregación). Además gestiona el personal administrativo, los eventos de la iglesia, las actas de asambleas, la planilla de empleados y los permisos de ausencia.'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Tecnologías Principales'), + makeTable( + ['Tecnología', 'Versión', 'Uso'], + [ + ['Next.js', '15.x', 'Framework fullstack con App Router'], + ['TypeScript', '5.x', 'Tipado estático en todo el proyecto'], + ['PostgreSQL', 'Neon (Serverless)', 'Base de datos principal'], + ['Prisma ORM', '7.8', 'Acceso a BD con type-safety'], + ['Tailwind CSS', '4.x', 'Estilos y diseño responsivo'], + ['JWT + bcryptjs', '—', 'Autenticación y hash de contraseñas'], + ['jose', '6.x', 'Firma y verificación de tokens JWT'], + ['Zod', '4.x', 'Validación de esquemas'], + ['SweetAlert2', '11.x', 'Alertas y confirmaciones UI'], + ['React Icons', '5.x', 'Iconografía'], + ['jsPDF + ExcelJS', '—', 'Exportación de reportes PDF/Excel'], + ['Vercel Blob', '—', 'Almacenamiento de documentos'], + ] + ), + + new Paragraph({ pageBreakBefore: true }), + + // ── 2. ARQUITECTURA ────────────────────────────────────────────────── + h1('2. Arquitectura del Sistema'), + separator(), + p('El sistema implementa una arquitectura en capas derivada del patrón MVC (Model-View-Controller), adaptada al contexto de Next.js con App Router. Se aplica el patrón DAO (Data Access Object) para aislar la lógica de acceso a datos, y DTOs (Data Transfer Objects) para desacoplar las capas.'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Capas del Sistema'), + makeTable( + ['Capa', 'Ubicación', 'Responsabilidad'], + [ + ['Presentación (View)', 'src/app/*/page.tsx', 'Componentes React del lado del cliente (UI, formularios, tablas)'], + ['Controlador (Controller)', 'src/app/api/*/route.ts', 'API Routes de Next.js que reciben peticiones HTTP y retornan JSON'], + ['Servicio (Service)', 'src/services/*.service.ts', 'Lógica de negocio: validación, transformación y orquestación'], + ['DAO (Data Access Object)', 'src/dao/*.dao.ts', 'Acceso a base de datos mediante Prisma. Una clase por entidad'], + ['Modelo (Model)', 'src/models/*.ts', 'Interfaces TypeScript que representan las entidades del dominio'], + ['DTO', 'src/dto/*.dto.ts', 'Objetos de transferencia para entrada (Request) y salida (Response)'], + ['Validadores', 'src/validators/*.validator.ts', 'Reglas de validación de datos de entrada'], + ['ORM', 'prisma/schema.prisma', 'Definición del esquema de BD y modelos Prisma'], + ['Middleware', 'src/middleware.ts', 'Autenticación y autorización en el Edge de Next.js'], + ['Utilidades', 'src/lib/ y src/utils/', 'Auth JWT, conexión Prisma, exportación CSV, permisos de roles'], + ] + ), + + new Paragraph({ spacing: { before: 300, after: 100 } }), + h3('Flujo de una Petición'), + bullet('1. El usuario interactúa con la UI (page.tsx) — componente React cliente'), + bullet('2. Se realiza una petición fetch() a una API Route (/api/*)'), + bullet('3. El Middleware verifica el JWT antes de llegar al controlador'), + bullet('4. La API Route (Controller) parsea el body y llama al Service correspondiente'), + bullet('5. El Service valida los datos, aplica la lógica de negocio y llama al DAO'), + bullet('6. El DAO ejecuta la consulta en PostgreSQL a través de Prisma'), + bullet('7. El resultado sube por las capas como DTO/Response y se retorna como JSON'), + + new Paragraph({ spacing: { before: 300, after: 100 } }), + h3('Patrones de Diseño Aplicados'), + makeTable( + ['Patrón', 'Dónde se aplica'], + [ + ['DAO (Data Access Object)', 'Cada entidad tiene su propio DAO que encapsula todas las consultas a BD'], + ['DTO (Data Transfer Object)', 'DTOs separados para Request y Response desacoplan el modelo interno de la API'], + ['Singleton', 'PrismaClient y DatabaseConnection se instancian una sola vez'], + ['Factory / Builder', 'AsociadoModel, CongregadoModel construyen entidades desde datos crudos'], + ['Repository (implícito)', 'Los DAOs actúan como repositorios sobre las entidades Prisma'], + ['Service Layer', 'Toda la lógica de negocio está aislada en servicios, los controllers son delgados'], + ['Middleware Chain', 'El Edge Middleware de Next.js intercepta y evalúa cada petición'], + ['Soft Delete', 'Los registros se marcan con estado=0 en lugar de eliminarse físicamente'], + ] + ), + + new Paragraph({ pageBreakBefore: true }), + + // ── 3. MÓDULOS FRONTEND ────────────────────────────────────────────── + h1('3. Módulos del Frontend'), + separator(), + p('Todas las páginas usan el App Router de Next.js. Las páginas con "use client" manejan estado local y efectos del lado del cliente. El layout global incluye el Sidebar y la Navbar.'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + makeTable( + ['Módulo / Ruta', 'Descripción', 'Acceso'], + [ + ['/ (Inicio)', 'Dashboard principal con accesos rápidos a módulos según el rol del usuario', 'Público'], + ['/login', 'Formulario de autenticación con manejo de errores y bloqueo de cuenta', 'Solo no autenticados'], + ['/consulta-asociados', 'Gestión completa de asociados: listado, búsqueda, creación, edición, eliminación, exportación Excel/PDF', 'Admin / Pastor General'], + ['/congregados', 'Gestión de congregados con filtros avanzados, paginación, subida de documentos y fotos', 'Admin / Tesorero / Pastor'], + ['/eventos', 'Alta, edición y desactivación de eventos de la iglesia. Vinculados a asistencia y actas', 'Protegida'], + ['/asistencia/registro', 'Registro de asistencia individual o masiva a eventos, por asociado/congregado', 'Protegida'], + ['/reportes', 'Reportes de asistencia filtrados por evento, fecha, estado. Exportación Excel/PDF', 'Protegida'], + ['/actas', 'Registro de sesiones y actas de asamblea de asociados y junta directiva, con lista de asistentes', 'Protegida'], + ['/permisos', 'Solicitud y gestión de permisos/ausencias con validación de traslapes de fechas', 'Protegida'], + ['/permisos/registro', 'Formulario para solicitar nuevos permisos de ausencia', 'Protegida'], + ['/planilla', 'Gestión de empleados, salarios, vacaciones y permisos de personal administrativo', 'Protegida'], + ['/historial', 'Tabla de auditoría con todos los cambios realizados en el sistema', 'Protegida'], + ['/gestion-usuarios', 'Alta, edición y desactivación de usuarios del sistema (solo admin)', 'Solo admin'], + ['/gestion-roles', 'Configuración dinámica de permisos por rol y módulo (solo admin)', 'Solo admin'], + ['/configuracion', 'Configuración general del sistema y preferencias', 'Admin / Pastor / Tesorero'], + ['/unauthorized', 'Página de acceso denegado cuando el rol no tiene permiso', 'Pública'], + ] + ), + + new Paragraph({ spacing: { before: 300, after: 100 } }), + h3('Componentes Reutilizables'), + makeTable( + ['Componente', 'Descripción'], + [ + ['SideBar.tsx', 'Menú lateral responsivo con navegación dinámica según el rol. En mobile colapsa con hamburguesa'], + ['Navbar.tsx', 'Barra superior con logo, nombre de usuario, rol, y botón de logout'], + ['ProtectedRoute.tsx', 'HOC que verifica autenticación y redirige si el usuario no está logueado'], + ['ToastProvider.tsx', 'Proveedor de notificaciones emergentes (react-hot-toast)'], + ['AccessibilityWidget.tsx', 'Widget flotante con opciones de zoom, contraste y accesibilidad'], + ] + ), + + new Paragraph({ pageBreakBefore: true }), + + // ── 4. MÓDULOS BACKEND / API ───────────────────────────────────────── + h1('4. API REST — Endpoints del Backend'), + separator(), + + h3('Autenticación (/api/auth)'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['POST', '/api/auth/login', 'Valida credenciales, genera JWT de 24h y lo guarda en cookie HttpOnly. Bloquea tras 5 intentos fallidos por 30 minutos'], + ['POST', '/api/auth/logout', 'Elimina la cookie auth-token y cierra la sesión'], + ['GET', '/api/auth/me', 'Retorna los datos del usuario autenticado actualmente'], + ['GET', '/api/auth/verify', 'Verifica si el token es válido y no ha expirado'], + ['GET', '/api/auth/verify-role', 'Valida que el token tenga el rol requerido'], + ] + ), + + new Paragraph({ spacing: { before: 200 } }), + h3('Asociados (/api/asociados)'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['GET', '/api/asociados', 'Lista todos los asociados sin paginación'], + ['POST', '/api/asociados', 'Crea un nuevo asociado con validación completa'], + ['GET', '/api/asociados/[id]', 'Obtiene un asociado por ID con todos sus campos'], + ['PUT', '/api/asociados/update?id=X', 'Actualiza campos de un asociado existente'], + ['DELETE', '/api/asociados/delete?id=X', 'Soft delete (estado=0) o hard delete permanente'], + ['GET', '/api/asociados/consulta', 'Búsqueda con filtros (nombre, cédula) y paginación'], + ] + ), + + new Paragraph({ spacing: { before: 200 } }), + h3('Congregados (/api/congregados)'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['GET', '/api/congregados', 'Lista congregados con filtros y paginación'], + ['POST', '/api/congregados', 'Crea congregado con documentos asociados'], + ['GET', '/api/congregados/[id]', 'Obtiene congregado por ID'], + ['PUT', '/api/congregados/[id]', 'Actualiza datos y documentos del congregado'], + ['DELETE', '/api/congregados/[id]', 'Elimina o desactiva el congregado'], + ] + ), + + new Paragraph({ spacing: { before: 200 } }), + h3('Eventos, Asistencia y Reportes'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['GET/POST', '/api/eventos', 'Listar y crear eventos'], + ['GET/PUT/DELETE', '/api/eventos/[id]', 'Operaciones CRUD sobre evento específico'], + ['POST', '/api/asistencia/registro', 'Registra asistencia individual a un evento'], + ['GET/POST', '/api/reporte-asistencia', 'Crear y consultar reportes de asistencia con estado'], + ['GET', '/api/reportes/asistencia', 'Reportes con filtros (evento, fecha, estado)'], + ['GET', '/api/reportes/asistencia/export', 'Exporta reporte en CSV o Excel'], + ] + ), + + new Paragraph({ spacing: { before: 200 } }), + h3('Usuarios y Permisos'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['GET/POST', '/api/usuarios', 'Listar y crear usuarios del sistema'], + ['GET/PUT/DELETE', '/api/usuarios/[id]', 'CRUD sobre usuario específico'], + ['GET/POST', '/api/permisos', 'Solicitar y listar permisos de ausencia'], + ['PUT', '/api/permisos/[id]/estado', 'Aprobar o rechazar una solicitud de permiso'], + ['GET', '/api/permisos/traslape', 'Verifica si un rango de fechas traslapa con otro permiso'], + ] + ), + + new Paragraph({ spacing: { before: 200 } }), + h3('Actas, Empleados y Otros'), + makeTable( + ['Método', 'Ruta', 'Descripción'], + [ + ['GET/POST', '/api/actas/asociacion', 'CRUD de actas de asamblea de asociados'], + ['POST', '/api/actas/asociacion/[id]/asistencia', 'Registra asistencia a un acta específica'], + ['GET/POST', '/api/actas/jd', 'CRUD de actas de junta directiva'], + ['GET/POST', '/api/empleados', 'Gestión de empleados con planilla y vacaciones'], + ['GET/POST', '/api/empleados/vacaciones', 'Solicitudes de vacaciones de empleados'], + ['POST', '/api/documentos/upload', 'Subida de documentos al almacenamiento (Vercel Blob)'], + ['GET', '/api/blob-download', 'Descarga de archivos desde Vercel Blob'], + ['GET/POST', '/api/historial', 'Registro de auditoría de cambios en el sistema'], + ['GET/POST', '/api/roles-config', 'Configuración de permisos por rol en BD'], + ] + ), + + new Paragraph({ pageBreakBefore: true }), + + // ── 5. BASE DE DATOS ───────────────────────────────────────────────── + h1('5. Modelo de Base de Datos'), + separator(), + p('La base de datos está hospedada en Neon (PostgreSQL serverless). El acceso se realiza exclusivamente a través de Prisma ORM con el adaptador @prisma/adapter-neon para conexiones serverless.'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Tablas Principales'), + makeTable( + ['Tabla', 'Descripción', 'Campos Clave'], + [ + ['usuarios', 'Cuentas de acceso al sistema', 'username, email, passwordHash, rol, intentosFallidos, bloqueadoHasta'], + ['asociados', 'Miembros formales de la asociación', 'cedula, fechaIngreso, estadoCivil, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud'], + ['congregados', 'Miembros regulares de la congregación', 'cedula, ministerio, estadoCivil, urlFotoCedula'], + ['empleados', 'Personal administrativo con planilla', 'cedula, puesto, salarioBase, cuentaBancaria, diasVacacionesDisponibles'], + ['eventos', 'Actividades y reuniones de la iglesia', 'nombre, fecha, hora, activo'], + ['asistencias', 'Registro de asistencia por asociado/evento', 'asociadoId, eventoId — UNIQUE(asociadoId, eventoId)'], + ['reportes_asistencia', 'Reporte consolidado con estado', 'asociadoId, eventoId, fecha, estado (enum), justificacion'], + ['permisos', 'Solicitudes de ausencia del personal', 'usuarioId, fechaInicio, fechaFin, estado (PENDIENTE/APROBADO/RECHAZADO)'], + ['actas_asociacion', 'Actas de asamblea de asociados', 'fecha, tipoSesion, urlActa'], + ['asistencias_acta_asociacion', 'Asistencia por acta y asociado', 'actaId, asociadoId, estado, justificacion'], + ['actas_junta_directiva', 'Actas de junta directiva', 'fecha, tipoSesion, urlActa'], + ['vacaciones_empleado', 'Solicitudes de vacaciones', 'empleadoId, fechaInicio, fechaFin, cantidadDias, estado'], + ['permisos_empleado', 'Permisos de ausencia de empleados', 'empleadoId, fechaInicio, fechaFin, estado'], + ['permisos_rol', 'Control de acceso por rol y módulo', 'rol, modulo, activo'], + ['auditoria', 'Historial de cambios en el sistema', 'tabla, registroId, accion, detalles, fecha'], + ] + ), + + new Paragraph({ spacing: { before: 300, after: 100 } }), + h3('Enum EstadoAsistencia'), + bullet('presente — El miembro estuvo en el evento'), + bullet('ausente — No se presentó sin justificación'), + bullet('justificado — No asistió pero con justificación registrada'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Estrategia de Eliminación'), + bullet('Soft Delete: Los asociados y congregados se marcan con estado = 0 (inactivo) y se excluyen de las búsquedas por defecto'), + bullet('Hard Delete: Opción disponible para eliminar registros permanentemente de la BD'), + bullet('Cascade: Las asistencias y reportes se eliminan en cascada cuando se elimina el asociado/evento padre'), + + new Paragraph({ pageBreakBefore: true }), + + // ── 6. AUTENTICACIÓN Y SEGURIDAD ───────────────────────────────────── + h1('6. Autenticación y Seguridad'), + separator(), + + h3('Flujo de Autenticación'), + bullet('1. El usuario ingresa username y password en /login'), + bullet('2. El backend verifica la contraseña con bcryptjs (hash bcrypt, salt 10)'), + bullet('3. Si es correcta: se genera un JWT firmado con HS256 y JWT_SECRET (24h de validez)'), + bullet('4. El token se guarda en una cookie HttpOnly, Secure, SameSite: Lax'), + bullet('5. En cada petición el Middleware Edge verifica el token antes de llegar al controlador'), + bullet('6. Si el token es inválido o expiró: se limpia la cookie y se redirige al login'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Mecanismo de Bloqueo de Cuenta'), + bullet('Después de 5 intentos fallidos consecutivos, la cuenta queda bloqueada durante 30 minutos'), + bullet('El campo bloqueadoHasta en la tabla usuarios almacena el timestamp de desbloqueo'), + bullet('Al login exitoso se resetean los intentos fallidos a 0'), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Roles del Sistema'), + makeTable( + ['Rol', 'Etiqueta', 'Acceso'], + [ + ['admin', 'Administrador', 'Acceso total: usuarios, roles, todas las secciones'], + ['pastorGeneral', 'Pastor General', 'Asociados, congregados, eventos, reportes, actas, permisos, configuración'], + ['juntaDirectiva', 'Junta Directiva', 'Consulta de asociados, reportes y actas'], + ['asistenteAdministrativo', 'Asistente Administrativo', 'Congregados, usuarios, eventos, permisos, asistencia, reportes'], + ['tesorero', 'Tesorero', 'Congregados, reportes y configuración'], + ] + ), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Control de Acceso en 3 Niveles'), + bullet('Nivel 1 — Edge Middleware: Verifica JWT y redirige según rol antes de servir la página'), + bullet('Nivel 2 — Frontend (useAuth hook): Filtra el menú del Sidebar mostrando solo módulos permitidos por rol'), + bullet('Nivel 3 — API Routes: Valida token en cada endpoint y aplica lógica específica de permisos'), + + new Paragraph({ pageBreakBefore: true }), + + // ── 7. SERVICIOS CLAVE ─────────────────────────────────────────────── + h1('7. Servicios y Lógica de Negocio'), + separator(), + + h3('UsuarioService'), + p('Gestiona la autenticación: valida credenciales, genera tokens, maneja bloqueos y registra el último acceso. Usa bcryptjs para verificar contraseñas hasheadas.'), + + h3('AsociadoService'), + p('Controla el ciclo de vida completo de los asociados: creación con validación, actualización, soft delete y hard delete. Valida y sanitiza datos antes de persistirlos. Mapea el modelo interno al DTO de respuesta, incluyendo todos los campos de documentos y junta directiva.'), + + h3('CongregadoService'), + p('Análogo al AsociadoService pero para congregados. Gestiona documentos de identidad (foto de cédula) y campos adicionales como segundo ministerio, segundo teléfono, fecha de nacimiento y profesión.'), + + h3('PermisoService'), + p('Verifica que no haya traslape de fechas con permisos existentes (PENDIENTE o APROBADO) del mismo usuario antes de crear una nueva solicitud. Permite aprobar o rechazar permisos con observaciones.'), + + h3('AsistenciaService / ReporteAsistenciaService'), + p('El servicio de asistencia valida que el asociado exista y previene registros duplicados por evento/asociado/fecha. El reporte usa UPSERT para actualizar el estado si ya existe el registro, en lugar de duplicarlo.'), + + new Paragraph({ pageBreakBefore: true }), + + // ── 8. ESTADO ACTUAL ───────────────────────────────────────────────── + h1('8. Estado Actual del Proyecto'), + separator(), + + h3('Funcionalidades Implementadas'), + makeTable( + ['Módulo', 'Estado'], + [ + ['Autenticación (login, logout, JWT, bloqueo de cuenta)', '✅ Completo'], + ['Gestión de Usuarios (CRUD completo)', '✅ Completo'], + ['Gestión de Roles y Permisos (configuración dinámica)', '✅ Completo'], + ['Gestión de Asociados (CRUD, documentos, junta directiva)', '✅ Completo'], + ['Gestión de Congregados (CRUD, documentos, filtros)', '✅ Completo'], + ['Gestión de Eventos (CRUD, activar/desactivar)', '✅ Completo'], + ['Registro de Asistencia (individual y masivo)', '✅ Completo'], + ['Reportes de Asistencia (con exportación Excel/PDF)', '✅ Completo'], + ['Actas de Asamblea y Junta Directiva', '✅ Completo'], + ['Solicitud y Aprobación de Permisos (con validación de traslapes)', '✅ Completo'], + ['Planilla de Empleados (salarios, vacaciones, permisos)', '✅ Completo'], + ['Historial de Auditoría', '✅ Completo'], + ['Migración a Prisma ORM 7', '✅ Completo'], + ['Subida de Documentos (Vercel Blob)', '✅ Completo'], + ['Middleware de autenticación Edge', '✅ Completo'], + ['Widget de Accesibilidad', '✅ Completo'], + ] + ), + + new Paragraph({ spacing: { before: 200, after: 100 } }), + h3('Pendiente / En Progreso'), + bullet('Aplicar migración de BD para los nuevos campos de asociados (npx prisma db push)'), + bullet('Formulario de Registro de Asociados con campos extendidos (en revisión)'), + bullet('Módulo de recuperación de contraseña (/recuperar-password)'), + bullet('Página de Historial (/historial) con filtros avanzados'), + + new Paragraph({ pageBreakBefore: true }), + + // ── 9. ESTRUCTURA DE CARPETAS ──────────────────────────────────────── + h1('9. Estructura de Carpetas'), + separator(), + makeTable( + ['Carpeta / Archivo', 'Descripción'], + [ + ['src/app/', 'Páginas y API Routes (Next.js App Router)'], + ['src/app/api/', 'Endpoints REST organizados por dominio'], + ['src/components/', 'Componentes React reutilizables (Sidebar, Navbar, etc.)'], + ['src/contexts/', 'Contextos React (AuthContext)'], + ['src/dao/', 'Data Access Objects — consultas Prisma por entidad'], + ['src/dto/', 'Data Transfer Objects para request y response'], + ['src/hooks/', 'Hooks personalizados (useAuth)'], + ['src/lib/', 'Utilidades globales: auth.ts (JWT), prisma.ts (cliente), db.ts'], + ['src/middleware.ts', 'Middleware Edge de Next.js (autenticación global)'], + ['src/models/', 'Interfaces y clases TypeScript del dominio'], + ['src/services/', 'Lógica de negocio por dominio'], + ['src/utils/', 'Funciones auxiliares (exportación CSV, permisos de roles)'], + ['src/validators/', 'Validadores de datos de entrada'], + ['prisma/schema.prisma', 'Esquema de BD: modelos, relaciones y enums'], + ['prisma.config.ts', 'Configuración de Prisma 7 (URL de conexión)'], + ['database/schema.sql', 'SQL original de referencia del esquema'], + ['.env.local', 'Variables de entorno (POSTGRES_URL, JWT_SECRET)'], + ] + ), + + new Paragraph({ spacing: { before: 400 } }), + separator(), + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 200 }, + children: [new TextRun({ text: 'Sistema SCRCR — Iglesia Bíblica Emanuel, Liberia', size: 18, italics: true, color: '999999' })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: `Documento generado el ${new Date().toLocaleDateString('es-CR')}`, size: 16, color: 'BBBBBB' })], + }), + ], + }, + ], +}); + +Packer.toBuffer(doc).then(buffer => { + fs.writeFileSync('/home/user/SCRCR/documentacion-scrcr.docx', buffer); + console.log('✅ Documento generado: documentacion-scrcr.docx'); +}); diff --git a/package-lock.json b/package-lock.json index 76ca621..cb3c562 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@vercel/blob": "^2.3.3", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.3", + "docx": "^9.7.1", "dotenv": "^17.2.3", "exceljs": "^4.4.0", "jose": "^6.1.1", @@ -4243,6 +4244,56 @@ "node": ">=8" } }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4781,6 +4832,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hono": { "version": "4.12.17", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.17.tgz", @@ -5700,6 +5761,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6609,6 +6676,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7560,6 +7636,24 @@ "node": ">=0.8" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 2ebb792..1b84899 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@vercel/blob": "^2.3.3", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.3", + "docx": "^9.7.1", "dotenv": "^17.2.3", "exceljs": "^4.4.0", "jose": "^6.1.1", From a9e34e24f28e5e9a44a016be82b578a21d5967cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 19:35:40 +0000 Subject: [PATCH 2/3] Improve Word documentation table formatting with visible borders and column widths Co-Authored-By: Claude --- documentacion-scrcr.docx | Bin 19710 -> 22437 bytes generar-doc.cjs | 653 ++++++++++++++++++++++----------------- 2 files changed, 369 insertions(+), 284 deletions(-) diff --git a/documentacion-scrcr.docx b/documentacion-scrcr.docx index 30c107299535898a43b58fa0e516c2f3074c7c30..0c6fc1cb11c5953ec81abe14265f79240f312450 100644 GIT binary patch delta 16562 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 - new TableCell({ - shading: isHeader ? { type: ShadingType.CLEAR, color: AZUL, fill: AZUL } : { type: ShadingType.CLEAR, fill: 'FFFFFF' }, - margins: { top: 80, bottom: 80, left: 120, right: 120 }, +function makeCell(text, isHeader, shade) { + return new TableCell({ + shading: { + type: ShadingType.CLEAR, + color: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), + fill: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), + }, + borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE }, + margins: { top: 80, bottom: 80, left: 140, right: 140 }, + children: [ + new Paragraph({ + alignment: AlignmentType.LEFT, children: [ - new Paragraph({ - alignment: AlignmentType.LEFT, - children: [new TextRun({ text: cell, bold: isHeader, color: isHeader ? 'FFFFFF' : NEGRO, size: 18 })], + new TextRun({ + text, + bold: isHeader, + color: isHeader ? BLANCO : NEGRO, + size: isHeader ? 20 : 18, + font: 'Calibri', }), ], - }) - ), + }), + ], }); } -function makeTable(headers, rows) { +function makeTable(headers, rows, colWidths) { + const total = 9360; // total DXA width for a page with normal margins + const n = headers.length; + const widths = colWidths + ? colWidths.map(w => Math.round((w / 100) * total)) + : headers.map(() => Math.round(total / n)); + + const headerRow = new TableRow({ + tableHeader: true, + children: headers.map((h, i) => + Object.assign(makeCell(h, true, false), { + // apply width per cell + }) && (() => { + const c = makeCell(h, true, false); + c.options = c.options || {}; + return c; + })() + ), + }); + + // rebuild using width on cell + function cellWithWidth(text, isHeader, shade, width) { + return new TableCell({ + width: { size: width, type: WidthType.DXA }, + shading: { + type: ShadingType.CLEAR, + color: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), + fill: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), + }, + borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE }, + margins: { top: 80, bottom: 80, left: 140, right: 140 }, + children: [ + new Paragraph({ + alignment: AlignmentType.LEFT, + children: [ + new TextRun({ + text, + bold: isHeader, + color: isHeader ? BLANCO : NEGRO, + size: isHeader ? 20 : 18, + font: 'Calibri', + }), + ], + }), + ], + }); + } + return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE, insideH: BORDE, insideV: BORDE }, rows: [ - tableRow(headers, true), - ...rows.map(r => tableRow(r, false)), + new TableRow({ + tableHeader: true, + children: headers.map((h, i) => cellWithWidth(h, true, false, widths[i])), + }), + ...rows.map((row, ri) => + new TableRow({ + children: row.map((cell, i) => cellWithWidth(cell, false, ri % 2 === 1, widths[i])), + }) + ), ], }); } +// ── documento ───────────────────────────────────────────────────────────────── + const doc = new Document({ creator: 'Sistema SCRCR', - title: 'Documentación del Sistema SCRCR', - description: 'Estado actual del proyecto, arquitectura y módulos', + title: 'Documentación Técnica del Sistema SCRCR', + description: 'Estado actual, arquitectura y módulos del sistema', + styles: { + default: { + document: { + run: { font: 'Calibri', size: 20, color: NEGRO }, + }, + }, + }, sections: [ { + properties: { + page: { + margin: { top: 1440, bottom: 1440, left: 1440, right: 1080 }, + }, + }, children: [ - // ── PORTADA ────────────────────────────────────────────────────────── - new Paragraph({ spacing: { before: 1200 } }), + // ── PORTADA ───────────────────────────────────────────────────────── + gap(1800), new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ text: 'SISTEMA SCRCR', bold: true, size: 52, color: AZUL })], + children: [new TextRun({ text: 'SISTEMA SCRCR', bold: true, size: 60, color: AZUL, font: 'Calibri' })], }), + gap(200), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 200 }, - children: [new TextRun({ text: 'Sistema de Control y Registro de', size: 28, color: '555555' })], + children: [new TextRun({ text: 'Sistema de Control y Registro de', size: 30, color: '444444', font: 'Calibri' })], }), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 100 }, - children: [new TextRun({ text: 'Congregados y Recursos', size: 28, color: '555555' })], + children: [new TextRun({ text: 'Congregados y Recursos', size: 30, color: '444444', font: 'Calibri' })], }), + gap(400), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 400 }, - children: [new TextRun({ text: 'Iglesia Bíblica Emanuel — Liberia', size: 24, italics: true, color: '777777' })], + border: { + top: { style: BorderStyle.SINGLE, size: 6, color: AZUL_HEADER }, + bottom: { style: BorderStyle.SINGLE, size: 6, color: AZUL_HEADER }, + }, + spacing: { before: 120, after: 120 }, + children: [ + new TextRun({ text: 'Iglesia Bíblica Emanuel — Liberia', size: 26, italics: true, color: '666666', font: 'Calibri' }), + ], }), + gap(400), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 200 }, - children: [new TextRun({ text: 'Documentación Técnica del Sistema', size: 22, color: '777777' })], + children: [new TextRun({ text: 'Documentación Técnica del Sistema', size: 24, color: '888888', font: 'Calibri' })], }), + gap(100), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 200 }, - children: [new TextRun({ text: `Fecha: ${new Date().toLocaleDateString('es-CR', { year: 'numeric', month: 'long', day: 'numeric' })}`, size: 20, color: '999999' })], + children: [new TextRun({ + text: `Fecha: ${new Date().toLocaleDateString('es-CR', { year: 'numeric', month: 'long', day: 'numeric' })}`, + size: 20, color: '999999', font: 'Calibri', + })], }), new Paragraph({ pageBreakBefore: true }), // ── 1. DESCRIPCIÓN GENERAL ─────────────────────────────────────────── h1('1. Descripción General del Proyecto'), - separator(), p('SCRCR es una aplicación web desarrollada para la Iglesia Bíblica Emanuel de Liberia. Su propósito es digitalizar y centralizar la gestión de los miembros de la iglesia, el control de asistencia, la administración de personal, permisos, actas y reportes.'), - p(''), - p('El sistema permite administrar dos tipos de miembros: asociados (miembros formales con derechos plenos) y congregados (miembros regulares de la congregación). Además gestiona el personal administrativo, los eventos de la iglesia, las actas de asambleas, la planilla de empleados y los permisos de ausencia.'), + gap(80), + p('El sistema diferencia dos tipos de miembros: asociados (miembros formales con derechos plenos) y congregados (miembros regulares). Además gestiona el personal administrativo, eventos, actas de asambleas, planilla de empleados y permisos de ausencia.'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Tecnologías Principales'), makeTable( - ['Tecnología', 'Versión', 'Uso'], + ['Tecnología', 'Versión', 'Uso en el Sistema'], [ - ['Next.js', '15.x', 'Framework fullstack con App Router'], - ['TypeScript', '5.x', 'Tipado estático en todo el proyecto'], - ['PostgreSQL', 'Neon (Serverless)', 'Base de datos principal'], - ['Prisma ORM', '7.8', 'Acceso a BD con type-safety'], - ['Tailwind CSS', '4.x', 'Estilos y diseño responsivo'], - ['JWT + bcryptjs', '—', 'Autenticación y hash de contraseñas'], - ['jose', '6.x', 'Firma y verificación de tokens JWT'], - ['Zod', '4.x', 'Validación de esquemas'], - ['SweetAlert2', '11.x', 'Alertas y confirmaciones UI'], - ['React Icons', '5.x', 'Iconografía'], - ['jsPDF + ExcelJS', '—', 'Exportación de reportes PDF/Excel'], - ['Vercel Blob', '—', 'Almacenamiento de documentos'], - ] + ['Next.js', '15.x', 'Framework fullstack con App Router'], + ['TypeScript', '5.x', 'Tipado estático en todo el proyecto'], + ['PostgreSQL (Neon)', 'Serverless', 'Base de datos principal'], + ['Prisma ORM', '7.8', 'Acceso a BD con type-safety completo'], + ['Tailwind CSS', '4.x', 'Estilos y diseño responsivo'], + ['jose', '6.x', 'Firma y verificación de tokens JWT'], + ['bcryptjs', '3.x', 'Hash y verificación de contraseñas'], + ['Zod', '4.x', 'Validación de esquemas de datos'], + ['SweetAlert2', '11.x', 'Alertas y confirmaciones UI'], + ['jsPDF + ExcelJS', '—', 'Exportación de reportes PDF / Excel'], + ['Vercel Blob', '—', 'Almacenamiento de documentos'], + ], + [28, 16, 56] ), new Paragraph({ pageBreakBefore: true }), // ── 2. ARQUITECTURA ────────────────────────────────────────────────── h1('2. Arquitectura del Sistema'), - separator(), - p('El sistema implementa una arquitectura en capas derivada del patrón MVC (Model-View-Controller), adaptada al contexto de Next.js con App Router. Se aplica el patrón DAO (Data Access Object) para aislar la lógica de acceso a datos, y DTOs (Data Transfer Objects) para desacoplar las capas.'), + p('El sistema implementa una arquitectura en capas derivada del patrón MVC, adaptada al contexto de Next.js App Router. Se aplica el patrón DAO para aislar la lógica de acceso a datos, y DTOs para desacoplar las capas.'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Capas del Sistema'), makeTable( ['Capa', 'Ubicación', 'Responsabilidad'], [ - ['Presentación (View)', 'src/app/*/page.tsx', 'Componentes React del lado del cliente (UI, formularios, tablas)'], - ['Controlador (Controller)', 'src/app/api/*/route.ts', 'API Routes de Next.js que reciben peticiones HTTP y retornan JSON'], - ['Servicio (Service)', 'src/services/*.service.ts', 'Lógica de negocio: validación, transformación y orquestación'], - ['DAO (Data Access Object)', 'src/dao/*.dao.ts', 'Acceso a base de datos mediante Prisma. Una clase por entidad'], - ['Modelo (Model)', 'src/models/*.ts', 'Interfaces TypeScript que representan las entidades del dominio'], - ['DTO', 'src/dto/*.dto.ts', 'Objetos de transferencia para entrada (Request) y salida (Response)'], - ['Validadores', 'src/validators/*.validator.ts', 'Reglas de validación de datos de entrada'], - ['ORM', 'prisma/schema.prisma', 'Definición del esquema de BD y modelos Prisma'], - ['Middleware', 'src/middleware.ts', 'Autenticación y autorización en el Edge de Next.js'], - ['Utilidades', 'src/lib/ y src/utils/', 'Auth JWT, conexión Prisma, exportación CSV, permisos de roles'], - ] + ['Presentación (View)', 'src/app/*/page.tsx', 'Componentes React cliente: UI, formularios, tablas'], + ['Controlador (Controller)', 'src/app/api/*/route.ts', 'API Routes de Next.js — reciben HTTP y retornan JSON'], + ['Servicio (Service)', 'src/services/*.service.ts', 'Lógica de negocio: validación, transformación, orquestación'], + ['DAO', 'src/dao/*.dao.ts', 'Acceso a BD mediante Prisma. Una clase por entidad'], + ['Modelo (Model)', 'src/models/*.ts', 'Interfaces TypeScript que representan entidades del dominio'], + ['DTO', 'src/dto/*.dto.ts', 'Objetos de transferencia para entrada (Request) y salida (Response)'], + ['Validadores', 'src/validators/*.validator.ts', 'Reglas de validación de datos de entrada'], + ['ORM', 'prisma/schema.prisma', 'Esquema de BD y modelos Prisma'], + ['Middleware', 'src/middleware.ts', 'Autenticación y autorización en el Edge de Next.js'], + ['Utilidades', 'src/lib/ y src/utils/', 'Auth JWT, cliente Prisma, exportación CSV, permisos de roles'], + ], + [24, 30, 46] ), - new Paragraph({ spacing: { before: 300, after: 100 } }), - h3('Flujo de una Petición'), - bullet('1. El usuario interactúa con la UI (page.tsx) — componente React cliente'), - bullet('2. Se realiza una petición fetch() a una API Route (/api/*)'), - bullet('3. El Middleware verifica el JWT antes de llegar al controlador'), - bullet('4. La API Route (Controller) parsea el body y llama al Service correspondiente'), - bullet('5. El Service valida los datos, aplica la lógica de negocio y llama al DAO'), + gap(240), + h3('Flujo de una Petición HTTP'), + bullet('1. El usuario interactúa con la UI (page.tsx) — componente React del lado del cliente'), + bullet('2. Se realiza un fetch() a una API Route (/api/*)'), + bullet('3. El Middleware Edge verifica el JWT antes de enrutar la petición'), + bullet('4. La API Route parsea el body y delega al Service correspondiente'), + bullet('5. El Service valida, aplica la lógica de negocio y llama al DAO'), bullet('6. El DAO ejecuta la consulta en PostgreSQL a través de Prisma'), bullet('7. El resultado sube por las capas como DTO/Response y se retorna como JSON'), - new Paragraph({ spacing: { before: 300, after: 100 } }), + gap(240), h3('Patrones de Diseño Aplicados'), makeTable( ['Patrón', 'Dónde se aplica'], [ ['DAO (Data Access Object)', 'Cada entidad tiene su propio DAO que encapsula todas las consultas a BD'], - ['DTO (Data Transfer Object)', 'DTOs separados para Request y Response desacoplan el modelo interno de la API'], - ['Singleton', 'PrismaClient y DatabaseConnection se instancian una sola vez'], - ['Factory / Builder', 'AsociadoModel, CongregadoModel construyen entidades desde datos crudos'], + ['DTO (Data Transfer Object)', 'DTOs separados para Request y Response desacoplan el modelo de la API'], + ['Singleton', 'PrismaClient se instancia una sola vez y se reutiliza globalmente'], ['Repository (implícito)', 'Los DAOs actúan como repositorios sobre las entidades Prisma'], - ['Service Layer', 'Toda la lógica de negocio está aislada en servicios, los controllers son delgados'], - ['Middleware Chain', 'El Edge Middleware de Next.js intercepta y evalúa cada petición'], + ['Service Layer', 'Toda la lógica de negocio está aislada en servicios; los controllers son delgados'], + ['Middleware Chain', 'El Edge Middleware intercepta y evalúa cada petición antes de llegar al handler'], ['Soft Delete', 'Los registros se marcan con estado=0 en lugar de eliminarse físicamente'], - ] + ], + [38, 62] ), new Paragraph({ pageBreakBefore: true }), // ── 3. MÓDULOS FRONTEND ────────────────────────────────────────────── h1('3. Módulos del Frontend'), - separator(), - p('Todas las páginas usan el App Router de Next.js. Las páginas con "use client" manejan estado local y efectos del lado del cliente. El layout global incluye el Sidebar y la Navbar.'), + p('Todas las páginas usan el App Router de Next.js. Las páginas marcadas con "use client" manejan estado local y efectos del lado del cliente. El layout global incluye el Sidebar y la Navbar.'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), makeTable( - ['Módulo / Ruta', 'Descripción', 'Acceso'], + ['Módulo / Ruta', 'Descripción', 'Acceso por Rol'], [ - ['/ (Inicio)', 'Dashboard principal con accesos rápidos a módulos según el rol del usuario', 'Público'], - ['/login', 'Formulario de autenticación con manejo de errores y bloqueo de cuenta', 'Solo no autenticados'], - ['/consulta-asociados', 'Gestión completa de asociados: listado, búsqueda, creación, edición, eliminación, exportación Excel/PDF', 'Admin / Pastor General'], - ['/congregados', 'Gestión de congregados con filtros avanzados, paginación, subida de documentos y fotos', 'Admin / Tesorero / Pastor'], - ['/eventos', 'Alta, edición y desactivación de eventos de la iglesia. Vinculados a asistencia y actas', 'Protegida'], - ['/asistencia/registro', 'Registro de asistencia individual o masiva a eventos, por asociado/congregado', 'Protegida'], - ['/reportes', 'Reportes de asistencia filtrados por evento, fecha, estado. Exportación Excel/PDF', 'Protegida'], - ['/actas', 'Registro de sesiones y actas de asamblea de asociados y junta directiva, con lista de asistentes', 'Protegida'], - ['/permisos', 'Solicitud y gestión de permisos/ausencias con validación de traslapes de fechas', 'Protegida'], - ['/permisos/registro', 'Formulario para solicitar nuevos permisos de ausencia', 'Protegida'], - ['/planilla', 'Gestión de empleados, salarios, vacaciones y permisos de personal administrativo', 'Protegida'], - ['/historial', 'Tabla de auditoría con todos los cambios realizados en el sistema', 'Protegida'], - ['/gestion-usuarios', 'Alta, edición y desactivación de usuarios del sistema (solo admin)', 'Solo admin'], - ['/gestion-roles', 'Configuración dinámica de permisos por rol y módulo (solo admin)', 'Solo admin'], - ['/configuracion', 'Configuración general del sistema y preferencias', 'Admin / Pastor / Tesorero'], - ['/unauthorized', 'Página de acceso denegado cuando el rol no tiene permiso', 'Pública'], - ] + ['/ (Dashboard)', 'Pantalla principal con accesos rápidos según el rol del usuario', 'Todos los roles'], + ['/login', 'Formulario de autenticación con bloqueo tras 5 intentos fallidos', 'Solo no autenticados'], + ['/consulta-asociados', 'Gestión completa: listado, búsqueda, creación, edición, documentos, exportación Excel/PDF', 'Admin / Pastor General'], + ['/congregados', 'Gestión de congregados con filtros, paginación, subida de foto y documentos', 'Admin / Tesorero / Pastor'], + ['/eventos', 'Alta, edición y desactivación de eventos de la iglesia, vinculados a asistencia y actas', 'Protegida'], + ['/asistencia/registro', 'Registro de asistencia individual o masiva a eventos por asociado o congregado', 'Protegida'], + ['/reportes', 'Reportes de asistencia filtrados por evento, fecha y estado. Exportación Excel/PDF', 'Protegida'], + ['/actas', 'Registro de sesiones y actas de asamblea de asociados y junta directiva con lista de asistentes', 'Protegida'], + ['/permisos', 'Solicitud y gestión de permisos/ausencias con validación de traslapes de fechas', 'Protegida'], + ['/planilla', 'Gestión de empleados, salarios, vacaciones y permisos de personal administrativo', 'Protegida'], + ['/historial', 'Tabla de auditoría con todos los cambios realizados en el sistema', 'Protegida'], + ['/gestion-usuarios', 'Alta, edición y desactivación de cuentas de usuario (solo admin)', 'Solo admin'], + ['/gestion-roles', 'Configuración dinámica de permisos por rol y módulo', 'Solo admin'], + ['/unauthorized', 'Página de acceso denegado cuando el rol no tiene permiso', 'Pública'], + ], + [28, 48, 24] ), - new Paragraph({ spacing: { before: 300, after: 100 } }), + gap(240), h3('Componentes Reutilizables'), makeTable( ['Componente', 'Descripción'], [ - ['SideBar.tsx', 'Menú lateral responsivo con navegación dinámica según el rol. En mobile colapsa con hamburguesa'], - ['Navbar.tsx', 'Barra superior con logo, nombre de usuario, rol, y botón de logout'], - ['ProtectedRoute.tsx', 'HOC que verifica autenticación y redirige si el usuario no está logueado'], - ['ToastProvider.tsx', 'Proveedor de notificaciones emergentes (react-hot-toast)'], - ['AccessibilityWidget.tsx', 'Widget flotante con opciones de zoom, contraste y accesibilidad'], - ] + ['SideBar.tsx', 'Menú lateral responsivo con navegación dinámica según el rol. En mobile colapsa con hamburguesa'], + ['Navbar.tsx', 'Barra superior con logo, nombre de usuario, rol activo y botón de cierre de sesión'], + ['ProtectedRoute.tsx', 'HOC que verifica autenticación y redirige si el usuario no está logueado'], + ['ToastProvider.tsx', 'Proveedor de notificaciones emergentes (react-hot-toast)'], + ['AccessibilityWidget.tsx','Widget flotante con opciones de zoom, contraste y accesibilidad'], + ], + [30, 70] ), new Paragraph({ pageBreakBefore: true }), - // ── 4. MÓDULOS BACKEND / API ───────────────────────────────────────── + // ── 4. API REST ────────────────────────────────────────────────────── h1('4. API REST — Endpoints del Backend'), - separator(), h3('Autenticación (/api/auth)'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['POST', '/api/auth/login', 'Valida credenciales, genera JWT de 24h y lo guarda en cookie HttpOnly. Bloquea tras 5 intentos fallidos por 30 minutos'], - ['POST', '/api/auth/logout', 'Elimina la cookie auth-token y cierra la sesión'], - ['GET', '/api/auth/me', 'Retorna los datos del usuario autenticado actualmente'], - ['GET', '/api/auth/verify', 'Verifica si el token es válido y no ha expirado'], - ['GET', '/api/auth/verify-role', 'Valida que el token tenga el rol requerido'], - ] + ['POST', '/api/auth/login', 'Valida credenciales, genera JWT de 24 h y lo guarda en cookie HttpOnly'], + ['POST', '/api/auth/logout', 'Elimina la cookie auth-token y cierra la sesión'], + ['GET', '/api/auth/me', 'Retorna los datos del usuario autenticado actualmente'], + ['GET', '/api/auth/verify', 'Verifica si el token JWT es válido y no ha expirado'], + ['GET', '/api/auth/verify-role', 'Valida que el token tenga el rol requerido para el módulo'], + ], + [12, 34, 54] ), - new Paragraph({ spacing: { before: 200 } }), + gap(160), h3('Asociados (/api/asociados)'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET', '/api/asociados', 'Lista todos los asociados sin paginación'], - ['POST', '/api/asociados', 'Crea un nuevo asociado con validación completa'], - ['GET', '/api/asociados/[id]', 'Obtiene un asociado por ID con todos sus campos'], - ['PUT', '/api/asociados/update?id=X', 'Actualiza campos de un asociado existente'], - ['DELETE', '/api/asociados/delete?id=X', 'Soft delete (estado=0) o hard delete permanente'], - ['GET', '/api/asociados/consulta', 'Búsqueda con filtros (nombre, cédula) y paginación'], - ] + ['GET', '/api/asociados', 'Lista todos los asociados sin paginación'], + ['POST', '/api/asociados', 'Crea un nuevo asociado con validación completa'], + ['GET', '/api/asociados/[id]', 'Obtiene un asociado por ID con todos sus campos'], + ['PUT', '/api/asociados/update?id=X', 'Actualiza campos de un asociado existente'], + ['DELETE', '/api/asociados/delete?id=X', 'Soft delete (estado=0) o hard delete permanente'], + ['GET', '/api/asociados/consulta', 'Búsqueda con filtros (nombre, cédula) y paginación'], + ], + [14, 36, 50] ), - new Paragraph({ spacing: { before: 200 } }), + gap(160), h3('Congregados (/api/congregados)'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET', '/api/congregados', 'Lista congregados con filtros y paginación'], - ['POST', '/api/congregados', 'Crea congregado con documentos asociados'], - ['GET', '/api/congregados/[id]', 'Obtiene congregado por ID'], - ['PUT', '/api/congregados/[id]', 'Actualiza datos y documentos del congregado'], + ['GET', '/api/congregados', 'Lista congregados con filtros y paginación'], + ['POST', '/api/congregados', 'Crea congregado con documentos asociados'], + ['GET', '/api/congregados/[id]', 'Obtiene congregado por ID'], + ['PUT', '/api/congregados/[id]', 'Actualiza datos y documentos del congregado'], ['DELETE', '/api/congregados/[id]', 'Elimina o desactiva el congregado'], - ] + ], + [14, 36, 50] ), - new Paragraph({ spacing: { before: 200 } }), + gap(160), h3('Eventos, Asistencia y Reportes'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET/POST', '/api/eventos', 'Listar y crear eventos'], - ['GET/PUT/DELETE', '/api/eventos/[id]', 'Operaciones CRUD sobre evento específico'], - ['POST', '/api/asistencia/registro', 'Registra asistencia individual a un evento'], - ['GET/POST', '/api/reporte-asistencia', 'Crear y consultar reportes de asistencia con estado'], - ['GET', '/api/reportes/asistencia', 'Reportes con filtros (evento, fecha, estado)'], - ['GET', '/api/reportes/asistencia/export', 'Exporta reporte en CSV o Excel'], - ] + ['GET/POST', '/api/eventos', 'Listar y crear eventos'], + ['GET/PUT/DELETE', '/api/eventos/[id]', 'CRUD sobre evento específico'], + ['POST', '/api/asistencia/registro', 'Registra asistencia individual a un evento'], + ['GET/POST', '/api/reporte-asistencia', 'Crear y consultar reportes de asistencia con estado'], + ['GET', '/api/reportes/asistencia', 'Reportes con filtros (evento, fecha, estado)'], + ['GET', '/api/reportes/asistencia/export', 'Exporta reporte en CSV o Excel'], + ], + [20, 38, 42] ), - new Paragraph({ spacing: { before: 200 } }), - h3('Usuarios y Permisos'), + gap(160), + h3('Usuarios, Permisos, Actas y Otros'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET/POST', '/api/usuarios', 'Listar y crear usuarios del sistema'], - ['GET/PUT/DELETE', '/api/usuarios/[id]', 'CRUD sobre usuario específico'], - ['GET/POST', '/api/permisos', 'Solicitar y listar permisos de ausencia'], - ['PUT', '/api/permisos/[id]/estado', 'Aprobar o rechazar una solicitud de permiso'], - ['GET', '/api/permisos/traslape', 'Verifica si un rango de fechas traslapa con otro permiso'], - ] - ), - - new Paragraph({ spacing: { before: 200 } }), - h3('Actas, Empleados y Otros'), - makeTable( - ['Método', 'Ruta', 'Descripción'], - [ - ['GET/POST', '/api/actas/asociacion', 'CRUD de actas de asamblea de asociados'], - ['POST', '/api/actas/asociacion/[id]/asistencia', 'Registra asistencia a un acta específica'], - ['GET/POST', '/api/actas/jd', 'CRUD de actas de junta directiva'], - ['GET/POST', '/api/empleados', 'Gestión de empleados con planilla y vacaciones'], - ['GET/POST', '/api/empleados/vacaciones', 'Solicitudes de vacaciones de empleados'], - ['POST', '/api/documentos/upload', 'Subida de documentos al almacenamiento (Vercel Blob)'], - ['GET', '/api/blob-download', 'Descarga de archivos desde Vercel Blob'], - ['GET/POST', '/api/historial', 'Registro de auditoría de cambios en el sistema'], - ['GET/POST', '/api/roles-config', 'Configuración de permisos por rol en BD'], - ] + ['GET/POST', '/api/usuarios', 'Listar y crear usuarios del sistema'], + ['GET/PUT/DEL','/api/usuarios/[id]', 'CRUD sobre usuario específico'], + ['GET/POST', '/api/permisos', 'Solicitar y listar permisos de ausencia'], + ['PUT', '/api/permisos/[id]/estado', 'Aprobar o rechazar una solicitud de permiso'], + ['GET', '/api/permisos/traslape', 'Verifica si un rango de fechas traslapa con otro permiso'], + ['GET/POST', '/api/actas/asociacion', 'CRUD de actas de asamblea de asociados'], + ['POST', '/api/actas/asociacion/[id]/asistencia', 'Registra asistencia a un acta específica'], + ['GET/POST', '/api/actas/jd', 'CRUD de actas de junta directiva'], + ['GET/POST', '/api/empleados', 'Gestión de empleados con planilla y vacaciones'], + ['POST', '/api/documentos/upload', 'Subida de documentos al almacenamiento (Vercel Blob)'], + ['GET', '/api/blob-download', 'Descarga de archivos desde Vercel Blob'], + ['GET/POST', '/api/historial', 'Registro de auditoría de cambios en el sistema'], + ], + [18, 40, 42] ), new Paragraph({ pageBreakBefore: true }), // ── 5. BASE DE DATOS ───────────────────────────────────────────────── h1('5. Modelo de Base de Datos'), - separator(), - p('La base de datos está hospedada en Neon (PostgreSQL serverless). El acceso se realiza exclusivamente a través de Prisma ORM con el adaptador @prisma/adapter-neon para conexiones serverless.'), + p('La base de datos está hospedada en Neon (PostgreSQL serverless). El acceso se realiza exclusivamente a través de Prisma ORM v7 con el adaptador @prisma/adapter-neon para conexiones serverless eficientes.'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Tablas Principales'), makeTable( ['Tabla', 'Descripción', 'Campos Clave'], [ - ['usuarios', 'Cuentas de acceso al sistema', 'username, email, passwordHash, rol, intentosFallidos, bloqueadoHasta'], - ['asociados', 'Miembros formales de la asociación', 'cedula, fechaIngreso, estadoCivil, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud'], - ['congregados', 'Miembros regulares de la congregación', 'cedula, ministerio, estadoCivil, urlFotoCedula'], - ['empleados', 'Personal administrativo con planilla', 'cedula, puesto, salarioBase, cuentaBancaria, diasVacacionesDisponibles'], - ['eventos', 'Actividades y reuniones de la iglesia', 'nombre, fecha, hora, activo'], - ['asistencias', 'Registro de asistencia por asociado/evento', 'asociadoId, eventoId — UNIQUE(asociadoId, eventoId)'], - ['reportes_asistencia', 'Reporte consolidado con estado', 'asociadoId, eventoId, fecha, estado (enum), justificacion'], - ['permisos', 'Solicitudes de ausencia del personal', 'usuarioId, fechaInicio, fechaFin, estado (PENDIENTE/APROBADO/RECHAZADO)'], - ['actas_asociacion', 'Actas de asamblea de asociados', 'fecha, tipoSesion, urlActa'], - ['asistencias_acta_asociacion', 'Asistencia por acta y asociado', 'actaId, asociadoId, estado, justificacion'], - ['actas_junta_directiva', 'Actas de junta directiva', 'fecha, tipoSesion, urlActa'], - ['vacaciones_empleado', 'Solicitudes de vacaciones', 'empleadoId, fechaInicio, fechaFin, cantidadDias, estado'], - ['permisos_empleado', 'Permisos de ausencia de empleados', 'empleadoId, fechaInicio, fechaFin, estado'], - ['permisos_rol', 'Control de acceso por rol y módulo', 'rol, modulo, activo'], - ['auditoria', 'Historial de cambios en el sistema', 'tabla, registroId, accion, detalles, fecha'], - ] + ['usuarios', 'Cuentas de acceso al sistema', 'username, email, passwordHash, rol, intentosFallidos, bloqueadoHasta'], + ['asociados', 'Miembros formales de la asociación', 'cedula, estadoCivil, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud'], + ['congregados', 'Miembros regulares de la congregación', 'cedula, ministerio, estadoCivil, urlFotoCedula'], + ['empleados', 'Personal administrativo con planilla', 'cedula, puesto, salarioBase, cuentaBancaria, diasVacacionesDisponibles'], + ['eventos', 'Actividades y reuniones de la iglesia', 'nombre, fecha, hora, activo'], + ['asistencias', 'Registro de asistencia por asociado/evento', 'asociadoId, eventoId — UNIQUE(asociadoId, eventoId)'], + ['reportes_asistencia', 'Reporte consolidado con estado', 'asociadoId, eventoId, fecha, estado (presente/ausente/justificado)'], + ['permisos', 'Solicitudes de ausencia del personal', 'usuarioId, fechaInicio, fechaFin, estado (PENDIENTE/APROBADO/RECHAZADO)'], + ['actas_asociacion', 'Actas de asamblea de asociados', 'fecha, tipoSesion, urlActa'], + ['asistencias_acta_asociacion', 'Asistencia por acta y asociado', 'actaId, asociadoId, estado, justificacion'], + ['actas_junta_directiva', 'Actas de junta directiva', 'fecha, tipoSesion, urlActa'], + ['vacaciones_empleado', 'Solicitudes de vacaciones', 'empleadoId, fechaInicio, fechaFin, cantidadDias, estado'], + ['permisos_rol', 'Control de acceso por rol y módulo en BD', 'rol, modulo, activo'], + ['auditoria', 'Historial de cambios en el sistema', 'tabla, registroId, accion, detalles, fecha'], + ], + [24, 28, 48] ), - new Paragraph({ spacing: { before: 300, after: 100 } }), - h3('Enum EstadoAsistencia'), - bullet('presente — El miembro estuvo en el evento'), - bullet('ausente — No se presentó sin justificación'), - bullet('justificado — No asistió pero con justificación registrada'), - - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(240), h3('Estrategia de Eliminación'), - bullet('Soft Delete: Los asociados y congregados se marcan con estado = 0 (inactivo) y se excluyen de las búsquedas por defecto'), - bullet('Hard Delete: Opción disponible para eliminar registros permanentemente de la BD'), - bullet('Cascade: Las asistencias y reportes se eliminan en cascada cuando se elimina el asociado/evento padre'), + bullet('Soft Delete — Los asociados y congregados se marcan con estado = 0 (inactivo) y se excluyen de las búsquedas por defecto. Permite recuperación de datos.'), + bullet('Hard Delete — Opción disponible para eliminar registros permanentemente de la base de datos cuando sea necesario.'), + bullet('Cascade — Las asistencias y reportes se eliminan en cascada cuando se elimina el asociado o evento padre.'), new Paragraph({ pageBreakBefore: true }), - // ── 6. AUTENTICACIÓN Y SEGURIDAD ───────────────────────────────────── + // ── 6. AUTENTICACIÓN ───────────────────────────────────────────────── h1('6. Autenticación y Seguridad'), - separator(), h3('Flujo de Autenticación'), bullet('1. El usuario ingresa username y password en /login'), bullet('2. El backend verifica la contraseña con bcryptjs (hash bcrypt, salt 10)'), - bullet('3. Si es correcta: se genera un JWT firmado con HS256 y JWT_SECRET (24h de validez)'), + bullet('3. Si es correcta: se genera un JWT firmado con HS256 y JWT_SECRET (validez 24 h)'), bullet('4. El token se guarda en una cookie HttpOnly, Secure, SameSite: Lax'), bullet('5. En cada petición el Middleware Edge verifica el token antes de llegar al controlador'), bullet('6. Si el token es inválido o expiró: se limpia la cookie y se redirige al login'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(160), h3('Mecanismo de Bloqueo de Cuenta'), bullet('Después de 5 intentos fallidos consecutivos, la cuenta queda bloqueada durante 30 minutos'), bullet('El campo bloqueadoHasta en la tabla usuarios almacena el timestamp de desbloqueo'), - bullet('Al login exitoso se resetean los intentos fallidos a 0'), + bullet('Al login exitoso se resetean los intentos fallidos a 0 automáticamente'), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Roles del Sistema'), makeTable( - ['Rol', 'Etiqueta', 'Acceso'], + ['Rol (código)', 'Etiqueta visible', 'Módulos con acceso'], [ - ['admin', 'Administrador', 'Acceso total: usuarios, roles, todas las secciones'], - ['pastorGeneral', 'Pastor General', 'Asociados, congregados, eventos, reportes, actas, permisos, configuración'], - ['juntaDirectiva', 'Junta Directiva', 'Consulta de asociados, reportes y actas'], - ['asistenteAdministrativo', 'Asistente Administrativo', 'Congregados, usuarios, eventos, permisos, asistencia, reportes'], - ['tesorero', 'Tesorero', 'Congregados, reportes y configuración'], - ] + ['admin', 'Administrador', 'Acceso total a todos los módulos del sistema'], + ['pastorGeneral', 'Pastor General', 'Asociados, congregados, eventos, reportes, actas, permisos, configuración'], + ['juntaDirectiva', 'Junta Directiva', 'Consulta de asociados, reportes y actas'], + ['asistenteAdministrativo','Asistente Administrativo','Congregados, usuarios, eventos, permisos, asistencia, reportes'], + ['tesorero', 'Tesorero', 'Congregados, reportes financieros y configuración'], + ], + [26, 26, 48] ), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Control de Acceso en 3 Niveles'), - bullet('Nivel 1 — Edge Middleware: Verifica JWT y redirige según rol antes de servir la página'), - bullet('Nivel 2 — Frontend (useAuth hook): Filtra el menú del Sidebar mostrando solo módulos permitidos por rol'), - bullet('Nivel 3 — API Routes: Valida token en cada endpoint y aplica lógica específica de permisos'), + bullet('Nivel 1 — Edge Middleware (src/middleware.ts): Verifica el JWT y redirige según el rol antes de servir la página. Runs en el Edge de Vercel, sin servidor Node.'), + bullet('Nivel 2 — Frontend (useAuth hook + Sidebar): Filtra el menú mostrando solo los módulos permitidos para el rol activo del usuario.'), + bullet('Nivel 3 — API Routes: Cada endpoint valida el token y aplica lógica específica de permisos antes de ejecutar la operación.'), new Paragraph({ pageBreakBefore: true }), - // ── 7. SERVICIOS CLAVE ─────────────────────────────────────────────── + // ── 7. SERVICIOS ───────────────────────────────────────────────────── h1('7. Servicios y Lógica de Negocio'), - separator(), h3('UsuarioService'), - p('Gestiona la autenticación: valida credenciales, genera tokens, maneja bloqueos y registra el último acceso. Usa bcryptjs para verificar contraseñas hasheadas.'), + p('Gestiona la autenticación: valida credenciales, genera tokens, maneja el contador de intentos fallidos y los bloqueos temporales. Usa bcryptjs para verificar contraseñas hasheadas.'), h3('AsociadoService'), - p('Controla el ciclo de vida completo de los asociados: creación con validación, actualización, soft delete y hard delete. Valida y sanitiza datos antes de persistirlos. Mapea el modelo interno al DTO de respuesta, incluyendo todos los campos de documentos y junta directiva.'), + p('Controla el ciclo de vida completo de los asociados: creación con validación, actualización parcial de campos, soft delete y hard delete. Valida y sanitiza datos antes de persistir. Mapea el modelo interno al DTO de respuesta incluyendo documentos, junta directiva y campos extendidos.'), h3('CongregadoService'), - p('Análogo al AsociadoService pero para congregados. Gestiona documentos de identidad (foto de cédula) y campos adicionales como segundo ministerio, segundo teléfono, fecha de nacimiento y profesión.'), + p('Análogo al AsociadoService para congregados. Gestiona documentos de identidad (foto de cédula), campos extendidos como segundo ministerio, profesión, fecha de nacimiento y registro de auditoría en cada operación.'), h3('PermisoService'), - p('Verifica que no haya traslape de fechas con permisos existentes (PENDIENTE o APROBADO) del mismo usuario antes de crear una nueva solicitud. Permite aprobar o rechazar permisos con observaciones.'), + p('Verifica que no haya traslape de fechas con permisos existentes (PENDIENTE o APROBADO) del mismo usuario antes de crear una nueva solicitud. Permite aprobar o rechazar con observaciones registradas.'), h3('AsistenciaService / ReporteAsistenciaService'), - p('El servicio de asistencia valida que el asociado exista y previene registros duplicados por evento/asociado/fecha. El reporte usa UPSERT para actualizar el estado si ya existe el registro, en lugar de duplicarlo.'), + p('El servicio de asistencia valida que el asociado exista y previene registros duplicados por evento/asociado. El reporte usa UPSERT (Prisma upsert) para actualizar el estado si ya existe el registro, en lugar de duplicarlo.'), new Paragraph({ pageBreakBefore: true }), // ── 8. ESTADO ACTUAL ───────────────────────────────────────────────── h1('8. Estado Actual del Proyecto'), - separator(), h3('Funcionalidades Implementadas'), makeTable( ['Módulo', 'Estado'], [ - ['Autenticación (login, logout, JWT, bloqueo de cuenta)', '✅ Completo'], - ['Gestión de Usuarios (CRUD completo)', '✅ Completo'], - ['Gestión de Roles y Permisos (configuración dinámica)', '✅ Completo'], - ['Gestión de Asociados (CRUD, documentos, junta directiva)', '✅ Completo'], - ['Gestión de Congregados (CRUD, documentos, filtros)', '✅ Completo'], - ['Gestión de Eventos (CRUD, activar/desactivar)', '✅ Completo'], - ['Registro de Asistencia (individual y masivo)', '✅ Completo'], - ['Reportes de Asistencia (con exportación Excel/PDF)', '✅ Completo'], - ['Actas de Asamblea y Junta Directiva', '✅ Completo'], - ['Solicitud y Aprobación de Permisos (con validación de traslapes)', '✅ Completo'], - ['Planilla de Empleados (salarios, vacaciones, permisos)', '✅ Completo'], - ['Historial de Auditoría', '✅ Completo'], - ['Migración a Prisma ORM 7', '✅ Completo'], - ['Subida de Documentos (Vercel Blob)', '✅ Completo'], - ['Middleware de autenticación Edge', '✅ Completo'], - ['Widget de Accesibilidad', '✅ Completo'], - ] + ['Autenticación (login, logout, JWT, bloqueo de cuenta)', '✅ Completo'], + ['Gestión de Usuarios (CRUD completo)', '✅ Completo'], + ['Gestión de Roles y Permisos (configuración dinámica en BD)', '✅ Completo'], + ['Gestión de Asociados (CRUD, documentos, junta directiva)', '✅ Completo'], + ['Gestión de Congregados (CRUD, documentos, filtros avanzados)', '✅ Completo'], + ['Gestión de Eventos (CRUD, activar/desactivar)', '✅ Completo'], + ['Registro de Asistencia (individual y masiva)', '✅ Completo'], + ['Reportes de Asistencia (con exportación Excel/PDF)', '✅ Completo'], + ['Actas de Asamblea y Junta Directiva', '✅ Completo'], + ['Solicitud y Aprobación de Permisos (con validación traslapes)', '✅ Completo'], + ['Planilla de Empleados (salarios, vacaciones, permisos)', '✅ Completo'], + ['Historial de Auditoría', '✅ Completo'], + ['Migración a Prisma ORM 7 con adaptador Neon', '✅ Completo'], + ['Subida y descarga de documentos (Vercel Blob)', '✅ Completo'], + ['Middleware de autenticación Edge (Next.js)', '✅ Completo'], + ['Widget de Accesibilidad', '✅ Completo'], + ], + [78, 22] ), - new Paragraph({ spacing: { before: 200, after: 100 } }), + gap(200), h3('Pendiente / En Progreso'), - bullet('Aplicar migración de BD para los nuevos campos de asociados (npx prisma db push)'), - bullet('Formulario de Registro de Asociados con campos extendidos (en revisión)'), - bullet('Módulo de recuperación de contraseña (/recuperar-password)'), - bullet('Página de Historial (/historial) con filtros avanzados'), + makeTable( + ['Tarea', 'Detalle'], + [ + ['Migración de BD para campos nuevos de asociados', 'Ejecutar npx prisma db push para aplicar los campos: telefonoContacto, fechaNacimiento, estadoCivil, profesion, anosCongregarse, fechaAceptacion, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud, urlCartaRenuncia, urlCartaDesafiliacion'], + ['Formulario de Registro de Asociados extendido', 'Página /registro-asociados actualizada con los 9 campos nuevos y carga de mínimo 3 documentos'], + ['Módulo de recuperación de contraseña', 'Página /recuperar-password con envío de email (pendiente integración de servicio de correo)'], + ['Filtros avanzados en Historial', 'Página /historial con filtros por tabla, acción, usuario y rango de fechas'], + ], + [36, 64] + ), new Paragraph({ pageBreakBefore: true }), // ── 9. ESTRUCTURA DE CARPETAS ──────────────────────────────────────── - h1('9. Estructura de Carpetas'), - separator(), + h1('9. Estructura de Carpetas del Proyecto'), makeTable( - ['Carpeta / Archivo', 'Descripción'], + ['Ruta', 'Descripción'], [ - ['src/app/', 'Páginas y API Routes (Next.js App Router)'], - ['src/app/api/', 'Endpoints REST organizados por dominio'], - ['src/components/', 'Componentes React reutilizables (Sidebar, Navbar, etc.)'], - ['src/contexts/', 'Contextos React (AuthContext)'], - ['src/dao/', 'Data Access Objects — consultas Prisma por entidad'], - ['src/dto/', 'Data Transfer Objects para request y response'], - ['src/hooks/', 'Hooks personalizados (useAuth)'], - ['src/lib/', 'Utilidades globales: auth.ts (JWT), prisma.ts (cliente), db.ts'], - ['src/middleware.ts', 'Middleware Edge de Next.js (autenticación global)'], - ['src/models/', 'Interfaces y clases TypeScript del dominio'], - ['src/services/', 'Lógica de negocio por dominio'], - ['src/utils/', 'Funciones auxiliares (exportación CSV, permisos de roles)'], - ['src/validators/', 'Validadores de datos de entrada'], - ['prisma/schema.prisma', 'Esquema de BD: modelos, relaciones y enums'], - ['prisma.config.ts', 'Configuración de Prisma 7 (URL de conexión)'], - ['database/schema.sql', 'SQL original de referencia del esquema'], - ['.env.local', 'Variables de entorno (POSTGRES_URL, JWT_SECRET)'], - ] + ['src/app/', 'Páginas y API Routes (Next.js App Router)'], + ['src/app/api/', 'Endpoints REST organizados por dominio de negocio'], + ['src/components/', 'Componentes React reutilizables (Sidebar, Navbar, etc.)'], + ['src/contexts/', 'Contextos React globales (AuthContext)'], + ['src/dao/', 'Data Access Objects — consultas Prisma por entidad del dominio'], + ['src/dto/', 'Data Transfer Objects para request y response de cada entidad'], + ['src/hooks/', 'Hooks personalizados (useAuth)'], + ['src/lib/', 'Utilidades globales: auth.ts (JWT), prisma.ts (cliente singleton), db.ts'], + ['src/middleware.ts', 'Middleware Edge de Next.js — autenticación global por ruta'], + ['src/models/', 'Interfaces y clases TypeScript que representan el dominio'], + ['src/services/', 'Lógica de negocio aislada por dominio'], + ['src/utils/', 'Funciones auxiliares: exportación CSV, permisos por rol'], + ['src/validators/', 'Validadores de datos de entrada por entidad'], + ['prisma/schema.prisma', 'Esquema de BD: modelos Prisma, relaciones y enums'], + ['prisma.config.ts', 'Configuración de Prisma 7 (URL de conexión, adaptador)'], + ['prisma/seed.ts', 'Datos iniciales para poblar la base de datos'], + ['database/schema.sql', 'SQL original de referencia del esquema de base de datos'], + ['.env.local', 'Variables de entorno: POSTGRES_URL, JWT_SECRET, BLOB_READ_WRITE_TOKEN'], + ], + [36, 64] ), - new Paragraph({ spacing: { before: 400 } }), - separator(), + gap(400), new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 200 }, - children: [new TextRun({ text: 'Sistema SCRCR — Iglesia Bíblica Emanuel, Liberia', size: 18, italics: true, color: '999999' })], + border: { top: { style: BorderStyle.SINGLE, size: 4, color: AZUL_HEADER } }, + spacing: { before: 160, after: 80 }, + children: [new TextRun({ text: 'Sistema SCRCR — Iglesia Bíblica Emanuel, Liberia', size: 18, italics: true, color: '888888', font: 'Calibri' })], }), new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ text: `Documento generado el ${new Date().toLocaleDateString('es-CR')}`, size: 16, color: 'BBBBBB' })], + children: [new TextRun({ + text: `Documento generado el ${new Date().toLocaleDateString('es-CR')}`, + size: 16, color: 'BBBBBB', font: 'Calibri', + })], }), ], }, @@ -524,4 +607,6 @@ const doc = new Document({ Packer.toBuffer(doc).then(buffer => { fs.writeFileSync('/home/user/SCRCR/documentacion-scrcr.docx', buffer); console.log('✅ Documento generado: documentacion-scrcr.docx'); +}).catch(err => { + console.error('❌ Error:', err.message); }); From 70f20c8b8ac566b235868534b2fbaf22bf858865 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 19:40:07 +0000 Subject: [PATCH 3/3] Fix table column widths using consistent percentage units throughout Co-Authored-By: Claude --- documentacion-scrcr.docx | Bin 22437 -> 21160 bytes generar-doc.cjs | 504 ++++++++++++++++----------------------- 2 files changed, 207 insertions(+), 297 deletions(-) diff --git a/documentacion-scrcr.docx b/documentacion-scrcr.docx index 0c6fc1cb11c5953ec81abe14265f79240f312450..585de10f3d96ceccfa9379c7c7715b4b78776685 100644 GIT binary patch delta 15237 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 delta 16575 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 KVDQxcQU4D}dPRc( diff --git a/generar-doc.cjs b/generar-doc.cjs index 781bc65..ff8aabd 100644 --- a/generar-doc.cjs +++ b/generar-doc.cjs @@ -6,12 +6,11 @@ const fs = require('fs'); const AZUL = '1F4E79'; const AZUL_HEADER = '2E75B6'; -const AZUL_CLARO = 'DEEAF1'; const GRIS_FILA = 'F2F2F2'; const NEGRO = '1A1A1A'; const BLANCO = 'FFFFFF'; -const BORDE = { style: BorderStyle.SINGLE, size: 4, color: 'BFBFBF' }; +const BORDE = { style: BorderStyle.SINGLE, size: 4, color: 'AAAAAA' }; // ── helpers de texto ───────────────────────────────────────────────────────── @@ -19,24 +18,16 @@ function h1(text) { return new Paragraph({ heading: HeadingLevel.HEADING_1, spacing: { before: 480, after: 240 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: AZUL } }, children: [new TextRun({ text, bold: true, color: AZUL, size: 34, font: 'Calibri' })], - border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: AZUL } }, - }); -} - -function h2(text) { - return new Paragraph({ - heading: HeadingLevel.HEADING_2, - spacing: { before: 320, after: 160 }, - children: [new TextRun({ text, bold: true, color: AZUL_HEADER, size: 28, font: 'Calibri' })], }); } function h3(text) { return new Paragraph({ heading: HeadingLevel.HEADING_3, - spacing: { before: 240, after: 120 }, - children: [new TextRun({ text, bold: true, color: AZUL, size: 24, font: 'Calibri' })], + spacing: { before: 280, after: 140 }, + children: [new TextRun({ text, bold: true, color: AZUL_HEADER, size: 24, font: 'Calibri' })], }); } @@ -47,88 +38,43 @@ function p(text) { }); } -function bullet(text, level = 0) { +function bullet(text) { return new Paragraph({ - bullet: { level }, + bullet: { level: 0 }, spacing: { before: 60, after: 60 }, children: [new TextRun({ text, size: 20, color: NEGRO, font: 'Calibri' })], }); } -function gap(size = 100) { - return new Paragraph({ spacing: { before: size, after: 0 }, children: [] }); +function gap(before = 120) { + return new Paragraph({ spacing: { before, after: 0 }, children: [] }); } -// ── constructor de tablas ───────────────────────────────────────────────────── -// colWidths: array of numbers that sum to 100 (percentages per column) -// If omitted, columns are distributed evenly. - -function makeCell(text, isHeader, shade) { - return new TableCell({ - shading: { - type: ShadingType.CLEAR, - color: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), - fill: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), - }, - borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE }, - margins: { top: 80, bottom: 80, left: 140, right: 140 }, - children: [ - new Paragraph({ - alignment: AlignmentType.LEFT, - children: [ - new TextRun({ - text, - bold: isHeader, - color: isHeader ? BLANCO : NEGRO, - size: isHeader ? 20 : 18, - font: 'Calibri', - }), - ], - }), - ], - }); -} +// ── tablas (porcentajes puros en todo) ─────────────────────────────────────── +// colPcts: array de enteros que suman 100 (porcentaje por columna). +// Si se omite, las columnas se distribuyen de forma equitativa. -function makeTable(headers, rows, colWidths) { - const total = 9360; // total DXA width for a page with normal margins +function makeTable(headers, rows, colPcts) { const n = headers.length; - const widths = colWidths - ? colWidths.map(w => Math.round((w / 100) * total)) - : headers.map(() => Math.round(total / n)); - - const headerRow = new TableRow({ - tableHeader: true, - children: headers.map((h, i) => - Object.assign(makeCell(h, true, false), { - // apply width per cell - }) && (() => { - const c = makeCell(h, true, false); - c.options = c.options || {}; - return c; - })() - ), - }); + const pcts = colPcts || headers.map(() => Math.round(100 / n)); - // rebuild using width on cell - function cellWithWidth(text, isHeader, shade, width) { + function cell(text, isHeader, odd) { return new TableCell({ - width: { size: width, type: WidthType.DXA }, + width: { size: pcts[headers.indexOf ? 0 : 0], type: WidthType.PERCENTAGE }, shading: { type: ShadingType.CLEAR, - color: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), - fill: isHeader ? AZUL_HEADER : (shade ? GRIS_FILA : BLANCO), + fill: isHeader ? AZUL_HEADER : (odd ? GRIS_FILA : BLANCO), }, borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE }, - margins: { top: 80, bottom: 80, left: 140, right: 140 }, + margins: { top: 80, bottom: 80, left: 150, right: 150 }, children: [ new Paragraph({ - alignment: AlignmentType.LEFT, children: [ new TextRun({ text, bold: isHeader, color: isHeader ? BLANCO : NEGRO, - size: isHeader ? 20 : 18, + size: isHeader ? 19 : 18, font: 'Calibri', }), ], @@ -137,19 +83,42 @@ function makeTable(headers, rows, colWidths) { }); } + function row(cells, isHeader, odd) { + return new TableRow({ + tableHeader: isHeader, + children: cells.map((text, ci) => { + const pct = pcts[ci] || Math.round(100 / n); + return new TableCell({ + width: { size: pct, type: WidthType.PERCENTAGE }, + shading: { + type: ShadingType.CLEAR, + fill: isHeader ? AZUL_HEADER : (odd ? GRIS_FILA : BLANCO), + }, + borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE }, + margins: { top: 80, bottom: 80, left: 150, right: 150 }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text, + bold: isHeader, + color: isHeader ? BLANCO : NEGRO, + size: isHeader ? 19 : 18, + font: 'Calibri', + }), + ], + }), + ], + }); + }), + }); + } + return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, - borders: { top: BORDE, bottom: BORDE, left: BORDE, right: BORDE, insideH: BORDE, insideV: BORDE }, rows: [ - new TableRow({ - tableHeader: true, - children: headers.map((h, i) => cellWithWidth(h, true, false, widths[i])), - }), - ...rows.map((row, ri) => - new TableRow({ - children: row.map((cell, i) => cellWithWidth(cell, false, ri % 2 === 1, widths[i])), - }) - ), + row(headers, true, false), + ...rows.map((r, ri) => row(r, false, ri % 2 === 1)), ], }); } @@ -159,56 +128,40 @@ function makeTable(headers, rows, colWidths) { const doc = new Document({ creator: 'Sistema SCRCR', title: 'Documentación Técnica del Sistema SCRCR', - description: 'Estado actual, arquitectura y módulos del sistema', - styles: { - default: { - document: { - run: { font: 'Calibri', size: 20, color: NEGRO }, - }, - }, - }, sections: [ { properties: { - page: { - margin: { top: 1440, bottom: 1440, left: 1440, right: 1080 }, - }, + page: { margin: { top: 1440, bottom: 1440, left: 1440, right: 1080 } }, }, children: [ // ── PORTADA ───────────────────────────────────────────────────────── - gap(1800), + gap(1600), new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ text: 'SISTEMA SCRCR', bold: true, size: 60, color: AZUL, font: 'Calibri' })], }), - gap(200), - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: 'Sistema de Control y Registro de', size: 30, color: '444444', font: 'Calibri' })], - }), + gap(160), new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ text: 'Congregados y Recursos', size: 30, color: '444444', font: 'Calibri' })], + children: [new TextRun({ text: 'Sistema de Control y Registro de Congregados y Recursos', size: 28, color: '555555', font: 'Calibri' })], }), - gap(400), + gap(320), new Paragraph({ alignment: AlignmentType.CENTER, + spacing: { before: 120, after: 120 }, border: { top: { style: BorderStyle.SINGLE, size: 6, color: AZUL_HEADER }, bottom: { style: BorderStyle.SINGLE, size: 6, color: AZUL_HEADER }, }, - spacing: { before: 120, after: 120 }, - children: [ - new TextRun({ text: 'Iglesia Bíblica Emanuel — Liberia', size: 26, italics: true, color: '666666', font: 'Calibri' }), - ], + children: [new TextRun({ text: 'Iglesia Bíblica Emanuel — Liberia', size: 26, italics: true, color: '666666', font: 'Calibri' })], }), - gap(400), + gap(320), new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ text: 'Documentación Técnica del Sistema', size: 24, color: '888888', font: 'Calibri' })], + children: [new TextRun({ text: 'Documentación Técnica del Sistema', size: 22, color: '888888', font: 'Calibri' })], }), - gap(100), + gap(80), new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ @@ -220,55 +173,47 @@ const doc = new Document({ // ── 1. DESCRIPCIÓN GENERAL ─────────────────────────────────────────── h1('1. Descripción General del Proyecto'), - p('SCRCR es una aplicación web desarrollada para la Iglesia Bíblica Emanuel de Liberia. Su propósito es digitalizar y centralizar la gestión de los miembros de la iglesia, el control de asistencia, la administración de personal, permisos, actas y reportes.'), + p('SCRCR es una aplicación web desarrollada para la Iglesia Bíblica Emanuel de Liberia. Su propósito es digitalizar y centralizar la gestión de miembros, control de asistencia, administración de personal, permisos, actas y reportes.'), gap(80), - p('El sistema diferencia dos tipos de miembros: asociados (miembros formales con derechos plenos) y congregados (miembros regulares). Además gestiona el personal administrativo, eventos, actas de asambleas, planilla de empleados y permisos de ausencia.'), - + p('El sistema diferencia dos tipos de miembros: asociados (miembros formales con derechos plenos) y congregados (miembros regulares). Además gestiona personal administrativo, eventos, actas de asambleas, planilla de empleados y permisos de ausencia.'), gap(200), h3('Tecnologías Principales'), makeTable( ['Tecnología', 'Versión', 'Uso en el Sistema'], [ - ['Next.js', '15.x', 'Framework fullstack con App Router'], - ['TypeScript', '5.x', 'Tipado estático en todo el proyecto'], - ['PostgreSQL (Neon)', 'Serverless', 'Base de datos principal'], - ['Prisma ORM', '7.8', 'Acceso a BD con type-safety completo'], - ['Tailwind CSS', '4.x', 'Estilos y diseño responsivo'], - ['jose', '6.x', 'Firma y verificación de tokens JWT'], - ['bcryptjs', '3.x', 'Hash y verificación de contraseñas'], - ['Zod', '4.x', 'Validación de esquemas de datos'], - ['SweetAlert2', '11.x', 'Alertas y confirmaciones UI'], - ['jsPDF + ExcelJS', '—', 'Exportación de reportes PDF / Excel'], - ['Vercel Blob', '—', 'Almacenamiento de documentos'], + ['Next.js', '15.x', 'Framework fullstack con App Router'], + ['TypeScript', '5.x', 'Tipado estático en todo el proyecto'], + ['PostgreSQL (Neon)', 'Serverless', 'Base de datos principal'], + ['Prisma ORM', '7.8', 'Acceso a BD con type-safety completo'], + ['Tailwind CSS', '4.x', 'Estilos y diseño responsivo'], + ['jose + bcryptjs', '6.x / 3.x', 'JWT y hash de contraseñas'], + ['Zod', '4.x', 'Validación de esquemas de datos'], + ['jsPDF + ExcelJS', '—', 'Exportación de reportes PDF / Excel'], + ['Vercel Blob', '—', 'Almacenamiento de documentos'], ], - [28, 16, 56] + [28, 15, 57] ), - new Paragraph({ pageBreakBefore: true }), // ── 2. ARQUITECTURA ────────────────────────────────────────────────── h1('2. Arquitectura del Sistema'), - p('El sistema implementa una arquitectura en capas derivada del patrón MVC, adaptada al contexto de Next.js App Router. Se aplica el patrón DAO para aislar la lógica de acceso a datos, y DTOs para desacoplar las capas.'), - + p('El sistema implementa una arquitectura en capas derivada del patrón MVC, adaptada al contexto de Next.js App Router. Se aplica el patrón DAO para aislar la lógica de acceso a datos y DTOs para desacoplar las capas.'), gap(200), h3('Capas del Sistema'), makeTable( ['Capa', 'Ubicación', 'Responsabilidad'], [ - ['Presentación (View)', 'src/app/*/page.tsx', 'Componentes React cliente: UI, formularios, tablas'], - ['Controlador (Controller)', 'src/app/api/*/route.ts', 'API Routes de Next.js — reciben HTTP y retornan JSON'], - ['Servicio (Service)', 'src/services/*.service.ts', 'Lógica de negocio: validación, transformación, orquestación'], - ['DAO', 'src/dao/*.dao.ts', 'Acceso a BD mediante Prisma. Una clase por entidad'], - ['Modelo (Model)', 'src/models/*.ts', 'Interfaces TypeScript que representan entidades del dominio'], - ['DTO', 'src/dto/*.dto.ts', 'Objetos de transferencia para entrada (Request) y salida (Response)'], - ['Validadores', 'src/validators/*.validator.ts', 'Reglas de validación de datos de entrada'], - ['ORM', 'prisma/schema.prisma', 'Esquema de BD y modelos Prisma'], - ['Middleware', 'src/middleware.ts', 'Autenticación y autorización en el Edge de Next.js'], - ['Utilidades', 'src/lib/ y src/utils/', 'Auth JWT, cliente Prisma, exportación CSV, permisos de roles'], + ['Presentación (View)', 'src/app/*/page.tsx', 'Componentes React cliente: UI, formularios, tablas'], + ['Controlador (Controller)', 'src/app/api/*/route.ts', 'API Routes de Next.js — reciben HTTP y retornan JSON'], + ['Servicio (Service)', 'src/services/*.service.ts', 'Lógica de negocio: validación, transformación, orquestación'], + ['DAO', 'src/dao/*.dao.ts', 'Acceso a BD mediante Prisma. Una clase por entidad'], + ['Modelo (Model)', 'src/models/*.ts', 'Interfaces TypeScript del dominio'], + ['DTO', 'src/dto/*.dto.ts', 'Objetos de transferencia Request/Response'], + ['Validadores', 'src/validators/*.validator.ts', 'Reglas de validación de datos de entrada'], + ['Middleware', 'src/middleware.ts', 'Autenticación y autorización en el Edge de Next.js'], ], - [24, 30, 46] + [25, 30, 45] ), - gap(240), h3('Flujo de una Petición HTTP'), bullet('1. El usuario interactúa con la UI (page.tsx) — componente React del lado del cliente'), @@ -277,71 +222,62 @@ const doc = new Document({ bullet('4. La API Route parsea el body y delega al Service correspondiente'), bullet('5. El Service valida, aplica la lógica de negocio y llama al DAO'), bullet('6. El DAO ejecuta la consulta en PostgreSQL a través de Prisma'), - bullet('7. El resultado sube por las capas como DTO/Response y se retorna como JSON'), - + bullet('7. El resultado sube como DTO/Response y se retorna como JSON'), gap(240), h3('Patrones de Diseño Aplicados'), makeTable( ['Patrón', 'Dónde se aplica'], [ - ['DAO (Data Access Object)', 'Cada entidad tiene su propio DAO que encapsula todas las consultas a BD'], - ['DTO (Data Transfer Object)', 'DTOs separados para Request y Response desacoplan el modelo de la API'], - ['Singleton', 'PrismaClient se instancia una sola vez y se reutiliza globalmente'], - ['Repository (implícito)', 'Los DAOs actúan como repositorios sobre las entidades Prisma'], - ['Service Layer', 'Toda la lógica de negocio está aislada en servicios; los controllers son delgados'], - ['Middleware Chain', 'El Edge Middleware intercepta y evalúa cada petición antes de llegar al handler'], - ['Soft Delete', 'Los registros se marcan con estado=0 en lugar de eliminarse físicamente'], + ['DAO (Data Access Object)', 'Cada entidad tiene su propio DAO que encapsula todas las consultas a BD'], + ['DTO (Data Transfer Object)','DTOs separados para Request y Response desacoplan el modelo de la API'], + ['Singleton', 'PrismaClient se instancia una sola vez y se reutiliza globalmente'], + ['Service Layer', 'Toda la lógica de negocio está aislada en servicios; los controllers son delgados'], + ['Middleware Chain', 'El Edge Middleware intercepta y evalúa cada petición antes de llegar al handler'], + ['Soft Delete', 'Los registros se marcan con estado=0 en lugar de eliminarse físicamente'], ], - [38, 62] + [35, 65] ), - new Paragraph({ pageBreakBefore: true }), // ── 3. MÓDULOS FRONTEND ────────────────────────────────────────────── h1('3. Módulos del Frontend'), - p('Todas las páginas usan el App Router de Next.js. Las páginas marcadas con "use client" manejan estado local y efectos del lado del cliente. El layout global incluye el Sidebar y la Navbar.'), - + p('Todas las páginas usan el App Router de Next.js. Las páginas con "use client" manejan estado local y efectos del lado del cliente. El layout global incluye el Sidebar y la Navbar.'), gap(200), makeTable( - ['Módulo / Ruta', 'Descripción', 'Acceso por Rol'], + ['Módulo / Ruta', 'Descripción', 'Acceso'], [ - ['/ (Dashboard)', 'Pantalla principal con accesos rápidos según el rol del usuario', 'Todos los roles'], - ['/login', 'Formulario de autenticación con bloqueo tras 5 intentos fallidos', 'Solo no autenticados'], - ['/consulta-asociados', 'Gestión completa: listado, búsqueda, creación, edición, documentos, exportación Excel/PDF', 'Admin / Pastor General'], - ['/congregados', 'Gestión de congregados con filtros, paginación, subida de foto y documentos', 'Admin / Tesorero / Pastor'], - ['/eventos', 'Alta, edición y desactivación de eventos de la iglesia, vinculados a asistencia y actas', 'Protegida'], - ['/asistencia/registro', 'Registro de asistencia individual o masiva a eventos por asociado o congregado', 'Protegida'], - ['/reportes', 'Reportes de asistencia filtrados por evento, fecha y estado. Exportación Excel/PDF', 'Protegida'], - ['/actas', 'Registro de sesiones y actas de asamblea de asociados y junta directiva con lista de asistentes', 'Protegida'], - ['/permisos', 'Solicitud y gestión de permisos/ausencias con validación de traslapes de fechas', 'Protegida'], - ['/planilla', 'Gestión de empleados, salarios, vacaciones y permisos de personal administrativo', 'Protegida'], - ['/historial', 'Tabla de auditoría con todos los cambios realizados en el sistema', 'Protegida'], - ['/gestion-usuarios', 'Alta, edición y desactivación de cuentas de usuario (solo admin)', 'Solo admin'], - ['/gestion-roles', 'Configuración dinámica de permisos por rol y módulo', 'Solo admin'], - ['/unauthorized', 'Página de acceso denegado cuando el rol no tiene permiso', 'Pública'], + ['/ (Dashboard)', 'Pantalla principal con accesos rápidos según el rol del usuario', 'Todos los roles'], + ['/login', 'Formulario de autenticación con bloqueo tras 5 intentos fallidos', 'Solo no autenticados'], + ['/consulta-asociados', 'Gestión completa: listado, búsqueda, creación, edición, documentos, exportación Excel/PDF', 'Admin / Pastor General'], + ['/congregados', 'Gestión de congregados con filtros, paginación, foto y documentos', 'Admin / Tesorero / Pastor'], + ['/eventos', 'Alta, edición y desactivación de eventos, vinculados a asistencia y actas', 'Protegida'], + ['/asistencia/registro', 'Registro de asistencia individual o masiva a eventos', 'Protegida'], + ['/reportes', 'Reportes de asistencia filtrados por evento, fecha y estado. Exportación Excel/PDF', 'Protegida'], + ['/actas', 'Registro de sesiones y actas de asamblea y junta directiva con lista de asistentes', 'Protegida'], + ['/permisos', 'Solicitud y gestión de permisos/ausencias con validación de traslapes de fechas', 'Protegida'], + ['/planilla', 'Gestión de empleados, salarios, vacaciones y permisos del personal', 'Protegida'], + ['/historial', 'Tabla de auditoría con todos los cambios realizados en el sistema', 'Protegida'], + ['/gestion-usuarios', 'Alta, edición y desactivación de cuentas de usuario', 'Solo admin'], + ['/gestion-roles', 'Configuración dinámica de permisos por rol y módulo', 'Solo admin'], ], - [28, 48, 24] + [28, 52, 20] ), - gap(240), h3('Componentes Reutilizables'), makeTable( ['Componente', 'Descripción'], [ - ['SideBar.tsx', 'Menú lateral responsivo con navegación dinámica según el rol. En mobile colapsa con hamburguesa'], - ['Navbar.tsx', 'Barra superior con logo, nombre de usuario, rol activo y botón de cierre de sesión'], - ['ProtectedRoute.tsx', 'HOC que verifica autenticación y redirige si el usuario no está logueado'], - ['ToastProvider.tsx', 'Proveedor de notificaciones emergentes (react-hot-toast)'], - ['AccessibilityWidget.tsx','Widget flotante con opciones de zoom, contraste y accesibilidad'], + ['SideBar.tsx', 'Menú lateral responsivo con navegación dinámica según el rol. En mobile colapsa con hamburguesa'], + ['Navbar.tsx', 'Barra superior con logo, nombre de usuario, rol activo y botón de cierre de sesión'], + ['ProtectedRoute.tsx', 'HOC que verifica autenticación y redirige si el usuario no está logueado'], + ['AccessibilityWidget.tsx', 'Widget flotante con opciones de zoom, contraste y accesibilidad'], ], - [30, 70] + [28, 72] ), - new Paragraph({ pageBreakBefore: true }), // ── 4. API REST ────────────────────────────────────────────────────── h1('4. API REST — Endpoints del Backend'), - h3('Autenticación (/api/auth)'), makeTable( ['Método', 'Ruta', 'Descripción'], @@ -352,24 +288,22 @@ const doc = new Document({ ['GET', '/api/auth/verify', 'Verifica si el token JWT es válido y no ha expirado'], ['GET', '/api/auth/verify-role', 'Valida que el token tenga el rol requerido para el módulo'], ], - [12, 34, 54] + [12, 32, 56] ), - gap(160), h3('Asociados (/api/asociados)'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET', '/api/asociados', 'Lista todos los asociados sin paginación'], - ['POST', '/api/asociados', 'Crea un nuevo asociado con validación completa'], - ['GET', '/api/asociados/[id]', 'Obtiene un asociado por ID con todos sus campos'], - ['PUT', '/api/asociados/update?id=X', 'Actualiza campos de un asociado existente'], - ['DELETE', '/api/asociados/delete?id=X', 'Soft delete (estado=0) o hard delete permanente'], - ['GET', '/api/asociados/consulta', 'Búsqueda con filtros (nombre, cédula) y paginación'], + ['GET', '/api/asociados', 'Lista todos los asociados sin paginación'], + ['POST', '/api/asociados', 'Crea un nuevo asociado con validación completa'], + ['GET', '/api/asociados/[id]', 'Obtiene un asociado por ID con todos sus campos'], + ['PUT', '/api/asociados/update?id=X', 'Actualiza campos de un asociado existente'], + ['DELETE', '/api/asociados/delete?id=X', 'Soft delete (estado=0) o hard delete permanente'], + ['GET', '/api/asociados/consulta', 'Búsqueda con filtros (nombre, cédula) y paginación'], ], - [14, 36, 50] + [12, 36, 52] ), - gap(160), h3('Congregados (/api/congregados)'), makeTable( @@ -381,181 +315,157 @@ const doc = new Document({ ['PUT', '/api/congregados/[id]', 'Actualiza datos y documentos del congregado'], ['DELETE', '/api/congregados/[id]', 'Elimina o desactiva el congregado'], ], - [14, 36, 50] + [12, 36, 52] ), - gap(160), h3('Eventos, Asistencia y Reportes'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET/POST', '/api/eventos', 'Listar y crear eventos'], - ['GET/PUT/DELETE', '/api/eventos/[id]', 'CRUD sobre evento específico'], - ['POST', '/api/asistencia/registro', 'Registra asistencia individual a un evento'], - ['GET/POST', '/api/reporte-asistencia', 'Crear y consultar reportes de asistencia con estado'], - ['GET', '/api/reportes/asistencia', 'Reportes con filtros (evento, fecha, estado)'], - ['GET', '/api/reportes/asistencia/export', 'Exporta reporte en CSV o Excel'], + ['GET/POST', '/api/eventos', 'Listar y crear eventos'], + ['GET/PUT/DELETE', '/api/eventos/[id]', 'CRUD sobre evento específico'], + ['POST', '/api/asistencia/registro', 'Registra asistencia individual a un evento'], + ['GET/POST', '/api/reporte-asistencia', 'Crear y consultar reportes con estado'], + ['GET', '/api/reportes/asistencia', 'Reportes con filtros (evento, fecha, estado)'], + ['GET', '/api/reportes/asistencia/export', 'Exporta reporte en CSV o Excel'], ], - [20, 38, 42] + [18, 38, 44] ), - gap(160), h3('Usuarios, Permisos, Actas y Otros'), makeTable( ['Método', 'Ruta', 'Descripción'], [ - ['GET/POST', '/api/usuarios', 'Listar y crear usuarios del sistema'], - ['GET/PUT/DEL','/api/usuarios/[id]', 'CRUD sobre usuario específico'], - ['GET/POST', '/api/permisos', 'Solicitar y listar permisos de ausencia'], - ['PUT', '/api/permisos/[id]/estado', 'Aprobar o rechazar una solicitud de permiso'], - ['GET', '/api/permisos/traslape', 'Verifica si un rango de fechas traslapa con otro permiso'], - ['GET/POST', '/api/actas/asociacion', 'CRUD de actas de asamblea de asociados'], - ['POST', '/api/actas/asociacion/[id]/asistencia', 'Registra asistencia a un acta específica'], - ['GET/POST', '/api/actas/jd', 'CRUD de actas de junta directiva'], - ['GET/POST', '/api/empleados', 'Gestión de empleados con planilla y vacaciones'], - ['POST', '/api/documentos/upload', 'Subida de documentos al almacenamiento (Vercel Blob)'], - ['GET', '/api/blob-download', 'Descarga de archivos desde Vercel Blob'], - ['GET/POST', '/api/historial', 'Registro de auditoría de cambios en el sistema'], + ['GET/POST', '/api/usuarios', 'Listar y crear usuarios del sistema'], + ['GET/PUT/DEL', '/api/usuarios/[id]', 'CRUD sobre usuario específico'], + ['GET/POST', '/api/permisos', 'Solicitar y listar permisos de ausencia'], + ['PUT', '/api/permisos/[id]/estado', 'Aprobar o rechazar una solicitud de permiso'], + ['GET/POST', '/api/actas/asociacion', 'CRUD de actas de asamblea de asociados'], + ['POST', '/api/actas/asociacion/[id]/asistencia', 'Registra asistencia a un acta específica'], + ['GET/POST', '/api/actas/jd', 'CRUD de actas de junta directiva'], + ['GET/POST', '/api/empleados', 'Gestión de empleados con planilla y vacaciones'], + ['POST', '/api/documentos/upload', 'Subida de documentos (Vercel Blob)'], + ['GET/POST', '/api/historial', 'Registro de auditoría de cambios'], ], - [18, 40, 42] + [16, 40, 44] ), - new Paragraph({ pageBreakBefore: true }), // ── 5. BASE DE DATOS ───────────────────────────────────────────────── h1('5. Modelo de Base de Datos'), p('La base de datos está hospedada en Neon (PostgreSQL serverless). El acceso se realiza exclusivamente a través de Prisma ORM v7 con el adaptador @prisma/adapter-neon para conexiones serverless eficientes.'), - gap(200), h3('Tablas Principales'), makeTable( ['Tabla', 'Descripción', 'Campos Clave'], [ - ['usuarios', 'Cuentas de acceso al sistema', 'username, email, passwordHash, rol, intentosFallidos, bloqueadoHasta'], - ['asociados', 'Miembros formales de la asociación', 'cedula, estadoCivil, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud'], - ['congregados', 'Miembros regulares de la congregación', 'cedula, ministerio, estadoCivil, urlFotoCedula'], - ['empleados', 'Personal administrativo con planilla', 'cedula, puesto, salarioBase, cuentaBancaria, diasVacacionesDisponibles'], - ['eventos', 'Actividades y reuniones de la iglesia', 'nombre, fecha, hora, activo'], - ['asistencias', 'Registro de asistencia por asociado/evento', 'asociadoId, eventoId — UNIQUE(asociadoId, eventoId)'], - ['reportes_asistencia', 'Reporte consolidado con estado', 'asociadoId, eventoId, fecha, estado (presente/ausente/justificado)'], - ['permisos', 'Solicitudes de ausencia del personal', 'usuarioId, fechaInicio, fechaFin, estado (PENDIENTE/APROBADO/RECHAZADO)'], - ['actas_asociacion', 'Actas de asamblea de asociados', 'fecha, tipoSesion, urlActa'], - ['asistencias_acta_asociacion', 'Asistencia por acta y asociado', 'actaId, asociadoId, estado, justificacion'], - ['actas_junta_directiva', 'Actas de junta directiva', 'fecha, tipoSesion, urlActa'], - ['vacaciones_empleado', 'Solicitudes de vacaciones', 'empleadoId, fechaInicio, fechaFin, cantidadDias, estado'], - ['permisos_rol', 'Control de acceso por rol y módulo en BD', 'rol, modulo, activo'], - ['auditoria', 'Historial de cambios en el sistema', 'tabla, registroId, accion, detalles, fecha'], + ['usuarios', 'Cuentas de acceso al sistema', 'username, email, passwordHash, rol, intentosFallidos, bloqueadoHasta'], + ['asociados', 'Miembros formales de la asociación', 'cedula, estadoCivil, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud'], + ['congregados', 'Miembros regulares de la congregación', 'cedula, ministerio, estadoCivil, urlFotoCedula'], + ['empleados', 'Personal administrativo con planilla', 'cedula, puesto, salarioBase, cuentaBancaria, diasVacacionesDisponibles'], + ['eventos', 'Actividades y reuniones de la iglesia', 'nombre, fecha, hora, activo'], + ['asistencias', 'Registro de asistencia por asociado/evento', 'asociadoId, eventoId — UNIQUE(asociadoId, eventoId)'], + ['reportes_asistencia', 'Reporte consolidado con estado', 'asociadoId, eventoId, fecha, estado (presente/ausente/justificado)'], + ['permisos', 'Solicitudes de ausencia del personal', 'usuarioId, fechaInicio, fechaFin, estado (PENDIENTE/APROBADO/RECHAZADO)'], + ['actas_asociacion', 'Actas de asamblea de asociados', 'fecha, tipoSesion, urlActa'], + ['actas_junta_directiva', 'Actas de junta directiva', 'fecha, tipoSesion, urlActa'], + ['vacaciones_empleado', 'Solicitudes de vacaciones del personal', 'empleadoId, fechaInicio, fechaFin, cantidadDias, estado'], + ['permisos_rol', 'Control de acceso por rol y módulo', 'rol, modulo, activo'], + ['auditoria', 'Historial de cambios en el sistema', 'tabla, registroId, accion, detalles, fecha'], ], - [24, 28, 48] + [22, 28, 50] ), - gap(240), h3('Estrategia de Eliminación'), bullet('Soft Delete — Los asociados y congregados se marcan con estado = 0 (inactivo) y se excluyen de las búsquedas por defecto. Permite recuperación de datos.'), - bullet('Hard Delete — Opción disponible para eliminar registros permanentemente de la base de datos cuando sea necesario.'), + bullet('Hard Delete — Opción disponible para eliminar registros permanentemente de la base de datos.'), bullet('Cascade — Las asistencias y reportes se eliminan en cascada cuando se elimina el asociado o evento padre.'), - new Paragraph({ pageBreakBefore: true }), // ── 6. AUTENTICACIÓN ───────────────────────────────────────────────── h1('6. Autenticación y Seguridad'), - h3('Flujo de Autenticación'), bullet('1. El usuario ingresa username y password en /login'), bullet('2. El backend verifica la contraseña con bcryptjs (hash bcrypt, salt 10)'), - bullet('3. Si es correcta: se genera un JWT firmado con HS256 y JWT_SECRET (validez 24 h)'), + bullet('3. Si es correcta: se genera un JWT firmado con HS256 (validez 24 h)'), bullet('4. El token se guarda en una cookie HttpOnly, Secure, SameSite: Lax'), bullet('5. En cada petición el Middleware Edge verifica el token antes de llegar al controlador'), bullet('6. Si el token es inválido o expiró: se limpia la cookie y se redirige al login'), - gap(160), - h3('Mecanismo de Bloqueo de Cuenta'), - bullet('Después de 5 intentos fallidos consecutivos, la cuenta queda bloqueada durante 30 minutos'), - bullet('El campo bloqueadoHasta en la tabla usuarios almacena el timestamp de desbloqueo'), + h3('Bloqueo de Cuenta'), + bullet('Después de 5 intentos fallidos consecutivos, la cuenta queda bloqueada 30 minutos'), + bullet('El campo bloqueadoHasta en tabla usuarios almacena el timestamp de desbloqueo'), bullet('Al login exitoso se resetean los intentos fallidos a 0 automáticamente'), - gap(200), h3('Roles del Sistema'), makeTable( ['Rol (código)', 'Etiqueta visible', 'Módulos con acceso'], [ ['admin', 'Administrador', 'Acceso total a todos los módulos del sistema'], - ['pastorGeneral', 'Pastor General', 'Asociados, congregados, eventos, reportes, actas, permisos, configuración'], + ['pastorGeneral', 'Pastor General', 'Asociados, congregados, eventos, reportes, actas, permisos'], ['juntaDirectiva', 'Junta Directiva', 'Consulta de asociados, reportes y actas'], - ['asistenteAdministrativo','Asistente Administrativo','Congregados, usuarios, eventos, permisos, asistencia, reportes'], + ['asistenteAdministrativo','Asistente Administrativo','Congregados, eventos, permisos, asistencia, reportes'], ['tesorero', 'Tesorero', 'Congregados, reportes financieros y configuración'], ], - [26, 26, 48] + [25, 25, 50] ), - gap(200), h3('Control de Acceso en 3 Niveles'), - bullet('Nivel 1 — Edge Middleware (src/middleware.ts): Verifica el JWT y redirige según el rol antes de servir la página. Runs en el Edge de Vercel, sin servidor Node.'), - bullet('Nivel 2 — Frontend (useAuth hook + Sidebar): Filtra el menú mostrando solo los módulos permitidos para el rol activo del usuario.'), - bullet('Nivel 3 — API Routes: Cada endpoint valida el token y aplica lógica específica de permisos antes de ejecutar la operación.'), - + bullet('Nivel 1 — Edge Middleware (src/middleware.ts): Verifica JWT y redirige según rol antes de servir la página'), + bullet('Nivel 2 — Frontend (useAuth hook + Sidebar): Filtra el menú mostrando solo módulos permitidos para el rol activo'), + bullet('Nivel 3 — API Routes: Cada endpoint valida el token y aplica lógica específica de permisos'), new Paragraph({ pageBreakBefore: true }), // ── 7. SERVICIOS ───────────────────────────────────────────────────── h1('7. Servicios y Lógica de Negocio'), - h3('UsuarioService'), - p('Gestiona la autenticación: valida credenciales, genera tokens, maneja el contador de intentos fallidos y los bloqueos temporales. Usa bcryptjs para verificar contraseñas hasheadas.'), - + p('Gestiona la autenticación: valida credenciales, genera tokens, maneja el contador de intentos fallidos y bloqueos temporales. Usa bcryptjs para verificar contraseñas hasheadas.'), h3('AsociadoService'), - p('Controla el ciclo de vida completo de los asociados: creación con validación, actualización parcial de campos, soft delete y hard delete. Valida y sanitiza datos antes de persistir. Mapea el modelo interno al DTO de respuesta incluyendo documentos, junta directiva y campos extendidos.'), - + p('Controla el ciclo de vida completo de los asociados: creación con validación, actualización parcial, soft delete y hard delete. Mapea el modelo interno al DTO de respuesta incluyendo documentos, junta directiva y campos extendidos.'), h3('CongregadoService'), - p('Análogo al AsociadoService para congregados. Gestiona documentos de identidad (foto de cédula), campos extendidos como segundo ministerio, profesión, fecha de nacimiento y registro de auditoría en cada operación.'), - + p('Análogo al AsociadoService para congregados. Gestiona documentos de identidad, campos extendidos y registra auditoría en cada operación.'), h3('PermisoService'), - p('Verifica que no haya traslape de fechas con permisos existentes (PENDIENTE o APROBADO) del mismo usuario antes de crear una nueva solicitud. Permite aprobar o rechazar con observaciones registradas.'), - + p('Verifica que no haya traslape de fechas con permisos existentes (PENDIENTE o APROBADO) del mismo usuario antes de crear una solicitud. Permite aprobar o rechazar con observaciones.'), h3('AsistenciaService / ReporteAsistenciaService'), - p('El servicio de asistencia valida que el asociado exista y previene registros duplicados por evento/asociado. El reporte usa UPSERT (Prisma upsert) para actualizar el estado si ya existe el registro, en lugar de duplicarlo.'), - + p('El servicio de asistencia previene registros duplicados por evento/asociado. El reporte usa UPSERT de Prisma para actualizar el estado si ya existe el registro, en lugar de duplicarlo.'), new Paragraph({ pageBreakBefore: true }), // ── 8. ESTADO ACTUAL ───────────────────────────────────────────────── h1('8. Estado Actual del Proyecto'), - h3('Funcionalidades Implementadas'), makeTable( ['Módulo', 'Estado'], [ - ['Autenticación (login, logout, JWT, bloqueo de cuenta)', '✅ Completo'], - ['Gestión de Usuarios (CRUD completo)', '✅ Completo'], - ['Gestión de Roles y Permisos (configuración dinámica en BD)', '✅ Completo'], - ['Gestión de Asociados (CRUD, documentos, junta directiva)', '✅ Completo'], - ['Gestión de Congregados (CRUD, documentos, filtros avanzados)', '✅ Completo'], - ['Gestión de Eventos (CRUD, activar/desactivar)', '✅ Completo'], - ['Registro de Asistencia (individual y masiva)', '✅ Completo'], - ['Reportes de Asistencia (con exportación Excel/PDF)', '✅ Completo'], - ['Actas de Asamblea y Junta Directiva', '✅ Completo'], - ['Solicitud y Aprobación de Permisos (con validación traslapes)', '✅ Completo'], - ['Planilla de Empleados (salarios, vacaciones, permisos)', '✅ Completo'], - ['Historial de Auditoría', '✅ Completo'], - ['Migración a Prisma ORM 7 con adaptador Neon', '✅ Completo'], - ['Subida y descarga de documentos (Vercel Blob)', '✅ Completo'], - ['Middleware de autenticación Edge (Next.js)', '✅ Completo'], - ['Widget de Accesibilidad', '✅ Completo'], + ['Autenticación (login, logout, JWT, bloqueo de cuenta)', '✅ Completo'], + ['Gestión de Usuarios (CRUD completo)', '✅ Completo'], + ['Gestión de Roles y Permisos (configuración dinámica en BD)', '✅ Completo'], + ['Gestión de Asociados (CRUD, documentos, junta directiva)', '✅ Completo'], + ['Gestión de Congregados (CRUD, documentos, filtros avanzados)', '✅ Completo'], + ['Gestión de Eventos (CRUD, activar/desactivar)', '✅ Completo'], + ['Registro de Asistencia (individual y masiva)', '✅ Completo'], + ['Reportes de Asistencia (con exportación Excel/PDF)', '✅ Completo'], + ['Actas de Asamblea y Junta Directiva', '✅ Completo'], + ['Solicitud y Aprobación de Permisos (con validación traslapes)', '✅ Completo'], + ['Planilla de Empleados (salarios, vacaciones, permisos)', '✅ Completo'], + ['Historial de Auditoría', '✅ Completo'], + ['Migración a Prisma ORM 7 con adaptador Neon', '✅ Completo'], + ['Subida y descarga de documentos (Vercel Blob)', '✅ Completo'], + ['Middleware de autenticación Edge (Next.js)', '✅ Completo'], ], - [78, 22] + [80, 20] ), - gap(200), h3('Pendiente / En Progreso'), makeTable( ['Tarea', 'Detalle'], [ - ['Migración de BD para campos nuevos de asociados', 'Ejecutar npx prisma db push para aplicar los campos: telefonoContacto, fechaNacimiento, estadoCivil, profesion, anosCongregarse, fechaAceptacion, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula, urlCartaSolicitud, urlCartaRenuncia, urlCartaDesafiliacion'], + ['Migración de BD para campos nuevos de asociados', 'Ejecutar npx prisma db push para los campos: telefonoContacto, fechaNacimiento, estadoCivil, profesion, anosCongregarse, fechaAceptacion, perteneceJuntaDirectiva, puestoJuntaDirectiva, urlCedula y URLs de documentos'], ['Formulario de Registro de Asociados extendido', 'Página /registro-asociados actualizada con los 9 campos nuevos y carga de mínimo 3 documentos'], ['Módulo de recuperación de contraseña', 'Página /recuperar-password con envío de email (pendiente integración de servicio de correo)'], ['Filtros avanzados en Historial', 'Página /historial con filtros por tabla, acción, usuario y rango de fechas'], ], - [36, 64] + [35, 65] ), - new Paragraph({ pageBreakBefore: true }), // ── 9. ESTRUCTURA DE CARPETAS ──────────────────────────────────────── @@ -563,33 +473,32 @@ const doc = new Document({ makeTable( ['Ruta', 'Descripción'], [ - ['src/app/', 'Páginas y API Routes (Next.js App Router)'], - ['src/app/api/', 'Endpoints REST organizados por dominio de negocio'], - ['src/components/', 'Componentes React reutilizables (Sidebar, Navbar, etc.)'], - ['src/contexts/', 'Contextos React globales (AuthContext)'], - ['src/dao/', 'Data Access Objects — consultas Prisma por entidad del dominio'], - ['src/dto/', 'Data Transfer Objects para request y response de cada entidad'], - ['src/hooks/', 'Hooks personalizados (useAuth)'], - ['src/lib/', 'Utilidades globales: auth.ts (JWT), prisma.ts (cliente singleton), db.ts'], - ['src/middleware.ts', 'Middleware Edge de Next.js — autenticación global por ruta'], - ['src/models/', 'Interfaces y clases TypeScript que representan el dominio'], - ['src/services/', 'Lógica de negocio aislada por dominio'], - ['src/utils/', 'Funciones auxiliares: exportación CSV, permisos por rol'], - ['src/validators/', 'Validadores de datos de entrada por entidad'], - ['prisma/schema.prisma', 'Esquema de BD: modelos Prisma, relaciones y enums'], - ['prisma.config.ts', 'Configuración de Prisma 7 (URL de conexión, adaptador)'], - ['prisma/seed.ts', 'Datos iniciales para poblar la base de datos'], - ['database/schema.sql', 'SQL original de referencia del esquema de base de datos'], - ['.env.local', 'Variables de entorno: POSTGRES_URL, JWT_SECRET, BLOB_READ_WRITE_TOKEN'], + ['src/app/', 'Páginas y API Routes (Next.js App Router)'], + ['src/app/api/', 'Endpoints REST organizados por dominio de negocio'], + ['src/components/', 'Componentes React reutilizables (Sidebar, Navbar, etc.)'], + ['src/contexts/', 'Contextos React globales (AuthContext)'], + ['src/dao/', 'Data Access Objects — consultas Prisma por entidad'], + ['src/dto/', 'Data Transfer Objects para request y response'], + ['src/hooks/', 'Hooks personalizados (useAuth)'], + ['src/lib/', 'Utilidades globales: auth.ts (JWT), prisma.ts (singleton), db.ts'], + ['src/middleware.ts', 'Middleware Edge de Next.js — autenticación global por ruta'], + ['src/models/', 'Interfaces TypeScript del dominio'], + ['src/services/', 'Lógica de negocio por dominio'], + ['src/utils/', 'Funciones auxiliares: exportación CSV, permisos por rol'], + ['src/validators/', 'Validadores de datos de entrada por entidad'], + ['prisma/schema.prisma', 'Esquema de BD: modelos Prisma, relaciones y enums'], + ['prisma.config.ts', 'Configuración de Prisma 7 (URL de conexión, adaptador)'], + ['prisma/seed.ts', 'Datos iniciales para poblar la base de datos'], + ['.env.local', 'Variables de entorno: POSTGRES_URL, JWT_SECRET, BLOB_READ_WRITE_TOKEN'], ], - [36, 64] + [35, 65] ), gap(400), new Paragraph({ alignment: AlignmentType.CENTER, - border: { top: { style: BorderStyle.SINGLE, size: 4, color: AZUL_HEADER } }, spacing: { before: 160, after: 80 }, + border: { top: { style: BorderStyle.SINGLE, size: 4, color: AZUL_HEADER } }, children: [new TextRun({ text: 'Sistema SCRCR — Iglesia Bíblica Emanuel, Liberia', size: 18, italics: true, color: '888888', font: 'Calibri' })], }), new Paragraph({ @@ -609,4 +518,5 @@ Packer.toBuffer(doc).then(buffer => { console.log('✅ Documento generado: documentacion-scrcr.docx'); }).catch(err => { console.error('❌ Error:', err.message); + process.exit(1); });