[GAP-006,007] feat: Add Digital Signatures and Audit Change Log

GAP-006: Digital Signatures (3 SP)
- digital-signature.entity.ts: Firma simple/avanzada/cualificada
- digital-signature.service.ts: Request, sign, reject, revoke, validate
- digital-signature.controller.ts: REST endpoints

GAP-007: Audit Change Log (5 SP)
- change-log.entity.ts: Registro de cambios via triggers
- change-log.service.ts: Queries, history, statistics
- change-log.controller.ts: REST endpoints

Routes registered in server.ts:
- /api/v1/digital-signatures
- /api/v1/change-log

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:14:53 -06:00
parent ba1d239f21
commit 2caa489093
13 changed files with 1324 additions and 22 deletions

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
}

View File

@ -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';

View File

@ -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<string, any>;
@Column({ name: 'new_data', type: 'jsonb', nullable: true })
newData?: Record<string, any>;
@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;
}

View File

@ -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';

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface RecordHistory {
changeId: string;
operation: OperationType;
changedFields: string[] | null;
oldData: Record<string, any> | null;
newData: Record<string, any> | null;
userId: string | null;
executedAt: Date;
}
export class ChangeLogService {
private repository: Repository<ChangeLog>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(ChangeLog);
}
// ============================================
// QUERIES
// ============================================
async findAll(
tenantId: string,
filters: ChangeLogFilters = {},
pagination: PaginationOptions = { page: 1, limit: 50 }
): Promise<PaginatedResult<ChangeLog>> {
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<ChangeLog | null> {
return this.repository.findOne({ where: { id } });
}
// ============================================
// HISTORIAL DE REGISTRO
// ============================================
async getRecordHistory(
tenantId: string,
tableSchema: string,
tableName: string,
recordId: string,
limit: number = 100
): Promise<RecordHistory[]> {
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<ChangeLog[]> {
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<PaginatedResult<ChangeLog>> {
return this.findAll(tenantId, { userId }, pagination);
}
async getChangesByTable(
tenantId: string,
tableSchema: string,
tableName: string,
pagination: PaginationOptions = { page: 1, limit: 50 }
): Promise<PaginatedResult<ChangeLog>> {
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<number> {
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;
}
}

View File

@ -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';

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
}

View File

@ -4,3 +4,6 @@
*/
export * from './document.controller';
// GAP-006: Firmas Digitales
export * from './digital-signature.controller';

View File

@ -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<string, any>;
@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<string, any>;
// Validación
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
@Column({ name: 'validation_errors', type: 'jsonb', nullable: true })
validationErrors?: Record<string, any>;
@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<string, any>;
// 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;
}

View File

@ -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';

View File

@ -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<string, any>;
}
export interface SignDocumentDto {
signatureData?: string;
certificateInfo?: Record<string, any>;
certificateSerial?: string;
certificateIssuer?: string;
ipAddress?: string;
userAgent?: string;
geolocation?: Record<string, any>;
}
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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class DigitalSignatureService {
private repository: Repository<DigitalSignature>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(DigitalSignature);
}
// ============================================
// CRUD
// ============================================
async requestSignature(
tenantId: string,
dto: CreateSignatureRequestDto,
_userId?: string
): Promise<DigitalSignature> {
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<DigitalSignature | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findAll(
tenantId: string,
filters: SignatureFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<DigitalSignature>> {
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<DigitalSignature | null> {
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<DigitalSignature | null> {
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<DigitalSignature | null> {
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<DigitalSignature[]> {
return this.repository.find({
where: { tenantId, documentId },
order: { requestedAt: 'DESC' },
});
}
async getBySigner(tenantId: string, signerId: string): Promise<DigitalSignature[]> {
return this.repository.find({
where: { tenantId, signerId },
order: { requestedAt: 'DESC' },
});
}
async getPendingSignatures(tenantId: string, signerId?: string): Promise<DigitalSignature[]> {
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<DigitalSignature[]> {
return this.repository.find({
where: {
tenantId,
status: 'pending' as SignatureStatus,
expiresAt: LessThan(new Date()),
},
});
}
async markExpiredSignatures(tenantId: string): Promise<number> {
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 };
}
}

View File

@ -5,3 +5,6 @@
export * from './document.service';
export * from './document-version.service';
// GAP-006: Firmas Digitales
export * from './digital-signature.service';

View File

@ -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<typeof createAuthController>;
let usersController: ReturnType<typeof createUsersController>;
/**
* 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');