[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 { createSystemSettingController } from './system-setting.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 './backup.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 './system-setting.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';
|
||||
|
||||
// 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 './access-log.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-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 { 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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user