import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { mergeMap, Observable } from 'rxjs'; import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service'; import { AuditService } from '../../modules/audit/audit.service'; import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator'; type MutationRequest = AuthenticatedRequest & { method?: string; originalUrl?: string; url?: string; params?: Record; body?: Record; }; @Injectable() export class AuditMutationInterceptor implements NestInterceptor { constructor( private readonly reflector: Reflector, private readonly audit: AuditService, private readonly authContext: AuthContextService, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const metadata = this.reflector.getAllAndOverride(AUDIT_MUTATION_METADATA_KEY, [ context.getHandler(), context.getClass(), ]); if (!metadata) return next.handle(); const request = context.switchToHttp().getRequest(); return next.handle().pipe(mergeMap(async (result) => { const actor = await this.authContext.resolveCurrentUser(request); const entity = extractEntity(result); const scope = resolveScope(metadata, request, entity); await this.audit.record({ actor, action: metadata.action, entityType: metadata.entityType, entityId: resolveEntityId(metadata, request, entity), productId: scope.productId, projectId: scope.projectId, versionId: scope.versionId, scope, after: result, metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() }, }); return result; })); } } function extractEntity(result: unknown): Record | undefined { if (!result || typeof result !== 'object') return undefined; const record = result as Record; if (record.item && typeof record.item === 'object') return record.item as Record; if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record; return record; } function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined; const fromEntity = entity?.id; return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown'); } function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { return compact({ productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity), projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity), versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity), }); } function scopedValue( paramKey: string | undefined, bodyKey: string | undefined, resultKey: string, request: MutationRequest, entity: Record | undefined, ): string | undefined { const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined) ?? entity?.[resultKey]; return typeof value === 'string' && value ? value : undefined; } function compact>(value: T): { [K in keyof T]?: string } { return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string }; }