import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import type { CurrentUser } from '../../common/auth/auth-context.service'; import type { QueryAuditEventsDto } from './dto/query-audit-events.dto'; export interface AuditRecordInput { actor?: CurrentUser | null; action: string; entityType: string; entityId: string; productId?: string | null; projectId?: string | null; versionId?: string | null; scope?: unknown; before?: unknown; after?: unknown; metadata?: unknown; } @Injectable() export class AuditService { constructor(private readonly prisma: PrismaService) {} record(input: AuditRecordInput) { return this.prisma.auditEvent.create({ data: { actorId: input.actor?.id ?? null, actorName: input.actor?.name ?? input.actor?.username ?? '', action: input.action, entityType: input.entityType, entityId: input.entityId, productId: input.productId ?? null, projectId: input.projectId ?? null, versionId: input.versionId ?? null, scope: toJson(input.scope ?? {}), before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)), after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)), metadata: toJson(redactSensitive(input.metadata ?? {})), }, }); } query(query: QueryAuditEventsDto) { return this.prisma.auditEvent.findMany({ where: buildWhere(query), orderBy: { createdAt: 'desc' }, take: clampTake(query.take), }); } } function buildWhere(query: QueryAuditEventsDto) { const where: Record = {}; for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) { if (query[key]) where[key] = query[key]; } const dateRange: Record = {}; if (query.dateFrom) dateRange.gte = new Date(query.dateFrom); if (query.dateTo) dateRange.lte = new Date(query.dateTo); if (Object.keys(dateRange).length > 0) where.createdAt = dateRange; return where; } function clampTake(value: string | undefined): number { const parsed = Number(value ?? 50); if (!Number.isFinite(parsed) || parsed <= 0) return 50; return Math.min(100, Math.floor(parsed)); } function redactSensitive(value: unknown): unknown { if (Array.isArray(value)) return value.map((item) => redactSensitive(item)); if (value instanceof Date) return value.toISOString(); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [ key, isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item), ])); } function isSensitiveKey(key: string): boolean { const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive)); } function toJson(value: unknown): Prisma.InputJsonValue { return value as Prisma.InputJsonValue; }