[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:
parent
ba1d239f21
commit
2caa489093
181
src/modules/admin/controllers/change-log.controller.ts
Normal file
181
src/modules/admin/controllers/change-log.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -7,3 +7,6 @@ export { createCostCenterController } from './cost-center.controller';
|
|||||||
export { createAuditLogController } from './audit-log.controller';
|
export { createAuditLogController } from './audit-log.controller';
|
||||||
export { createSystemSettingController } from './system-setting.controller';
|
export { createSystemSettingController } from './system-setting.controller';
|
||||||
export { createBackupController } from './backup.controller';
|
export { createBackupController } from './backup.controller';
|
||||||
|
|
||||||
|
// GAP-007: Audit Triggers
|
||||||
|
export { createChangeLogController } from './change-log.controller';
|
||||||
|
|||||||
83
src/modules/admin/entities/change-log.entity.ts
Normal file
83
src/modules/admin/entities/change-log.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -8,3 +8,6 @@ export * from './audit-log.entity';
|
|||||||
export * from './system-setting.entity';
|
export * from './system-setting.entity';
|
||||||
export * from './backup.entity';
|
export * from './backup.entity';
|
||||||
export * from './custom-permission.entity';
|
export * from './custom-permission.entity';
|
||||||
|
|
||||||
|
// GAP-007: Audit Triggers
|
||||||
|
export * from './change-log.entity';
|
||||||
|
|||||||
254
src/modules/admin/services/change-log.service.ts
Normal file
254
src/modules/admin/services/change-log.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,3 +7,6 @@ export * from './cost-center.service';
|
|||||||
export * from './audit-log.service';
|
export * from './audit-log.service';
|
||||||
export * from './system-setting.service';
|
export * from './system-setting.service';
|
||||||
export * from './backup.service';
|
export * from './backup.service';
|
||||||
|
|
||||||
|
// GAP-007: Audit Triggers
|
||||||
|
export * from './change-log.service';
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -4,3 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './document.controller';
|
export * from './document.controller';
|
||||||
|
|
||||||
|
// GAP-006: Firmas Digitales
|
||||||
|
export * from './digital-signature.controller';
|
||||||
|
|||||||
152
src/modules/documents/entities/digital-signature.entity.ts
Normal file
152
src/modules/documents/entities/digital-signature.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -14,3 +14,6 @@ export * from './approval-action.entity';
|
|||||||
export * from './annotation.entity';
|
export * from './annotation.entity';
|
||||||
export * from './access-log.entity';
|
export * from './access-log.entity';
|
||||||
export * from './document-share.entity';
|
export * from './document-share.entity';
|
||||||
|
|
||||||
|
// GAP-006: Firmas Digitales
|
||||||
|
export * from './digital-signature.entity';
|
||||||
|
|||||||
339
src/modules/documents/services/digital-signature.service.ts
Normal file
339
src/modules/documents/services/digital-signature.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,3 +5,6 @@
|
|||||||
|
|
||||||
export * from './document.service';
|
export * from './document.service';
|
||||||
export * from './document-version.service';
|
export * from './document-version.service';
|
||||||
|
|
||||||
|
// GAP-006: Firmas Digitales
|
||||||
|
export * from './digital-signature.service';
|
||||||
|
|||||||
@ -62,8 +62,10 @@ import { createComparativoController } from './modules/purchase/controllers';
|
|||||||
import { createDerechohabienteController, createAsignacionController } from './modules/infonavit/controllers';
|
import { createDerechohabienteController, createAsignacionController } from './modules/infonavit/controllers';
|
||||||
import { createInspectionController, createTicketController } from './modules/quality/controllers';
|
import { createInspectionController, createTicketController } from './modules/quality/controllers';
|
||||||
import { createContractController, createSubcontractorController } from './modules/contracts/controllers';
|
import { createContractController, createSubcontractorController } from './modules/contracts/controllers';
|
||||||
import { createReportController, createDashboardController, createKpiController } from './modules/reports/controllers';
|
import { createReportController, createDashboardController, createKpiController, createKpiConfigController } from './modules/reports/controllers';
|
||||||
import { createCostCenterController, createAuditLogController, createSystemSettingController, createBackupController } from './modules/admin/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 { createOpportunityController, createBidAnalyticsController, createTenderController, createProposalController, createVendorController } from './modules/bidding/controllers';
|
||||||
import { createAccountingController, createAPController, createARController, createCashFlowController, createBankReconciliationController, createReportsController } from './modules/finance/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`,
|
'payment-terminals': `/api/${API_VERSION}/payment-terminals`,
|
||||||
mercadopago: `/api/${API_VERSION}/mercadopago`,
|
mercadopago: `/api/${API_VERSION}/mercadopago`,
|
||||||
clip: `/api/${API_VERSION}/clip`,
|
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 authController: ReturnType<typeof createAuthController>;
|
||||||
let usersController: ReturnType<typeof createUsersController>;
|
let usersController: ReturnType<typeof createUsersController>;
|
||||||
|
|
||||||
/**
|
// 404 and Error handlers are registered in bootstrap() after all routes
|
||||||
* 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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inicializar Base de Datos y Servidor
|
* Inicializar Base de Datos y Servidor
|
||||||
@ -357,6 +347,44 @@ async function bootstrap() {
|
|||||||
app.use('/webhooks', paymentTerminals.webhookRouter);
|
app.use('/webhooks', paymentTerminals.webhookRouter);
|
||||||
console.log('💳 Payment Terminals module inicializado');
|
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
|
// Iniciar servidor
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log('🚀 Servidor iniciado');
|
console.log('🚀 Servidor iniciado');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user