chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { AuditSource } from '../database/entities';
import { buildAuditFilters } from './audit-query';
import type { ListAuditQueryDto } from './dto/list-audit-query.dto';
export interface AuditEventSummary {
id: string;
occurredAt: Date;
actorUserId: string | null;
actorUsername: string | null;
action: string;
entityType: string | null;
entityId: string | null;
requestId: string | null;
source: AuditSource;
ip: string | null;
hasDetails: boolean;
}
export interface AuditEventDetail extends AuditEventSummary {
userAgent: string | null;
beforeData: Record<string, unknown> | null;
afterData: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}
@Injectable()
export class AuditQueryService {
constructor(private readonly dataSource: DataSource) {}
async list(query: ListAuditQueryDto) {
this.validateDateRange(query);
const filters = buildAuditFilters(query);
const page = query.page;
const pageSize = query.pageSize;
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [countRow] = (await manager.query(
`SELECT COUNT(*)::integer AS total FROM audit_events event ${filters.clause}`,
filters.parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const parameters = [
...filters.parameters,
pageSize,
(page - 1) * pageSize,
];
const limitParameter = parameters.length - 1;
const offsetParameter = parameters.length;
const rows = (await manager.query(
`
SELECT
event.id,
event.occurred_at AS "occurredAt",
event.actor_user_id AS "actorUserId",
event.actor_username AS "actorUsername",
event.action,
event.entity_type AS "entityType",
event.entity_id AS "entityId",
event.request_id AS "requestId",
event.source,
event.ip,
(
event.before_data IS NOT NULL
OR event.after_data IS NOT NULL
OR event.metadata IS NOT NULL
) AS "hasDetails"
FROM audit_events event
${filters.clause}
ORDER BY event.occurred_at DESC, event.id DESC
LIMIT $${limitParameter} OFFSET $${offsetParameter}
`,
parameters,
)) as AuditEventSummary[];
return {
data: rows,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
});
}
async getById(id: string): Promise<AuditEventDetail> {
return this.dataSource.transaction((manager) =>
this.loadEvent(manager, id),
);
}
private async loadEvent(
manager: EntityManager,
id: string,
): Promise<AuditEventDetail> {
const [row] = (await manager.query(
`
SELECT
event.id,
event.occurred_at AS "occurredAt",
event.actor_user_id AS "actorUserId",
event.actor_username AS "actorUsername",
event.action,
event.entity_type AS "entityType",
event.entity_id AS "entityId",
event.request_id AS "requestId",
event.source,
event.ip,
event.user_agent AS "userAgent",
event.before_data AS "beforeData",
event.after_data AS "afterData",
event.metadata,
(
event.before_data IS NOT NULL
OR event.after_data IS NOT NULL
OR event.metadata IS NOT NULL
) AS "hasDetails"
FROM audit_events event
WHERE event.id = $1
`,
[id],
)) as AuditEventDetail[];
if (!row) {
throw new NotFoundException({
code: 'AUDIT_EVENT_NOT_FOUND',
message: 'Evento de auditoría no encontrado',
});
}
return row;
}
private validateDateRange(query: ListAuditQueryDto): void {
if (query.from && query.to && new Date(query.from) > new Date(query.to)) {
throw new BadRequestException({
code: 'INVALID_DATE_RANGE',
message: 'La fecha inicial no puede ser posterior a la fecha final',
});
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ListAuditQueryDto } from './dto/list-audit-query.dto';
export interface AuditFilters {
clause: string;
parameters: unknown[];
}
export function buildAuditFilters(query: ListAuditQueryDto): AuditFilters {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (condition: (parameter: string) => string, value: unknown) => {
parameters.push(value);
conditions.push(condition(`$${parameters.length}`));
};
if (query.actorUserId) {
add((parameter) => `event.actor_user_id = ${parameter}`, query.actorUserId);
}
if (query.actorUsername) {
add(
(parameter) => `event.actor_username ILIKE ${parameter}`,
`%${query.actorUsername}%`,
);
}
if (query.action) {
add((parameter) => `event.action = ${parameter}`, query.action);
}
if (query.source) {
add((parameter) => `event.source = ${parameter}`, query.source);
}
if (query.entityType) {
add((parameter) => `event.entity_type = ${parameter}`, query.entityType);
}
if (query.entityId) {
add((parameter) => `event.entity_id = ${parameter}`, query.entityId);
}
if (query.requestId) {
add((parameter) => `event.request_id = ${parameter}`, query.requestId);
}
if (query.from) {
add((parameter) => `event.occurred_at >= ${parameter}`, new Date(query.from));
}
if (query.to) {
add((parameter) => `event.occurred_at <= ${parameter}`, new Date(query.to));
}
if (query.search) {
add(
(parameter) => `
(
event.actor_username ILIKE ${parameter}
OR event.action ILIKE ${parameter}
OR event.entity_type ILIKE ${parameter}
OR event.entity_id ILIKE ${parameter}
OR event.request_id ILIKE ${parameter}
)
`,
`%${query.search}%`,
);
}
return {
clause: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '',
parameters,
};
}
+43
View File
@@ -0,0 +1,43 @@
const SENSITIVE_KEYS = new Set([
'password',
'currentpassword',
'newpassword',
'passwordhash',
'token',
'accesstoken',
'refreshtoken',
'refreshtokenhash',
'authorization',
'cookie',
'cookies',
'setcookie',
'secret',
'pepper',
'jwt',
]);
function normalizedKey(key: string): string {
return key.replaceAll(/[^a-zA-Z0-9]/g, '').toLowerCase();
}
export function sanitizeAuditData(
value: unknown,
seen = new WeakSet<object>(),
): unknown {
if (value === null || value === undefined) return value;
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) {
return value.map((entry) => sanitizeAuditData(entry, seen));
}
if (typeof value !== 'object') return value;
if (seen.has(value)) return '[CIRCULAR]';
seen.add(value);
const sanitized: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
sanitized[key] = SENSITIVE_KEYS.has(normalizedKey(key))
? '[REDACTED]'
: sanitizeAuditData(entry, seen);
}
return sanitized;
}
+27
View File
@@ -0,0 +1,27 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
} from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { AuditQueryService } from './audit-query.service';
import { ListAuditQueryDto } from './dto/list-audit-query.dto';
@Controller('audit')
export class AuditController {
constructor(private readonly audit: AuditQueryService) {}
@Get()
@RequirePermissions('audit.read')
list(@Query() query: ListAuditQueryDto) {
return this.audit.list(query);
}
@Get(':id')
@RequirePermissions('audit.read')
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.audit.getById(id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { AuditController } from './audit.controller';
import { AuditQueryService } from './audit-query.service';
import { AuditService } from './audit.service';
@Module({
imports: [PhaseADataModule],
controllers: [AuditController],
providers: [AuditService, AuditQueryService],
exports: [AuditService],
})
export class AuditModule {}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { AuditEventsRepository } from '../core-data/repositories/audit-events.repository';
import { AuditEvent, AuditSource } from '../database/entities';
import { sanitizeAuditData } from './audit-sanitizer';
export interface RecordAuditInput {
actorUserId?: string | null;
actorUsername?: string | null;
action: string;
entityType?: string | null;
entityId?: string | null;
requestId?: string | null;
source?: AuditSource;
ip?: string | null;
userAgent?: string | null;
beforeData?: Record<string, unknown> | null;
afterData?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
}
@Injectable()
export class AuditService {
constructor(private readonly events: AuditEventsRepository) {}
async record(
input: RecordAuditInput,
manager?: EntityManager,
): Promise<void> {
const eventInput = {
actorUserId: input.actorUserId ?? null,
actorUsername: input.actorUsername ?? null,
action: input.action,
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
requestId: input.requestId ?? null,
source: input.source ?? AuditSource.WEB,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
beforeData: sanitizeAuditData(input.beforeData ?? null) as Record<
string,
unknown
> | null,
afterData: sanitizeAuditData(input.afterData ?? null) as Record<
string,
unknown
> | null,
metadata: sanitizeAuditData(input.metadata ?? null) as Record<
string,
unknown
> | null,
};
if (manager) {
await manager.getRepository(AuditEvent).save(eventInput);
return;
}
await this.events.save(this.events.create(eventInput));
}
}
@@ -0,0 +1,88 @@
import { Transform, Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AuditSource } from '../../database/entities';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class ListAuditQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
@IsOptional()
@IsUUID('4')
actorUserId?: string;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(80)
actorUsername?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsOptional()
@IsString()
@MaxLength(100)
action?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
@IsOptional()
@IsEnum(AuditSource)
source?: AuditSource;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(255)
entityId?: string;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(128)
requestId?: string;
@IsOptional()
@IsISO8601({ strict: true })
from?: string;
@IsOptional()
@IsISO8601({ strict: true })
to?: string;
@Transform(trim)
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}