diff --git a/src/modules/admin/controllers/change-log.controller.ts b/src/modules/admin/controllers/change-log.controller.ts new file mode 100644 index 0000000..2f2aa50 --- /dev/null +++ b/src/modules/admin/controllers/change-log.controller.ts @@ -0,0 +1,181 @@ +/** + * ChangeLogController - Controlador de Historial de Cambios + * + * Endpoints para consultar el registro de cambios de auditoria. + * + * @module Admin + * @gap GAP-007 + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ChangeLogService } from '../services'; + +export function createChangeLogController(dataSource: DataSource): Router { + const router = Router(); + const service = new ChangeLogService(dataSource); + + // ==================== QUERIES ==================== + + /** + * GET / + * Lista cambios con filtros y paginacion + */ + router.get('/', async (req: Request, res: Response, _next: NextFunction): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + const filters = { + tableSchema: req.query.tableSchema as string, + tableName: req.query.tableName as string, + recordId: req.query.recordId as string, + userId: req.query.userId as string, + operation: req.query.operation as any, + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 50, + }; + + const result = await service.findAll(tenantId, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /statistics + * Estadisticas de cambios + */ + router.get('/statistics', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const fromDate = req.query.fromDate ? new Date(req.query.fromDate as string) : undefined; + const toDate = req.query.toDate ? new Date(req.query.toDate as string) : undefined; + + const stats = await service.getStatistics(tenantId, fromDate, toDate); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /recent + * Cambios recientes + */ + router.get('/recent', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const hours = parseInt(req.query.hours as string) || 24; + const limit = parseInt(req.query.limit as string) || 100; + + const changes = await service.getRecentChanges(tenantId, hours, limit); + res.json(changes); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-user/:userId + * Cambios por usuario + */ + router.get('/by-user/:userId', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + + const pagination = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 50, + }; + + const result = await service.getChangesByUser(tenantId, userId, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-table/:schema/:table + * Cambios por tabla + */ + router.get('/by-table/:schema/:table', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { schema, table } = req.params; + + const pagination = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 50, + }; + + const result = await service.getChangesByTable(tenantId, schema, table, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /history/:schema/:table/:recordId + * Historial completo de un registro + */ + router.get('/history/:schema/:table/:recordId', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { schema, table, recordId } = req.params; + const limit = parseInt(req.query.limit as string) || 100; + + const history = await service.getRecordHistory(tenantId, schema, table, recordId, limit); + res.json(history); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Obtiene cambio por ID + */ + router.get('/:id', async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + const change = await service.findById(id); + if (!change) { + res.status(404).json({ error: 'Registro de cambio no encontrado' }); + return; + } + res.json(change); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ==================== MANTENIMIENTO ==================== + + /** + * DELETE /purge + * Purgar registros antiguos (admin only) + */ + router.delete('/purge', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const daysToKeep = parseInt(req.query.daysToKeep as string) || 90; + + const deleted = await service.purgeOldRecords(tenantId, daysToKeep); + res.json({ deleted }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/admin/controllers/index.ts b/src/modules/admin/controllers/index.ts index bbd4d41..b0ea0f1 100644 --- a/src/modules/admin/controllers/index.ts +++ b/src/modules/admin/controllers/index.ts @@ -7,3 +7,6 @@ export { createCostCenterController } from './cost-center.controller'; export { createAuditLogController } from './audit-log.controller'; export { createSystemSettingController } from './system-setting.controller'; export { createBackupController } from './backup.controller'; + +// GAP-007: Audit Triggers +export { createChangeLogController } from './change-log.controller'; diff --git a/src/modules/admin/entities/change-log.entity.ts b/src/modules/admin/entities/change-log.entity.ts new file mode 100644 index 0000000..2899fbe --- /dev/null +++ b/src/modules/admin/entities/change-log.entity.ts @@ -0,0 +1,83 @@ +/** + * ChangeLog Entity + * + * Registro automático de cambios a nivel de base de datos + * mediante triggers. Captura INSERT, UPDATE, DELETE en tablas críticas. + * + * @module Admin + * @table audit.change_log + * @gap GAP-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type OperationType = 'INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE'; + +@Entity({ schema: 'audit', name: 'change_log' }) +@Index(['tenantId']) +@Index(['userId']) +@Index(['tableSchema', 'tableName']) +@Index(['recordId']) +@Index(['executedAt']) +@Index(['executedDate']) +@Index(['operation']) +export class ChangeLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // Contexto + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId?: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string; + + @Column({ name: 'session_id', length: 100, nullable: true }) + sessionId?: string; + + // Operación + @Column({ type: 'varchar', length: 10 }) + operation!: OperationType; + + @Column({ name: 'table_schema', length: 63 }) + tableSchema!: string; + + @Column({ name: 'table_name', length: 63 }) + tableName!: string; + + @Column({ name: 'record_id', type: 'uuid', nullable: true }) + recordId?: string; + + // Datos + @Column({ name: 'old_data', type: 'jsonb', nullable: true }) + oldData?: Record; + + @Column({ name: 'new_data', type: 'jsonb', nullable: true }) + newData?: Record; + + @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true }) + changedFields?: string[]; + + // Metadatos + @Column({ name: 'client_ip', type: 'inet', nullable: true }) + clientIp?: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string; + + @Column({ name: 'application_name', length: 100, nullable: true }) + applicationName?: string; + + // Timestamp + @Column({ name: 'executed_at', type: 'timestamptz', default: () => 'NOW()' }) + executedAt!: Date; + + // Campo generado (solo lectura) + @Column({ name: 'executed_date', type: 'date', insert: false, update: false }) + executedDate?: Date; +} diff --git a/src/modules/admin/entities/index.ts b/src/modules/admin/entities/index.ts index 9276d74..125801e 100644 --- a/src/modules/admin/entities/index.ts +++ b/src/modules/admin/entities/index.ts @@ -8,3 +8,6 @@ export * from './audit-log.entity'; export * from './system-setting.entity'; export * from './backup.entity'; export * from './custom-permission.entity'; + +// GAP-007: Audit Triggers +export * from './change-log.entity'; diff --git a/src/modules/admin/services/change-log.service.ts b/src/modules/admin/services/change-log.service.ts new file mode 100644 index 0000000..09a8101 --- /dev/null +++ b/src/modules/admin/services/change-log.service.ts @@ -0,0 +1,254 @@ +/** + * Change Log Service + * ERP Construccion - Modulo Admin + * + * Consultas sobre el registro de cambios de auditoria (triggers). + * @gap GAP-007 + */ + +import { Repository, DataSource, Between } from 'typeorm'; +import { ChangeLog, OperationType } from '../entities/change-log.entity'; + +// DTOs +export interface ChangeLogFilters { + tableSchema?: string; + tableName?: string; + recordId?: string; + userId?: string; + operation?: OperationType; + fromDate?: Date; + toDate?: Date; +} + +interface PaginationOptions { + page: number; + limit: number; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface RecordHistory { + changeId: string; + operation: OperationType; + changedFields: string[] | null; + oldData: Record | null; + newData: Record | null; + userId: string | null; + executedAt: Date; +} + +export class ChangeLogService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ChangeLog); + } + + // ============================================ + // QUERIES + // ============================================ + + async findAll( + tenantId: string, + filters: ChangeLogFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 50 } + ): Promise> { + const queryBuilder = this.repository.createQueryBuilder('log') + .where('log.tenant_id = :tenantId', { tenantId }); + + if (filters.tableSchema) { + queryBuilder.andWhere('log.table_schema = :schema', { schema: filters.tableSchema }); + } + if (filters.tableName) { + queryBuilder.andWhere('log.table_name = :table', { table: filters.tableName }); + } + if (filters.recordId) { + queryBuilder.andWhere('log.record_id = :recordId', { recordId: filters.recordId }); + } + if (filters.userId) { + queryBuilder.andWhere('log.user_id = :userId', { userId: filters.userId }); + } + if (filters.operation) { + queryBuilder.andWhere('log.operation = :operation', { operation: filters.operation }); + } + if (filters.fromDate) { + queryBuilder.andWhere('log.executed_at >= :fromDate', { fromDate: filters.fromDate }); + } + if (filters.toDate) { + queryBuilder.andWhere('log.executed_at <= :toDate', { toDate: filters.toDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('log.executed_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + // ============================================ + // HISTORIAL DE REGISTRO + // ============================================ + + async getRecordHistory( + tenantId: string, + tableSchema: string, + tableName: string, + recordId: string, + limit: number = 100 + ): Promise { + const logs = await this.repository.find({ + where: { + tenantId, + tableSchema, + tableName, + recordId, + }, + order: { executedAt: 'DESC' }, + take: limit, + }); + + return logs.map(log => ({ + changeId: log.id, + operation: log.operation, + changedFields: log.changedFields || null, + oldData: log.oldData || null, + newData: log.newData || null, + userId: log.userId || null, + executedAt: log.executedAt, + })); + } + + // ============================================ + // CAMBIOS RECIENTES + // ============================================ + + async getRecentChanges( + tenantId: string, + hours: number = 24, + limit: number = 100 + ): Promise { + const fromDate = new Date(); + fromDate.setHours(fromDate.getHours() - hours); + + return this.repository.find({ + where: { + tenantId, + executedAt: Between(fromDate, new Date()), + }, + order: { executedAt: 'DESC' }, + take: limit, + }); + } + + async getChangesByUser( + tenantId: string, + userId: string, + pagination: PaginationOptions = { page: 1, limit: 50 } + ): Promise> { + return this.findAll(tenantId, { userId }, pagination); + } + + async getChangesByTable( + tenantId: string, + tableSchema: string, + tableName: string, + pagination: PaginationOptions = { page: 1, limit: 50 } + ): Promise> { + return this.findAll(tenantId, { tableSchema, tableName }, pagination); + } + + // ============================================ + // ESTADÍSTICAS + // ============================================ + + async getStatistics( + tenantId: string, + fromDate?: Date, + toDate?: Date + ): Promise<{ + totalChanges: number; + inserts: number; + updates: number; + deletes: number; + tableBreakdown: { schema: string; table: string; count: number }[]; + }> { + const queryBuilder = this.repository.createQueryBuilder('log') + .where('log.tenant_id = :tenantId', { tenantId }); + + if (fromDate) { + queryBuilder.andWhere('log.executed_at >= :fromDate', { fromDate }); + } + if (toDate) { + queryBuilder.andWhere('log.executed_at <= :toDate', { toDate }); + } + + // Counts by operation + const [inserts, updates, deletes] = await Promise.all([ + queryBuilder.clone().andWhere('log.operation = :op', { op: 'INSERT' }).getCount(), + queryBuilder.clone().andWhere('log.operation = :op', { op: 'UPDATE' }).getCount(), + queryBuilder.clone().andWhere('log.operation = :op', { op: 'DELETE' }).getCount(), + ]); + + const totalChanges = inserts + updates + deletes; + + // Table breakdown + const tableStats = await this.repository.createQueryBuilder('log') + .select('log.table_schema', 'schema') + .addSelect('log.table_name', 'table') + .addSelect('COUNT(*)', 'count') + .where('log.tenant_id = :tenantId', { tenantId }) + .groupBy('log.table_schema') + .addGroupBy('log.table_name') + .orderBy('count', 'DESC') + .limit(20) + .getRawMany(); + + return { + totalChanges, + inserts, + updates, + deletes, + tableBreakdown: tableStats.map(s => ({ + schema: s.schema, + table: s.table, + count: parseInt(s.count), + })), + }; + } + + // ============================================ + // MANTENIMIENTO + // ============================================ + + async purgeOldRecords(tenantId: string, daysToKeep: number = 90): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.repository.delete({ + tenantId, + executedAt: Between(new Date(0), cutoffDate), + }); + + return result.affected || 0; + } +} diff --git a/src/modules/admin/services/index.ts b/src/modules/admin/services/index.ts index ade2981..acb131d 100644 --- a/src/modules/admin/services/index.ts +++ b/src/modules/admin/services/index.ts @@ -7,3 +7,6 @@ export * from './cost-center.service'; export * from './audit-log.service'; export * from './system-setting.service'; export * from './backup.service'; + +// GAP-007: Audit Triggers +export * from './change-log.service'; diff --git a/src/modules/documents/controllers/digital-signature.controller.ts b/src/modules/documents/controllers/digital-signature.controller.ts new file mode 100644 index 0000000..250f2cf --- /dev/null +++ b/src/modules/documents/controllers/digital-signature.controller.ts @@ -0,0 +1,247 @@ +/** + * DigitalSignatureController - Controlador de Firmas Digitales + * + * Endpoints para gestion de firmas digitales de documentos. + * + * @module Documents (MAE-016) + * @gap GAP-006 + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DigitalSignatureService } from '../services'; + +export function createDigitalSignatureController(dataSource: DataSource): Router { + const router = Router(); + const service = new DigitalSignatureService(dataSource); + + // ==================== CRUD ==================== + + /** + * GET / + * Lista firmas con filtros y paginacion + */ + router.get('/', async (req: Request, res: Response, _next: NextFunction): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + const filters = { + documentId: req.query.documentId as string, + signerId: req.query.signerId as string, + status: req.query.status as any, + signatureType: req.query.signatureType as any, + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + }; + + const pagination = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + }; + + const result = await service.findAll(tenantId, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /statistics + * Estadisticas de firmas + */ + router.get('/statistics', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const stats = await service.getStatistics(tenantId); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /pending + * Lista firmas pendientes + */ + router.get('/pending', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const signerId = req.query.signerId as string | undefined; + const signatures = await service.getPendingSignatures(tenantId, signerId); + res.json(signatures); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-document/:documentId + * Firmas de un documento + */ + router.get('/by-document/:documentId', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { documentId } = req.params; + const signatures = await service.getByDocument(tenantId, documentId); + res.json(signatures); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /by-signer/:signerId + * Firmas de un firmante + */ + router.get('/by-signer/:signerId', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { signerId } = req.params; + const signatures = await service.getBySigner(tenantId, signerId); + res.json(signatures); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Obtiene firma por ID + */ + router.get('/:id', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const signature = await service.findById(tenantId, id); + if (!signature) { + res.status(404).json({ error: 'Firma no encontrada' }); + return; + } + res.json(signature); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Solicita una nueva firma + */ + router.post('/', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const signature = await service.requestSignature(tenantId, req.body, userId); + res.status(201).json(signature); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== OPERACIONES ==================== + + /** + * POST /:id/sign + * Firmar documento + */ + router.post('/:id/sign', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { id } = req.params; + + // Agregar IP y User-Agent del request + const dto = { + ...req.body, + ipAddress: req.ip || req.headers['x-forwarded-for'], + userAgent: req.headers['user-agent'], + }; + + const signature = await service.signDocument(tenantId, id, dto, userId); + if (!signature) { + res.status(404).json({ error: 'Firma no encontrada' }); + return; + } + res.json(signature); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/reject + * Rechazar solicitud de firma + */ + router.post('/:id/reject', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { id } = req.params; + + const signature = await service.rejectSignature(tenantId, id, req.body, userId); + if (!signature) { + res.status(404).json({ error: 'Firma no encontrada' }); + return; + } + res.json(signature); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/revoke + * Revocar firma + */ + router.post('/:id/revoke', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { id } = req.params; + + const signature = await service.revokeSignature(tenantId, id, req.body.reason, userId); + if (!signature) { + res.status(404).json({ error: 'Firma no encontrada' }); + return; + } + res.json(signature); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/validate + * Validar firma + */ + router.post('/:id/validate', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await service.validateSignature(tenantId, id); + res.json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /mark-expired + * Marcar firmas expiradas (job interno) + */ + router.post('/mark-expired', async (req: Request, res: Response): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const count = await service.markExpiredSignatures(tenantId); + res.json({ marked: count }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/documents/controllers/index.ts b/src/modules/documents/controllers/index.ts index 2f16959..efc00b1 100644 --- a/src/modules/documents/controllers/index.ts +++ b/src/modules/documents/controllers/index.ts @@ -4,3 +4,6 @@ */ export * from './document.controller'; + +// GAP-006: Firmas Digitales +export * from './digital-signature.controller'; diff --git a/src/modules/documents/entities/digital-signature.entity.ts b/src/modules/documents/entities/digital-signature.entity.ts new file mode 100644 index 0000000..5fa9247 --- /dev/null +++ b/src/modules/documents/entities/digital-signature.entity.ts @@ -0,0 +1,152 @@ +/** + * DigitalSignature Entity + * + * Firmas digitales para documentos con soporte para + * firma simple, avanzada y cualificada. + * + * @module Documents (MAE-016) + * @table documents.digital_signatures + * @gap GAP-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type SignatureType = 'simple' | 'advanced' | 'qualified'; + +export type SignatureStatus = 'pending' | 'signed' | 'rejected' | 'expired' | 'revoked'; + +@Entity({ schema: 'documents', name: 'digital_signatures' }) +@Index(['tenantId']) +@Index(['documentId']) +@Index(['signerId']) +@Index(['tenantId', 'status']) +@Index(['signedAt']) +export class DigitalSignature { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Documento + @Column({ name: 'document_id', type: 'uuid' }) + documentId!: string; + + @Column({ name: 'version_id', type: 'uuid', nullable: true }) + versionId?: string; + + // Firmante + @Column({ name: 'signer_id', type: 'uuid' }) + signerId!: string; + + @Column({ name: 'signer_name', length: 200 }) + signerName!: string; + + @Column({ name: 'signer_email', length: 255, nullable: true }) + signerEmail?: string; + + @Column({ name: 'signer_role', length: 100, nullable: true }) + signerRole?: string; + + // Tipo y estado + @Column({ + name: 'signature_type', + type: 'varchar', + length: 20, + default: 'simple', + }) + signatureType!: SignatureType; + + @Column({ + type: 'varchar', + length: 20, + default: 'pending', + }) + status!: SignatureStatus; + + // Datos de firma + @Column({ name: 'signature_data', type: 'text', nullable: true }) + signatureData?: string; + + @Column({ name: 'signature_hash', length: 128, nullable: true }) + signatureHash?: string; + + @Column({ name: 'hash_algorithm', length: 20, default: 'SHA-256' }) + hashAlgorithm!: string; + + // Certificado + @Column({ name: 'certificate_info', type: 'jsonb', nullable: true }) + certificateInfo?: Record; + + @Column({ name: 'certificate_serial', length: 100, nullable: true }) + certificateSerial?: string; + + @Column({ name: 'certificate_issuer', length: 255, nullable: true }) + certificateIssuer?: string; + + @Column({ name: 'certificate_valid_from', type: 'timestamptz', nullable: true }) + certificateValidFrom?: Date; + + @Column({ name: 'certificate_valid_to', type: 'timestamptz', nullable: true }) + certificateValidTo?: Date; + + // Timestamps + @Column({ name: 'requested_at', type: 'timestamptz', default: () => 'NOW()' }) + requestedAt!: Date; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt?: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date; + + // Contexto + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress?: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string; + + @Column({ type: 'jsonb', nullable: true }) + geolocation?: Record; + + // Validación + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid!: boolean; + + @Column({ name: 'validation_errors', type: 'jsonb', nullable: true }) + validationErrors?: Record; + + @Column({ name: 'last_validated_at', type: 'timestamptz', nullable: true }) + lastValidatedAt?: Date; + + // Razón + @Column({ type: 'text', nullable: true }) + reason?: string; + + // Metadatos + @Column({ type: 'jsonb', default: '{}' }) + metadata!: Record; + + // Relaciones + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant?: Tenant; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/documents/entities/index.ts b/src/modules/documents/entities/index.ts index 622807f..d232b12 100644 --- a/src/modules/documents/entities/index.ts +++ b/src/modules/documents/entities/index.ts @@ -14,3 +14,6 @@ export * from './approval-action.entity'; export * from './annotation.entity'; export * from './access-log.entity'; export * from './document-share.entity'; + +// GAP-006: Firmas Digitales +export * from './digital-signature.entity'; diff --git a/src/modules/documents/services/digital-signature.service.ts b/src/modules/documents/services/digital-signature.service.ts new file mode 100644 index 0000000..4164439 --- /dev/null +++ b/src/modules/documents/services/digital-signature.service.ts @@ -0,0 +1,339 @@ +/** + * Digital Signature Service + * ERP Construccion - Modulo Documents (MAE-016) + * + * Logica de negocio para firmas digitales de documentos. + * @gap GAP-006 + */ + +import { Repository, DataSource, LessThan } from 'typeorm'; +import { DigitalSignature, SignatureType, SignatureStatus } from '../entities/digital-signature.entity'; +import * as crypto from 'crypto'; + +// DTOs +export interface CreateSignatureRequestDto { + documentId: string; + versionId?: string; + signerId: string; + signerName: string; + signerEmail?: string; + signerRole?: string; + signatureType?: SignatureType; + expiresAt?: Date; + metadata?: Record; +} + +export interface SignDocumentDto { + signatureData?: string; + certificateInfo?: Record; + certificateSerial?: string; + certificateIssuer?: string; + ipAddress?: string; + userAgent?: string; + geolocation?: Record; +} + +export interface RejectSignatureDto { + reason: string; +} + +export interface SignatureFilters { + documentId?: string; + signerId?: string; + status?: SignatureStatus; + signatureType?: SignatureType; + fromDate?: Date; + toDate?: Date; +} + +interface PaginationOptions { + page: number; + limit: number; +} + +interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class DigitalSignatureService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(DigitalSignature); + } + + // ============================================ + // CRUD + // ============================================ + + async requestSignature( + tenantId: string, + dto: CreateSignatureRequestDto, + _userId?: string + ): Promise { + const signature = this.repository.create({ + tenantId, + documentId: dto.documentId, + versionId: dto.versionId, + signerId: dto.signerId, + signerName: dto.signerName, + signerEmail: dto.signerEmail, + signerRole: dto.signerRole, + signatureType: dto.signatureType || 'simple', + status: 'pending', + expiresAt: dto.expiresAt, + metadata: dto.metadata || {}, + requestedAt: new Date(), + }); + + return this.repository.save(signature); + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findAll( + tenantId: string, + filters: SignatureFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 20 } + ): Promise> { + const queryBuilder = this.repository.createQueryBuilder('sig') + .where('sig.tenant_id = :tenantId', { tenantId }); + + if (filters.documentId) { + queryBuilder.andWhere('sig.document_id = :documentId', { documentId: filters.documentId }); + } + if (filters.signerId) { + queryBuilder.andWhere('sig.signer_id = :signerId', { signerId: filters.signerId }); + } + if (filters.status) { + queryBuilder.andWhere('sig.status = :status', { status: filters.status }); + } + if (filters.signatureType) { + queryBuilder.andWhere('sig.signature_type = :type', { type: filters.signatureType }); + } + if (filters.fromDate) { + queryBuilder.andWhere('sig.requested_at >= :fromDate', { fromDate: filters.fromDate }); + } + if (filters.toDate) { + queryBuilder.andWhere('sig.requested_at <= :toDate', { toDate: filters.toDate }); + } + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .orderBy('sig.requested_at', 'DESC') + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + // ============================================ + // OPERACIONES DE FIRMA + // ============================================ + + async signDocument( + tenantId: string, + id: string, + dto: SignDocumentDto, + _userId?: string + ): Promise { + const signature = await this.findById(tenantId, id); + if (!signature) return null; + + if (signature.status !== 'pending') { + throw new Error('Signature request is not pending'); + } + + // Check expiration + if (signature.expiresAt && signature.expiresAt < new Date()) { + signature.status = 'expired'; + await this.repository.save(signature); + throw new Error('Signature request has expired'); + } + + // Generate signature hash + const dataToHash = JSON.stringify({ + documentId: signature.documentId, + signerId: signature.signerId, + timestamp: new Date().toISOString(), + signatureData: dto.signatureData, + }); + const signatureHash = crypto.createHash('sha256').update(dataToHash).digest('hex'); + + signature.signatureData = dto.signatureData; + signature.signatureHash = signatureHash; + signature.certificateInfo = dto.certificateInfo; + signature.certificateSerial = dto.certificateSerial; + signature.certificateIssuer = dto.certificateIssuer; + signature.ipAddress = dto.ipAddress; + signature.userAgent = dto.userAgent; + signature.geolocation = dto.geolocation; + signature.signedAt = new Date(); + signature.status = 'signed'; + signature.isValid = true; + signature.lastValidatedAt = new Date(); + + return this.repository.save(signature); + } + + async rejectSignature( + tenantId: string, + id: string, + dto: RejectSignatureDto, + _userId?: string + ): Promise { + const signature = await this.findById(tenantId, id); + if (!signature) return null; + + if (signature.status !== 'pending') { + throw new Error('Signature request is not pending'); + } + + signature.status = 'rejected'; + signature.reason = dto.reason; + + return this.repository.save(signature); + } + + async revokeSignature( + tenantId: string, + id: string, + reason: string, + _userId?: string + ): Promise { + const signature = await this.findById(tenantId, id); + if (!signature) return null; + + if (signature.status !== 'signed') { + throw new Error('Only signed documents can be revoked'); + } + + signature.status = 'revoked'; + signature.reason = reason; + signature.isValid = false; + + return this.repository.save(signature); + } + + async validateSignature( + tenantId: string, + id: string + ): Promise<{ isValid: boolean; errors?: string[] }> { + const signature = await this.findById(tenantId, id); + if (!signature) { + return { isValid: false, errors: ['Signature not found'] }; + } + + const errors: string[] = []; + + // Check status + if (signature.status !== 'signed') { + errors.push(`Invalid status: ${signature.status}`); + } + + // Check certificate validity for advanced/qualified signatures + if (signature.signatureType !== 'simple' && signature.certificateValidTo) { + if (signature.certificateValidTo < new Date()) { + errors.push('Certificate has expired'); + } + } + + // Update validation timestamp + signature.lastValidatedAt = new Date(); + signature.validationErrors = errors.length > 0 ? { errors } : undefined; + signature.isValid = errors.length === 0; + await this.repository.save(signature); + + return { + isValid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined, + }; + } + + // ============================================ + // QUERIES + // ============================================ + + async getByDocument(tenantId: string, documentId: string): Promise { + return this.repository.find({ + where: { tenantId, documentId }, + order: { requestedAt: 'DESC' }, + }); + } + + async getBySigner(tenantId: string, signerId: string): Promise { + return this.repository.find({ + where: { tenantId, signerId }, + order: { requestedAt: 'DESC' }, + }); + } + + async getPendingSignatures(tenantId: string, signerId?: string): Promise { + const where: any = { tenantId, status: 'pending' as SignatureStatus }; + if (signerId) { + where.signerId = signerId; + } + + return this.repository.find({ + where, + order: { requestedAt: 'ASC' }, + }); + } + + async getExpiredSignatures(tenantId: string): Promise { + return this.repository.find({ + where: { + tenantId, + status: 'pending' as SignatureStatus, + expiresAt: LessThan(new Date()), + }, + }); + } + + async markExpiredSignatures(tenantId: string): Promise { + const result = await this.repository.update( + { + tenantId, + status: 'pending' as SignatureStatus, + expiresAt: LessThan(new Date()), + }, + { status: 'expired' as SignatureStatus } + ); + + return result.affected || 0; + } + + async getStatistics(tenantId: string): Promise<{ + pending: number; + signed: number; + rejected: number; + expired: number; + revoked: number; + }> { + const [pending, signed, rejected, expired, revoked] = await Promise.all([ + this.repository.count({ where: { tenantId, status: 'pending' } }), + this.repository.count({ where: { tenantId, status: 'signed' } }), + this.repository.count({ where: { tenantId, status: 'rejected' } }), + this.repository.count({ where: { tenantId, status: 'expired' } }), + this.repository.count({ where: { tenantId, status: 'revoked' } }), + ]); + + return { pending, signed, rejected, expired, revoked }; + } +} diff --git a/src/modules/documents/services/index.ts b/src/modules/documents/services/index.ts index 80ff157..f8595f9 100644 --- a/src/modules/documents/services/index.ts +++ b/src/modules/documents/services/index.ts @@ -5,3 +5,6 @@ export * from './document.service'; export * from './document-version.service'; + +// GAP-006: Firmas Digitales +export * from './digital-signature.service'; diff --git a/src/server.ts b/src/server.ts index 929eebd..238b37c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,8 +62,10 @@ import { createComparativoController } from './modules/purchase/controllers'; import { createDerechohabienteController, createAsignacionController } from './modules/infonavit/controllers'; import { createInspectionController, createTicketController } from './modules/quality/controllers'; import { createContractController, createSubcontractorController } from './modules/contracts/controllers'; -import { createReportController, createDashboardController, createKpiController } from './modules/reports/controllers'; -import { createCostCenterController, createAuditLogController, createSystemSettingController, createBackupController } from './modules/admin/controllers'; +import { createReportController, createDashboardController, createKpiController, createKpiConfigController } from './modules/reports/controllers'; +import { createToolLoanController, createDepreciationController } from './modules/assets/controllers'; +import { createCostCenterController, createAuditLogController, createSystemSettingController, createBackupController, createChangeLogController } from './modules/admin/controllers'; +import { createDigitalSignatureController } from './modules/documents/controllers'; import { createOpportunityController, createBidAnalyticsController, createTenderController, createProposalController, createVendorController } from './modules/bidding/controllers'; import { createAccountingController, createAPController, createARController, createCashFlowController, createBankReconciliationController, createReportsController } from './modules/finance/controllers'; @@ -124,6 +126,13 @@ app.get(`/api/${API_VERSION}`, (_req, res) => { 'payment-terminals': `/api/${API_VERSION}/payment-terminals`, mercadopago: `/api/${API_VERSION}/mercadopago`, clip: `/api/${API_VERSION}/clip`, + // GAP-001, GAP-002, GAP-003 + 'kpis-config': `/api/${API_VERSION}/kpis-config`, + 'tool-loans': `/api/${API_VERSION}/tool-loans`, + depreciation: `/api/${API_VERSION}/depreciation`, + // GAP-006, GAP-007 + 'digital-signatures': `/api/${API_VERSION}/digital-signatures`, + 'change-log': `/api/${API_VERSION}/change-log`, }, }); }); @@ -136,26 +145,7 @@ app.use(`/api/${API_VERSION}/fraccionamientos`, fraccionamientoController); let authController: ReturnType; let usersController: ReturnType; -/** - * 404 Handler - */ -app.use((req, res) => { - res.status(404).json({ - error: 'Not Found', - message: `Cannot ${req.method} ${req.path}`, - }); -}); - -/** - * Error Handler - */ -app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('Error:', err); - res.status(500).json({ - error: 'Internal Server Error', - message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong', - }); -}); +// 404 and Error handlers are registered in bootstrap() after all routes /** * Inicializar Base de Datos y Servidor @@ -357,6 +347,44 @@ async function bootstrap() { app.use('/webhooks', paymentTerminals.webhookRouter); console.log('💳 Payment Terminals module inicializado'); + // Inicializar GAP Controllers (KPIs, Tool Loans, Depreciation) + const kpiConfigController = createKpiConfigController(AppDataSource); + app.use(`/api/${API_VERSION}/kpis-config`, kpiConfigController); + + const toolLoanController = createToolLoanController(AppDataSource); + app.use(`/api/${API_VERSION}/tool-loans`, toolLoanController); + + const depreciationController = createDepreciationController(AppDataSource); + app.use(`/api/${API_VERSION}/depreciation`, depreciationController); + + console.log('🔧 GAP modules inicializados (KPIs Config, Tool Loans, Depreciation)'); + + // Inicializar GAP-006 y GAP-007 Controllers + const digitalSignatureController = createDigitalSignatureController(AppDataSource); + app.use(`/api/${API_VERSION}/digital-signatures`, digitalSignatureController); + + const changeLogController = createChangeLogController(AppDataSource); + app.use(`/api/${API_VERSION}/change-log`, changeLogController); + + console.log('🔐 GAP modules inicializados (Digital Signatures, Change Log)'); + + // 404 Handler - MUST be after all routes + app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Cannot ${req.method} ${req.path}`, + }); + }); + + // Error Handler - MUST be last + app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('Error:', err); + res.status(500).json({ + error: 'Internal Server Error', + message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong', + }); + }); + // Iniciar servidor app.listen(PORT, () => { console.log('🚀 Servidor iniciado');