File

src/common/interceptors/audit-log.interceptor.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

intercept
intercept(context: ExecutionContext, next: CallHandler)
Parameters :
Name Type Optional
context ExecutionContext No
next CallHandler No
Returns : Observable<unknown>
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
  private readonly logger = new Logger('AuditLog');

  constructor(private readonly prisma: PrismaService) {
    this.logger.log('AuditLogInterceptor initialized');
  }

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest();
    if (!request) return next.handle();

    const method = request.method;

    // Only log mutating actions
    if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
      return next.handle();
    }

    const user = request.user;
    const url: string = request.url || '';
    const body = request.body;

    // Parse URL to determine entity and action
    const cleanPath = url.replace(/^\/api\/v1\//, '').split('?')[0];
    const pathParts = cleanPath.split('/').filter(Boolean);
    const entityType = (pathParts[0] || 'SYSTEM').toUpperCase();
    const entityId = pathParts[1] && pathParts[1].length > 10 ? pathParts[1] : null;
    const subAction = pathParts.length > 2 ? pathParts[pathParts.length - 1] : null;

    let action: string;
    if (subAction && !entityId) {
      action = subAction.toUpperCase(); // e.g., "upload", "auto-allocate"
    } else if (subAction && entityId) {
      action = subAction.toUpperCase(); // e.g., "approve", "dispatch"
    } else {
      const map: Record<string, string> = { POST: 'CREATE', PATCH: 'UPDATE', PUT: 'UPDATE', DELETE: 'DELETE' };
      action = map[method] || method;
    }

    return next.handle().pipe(
      tap({
        next: (responseBody) => {
          const orgId = user?.organizationId;
          if (!orgId) return;

          const sanitized = this.sanitize(body);
          const resId = this.extractId(responseBody);

          this.prisma.auditLog.create({
            data: {
              organizationId: orgId,
              userId: user?.sub || user?.id || null,
              action,
              entityType,
              entityId: entityId || resId || null,
              newValues: sanitized as any,
              ipAddress: request.ip || request.headers?.['x-forwarded-for'] || null,
              userAgent: (request.headers?.['user-agent'] || '').substring(0, 255) || null,
            },
          }).catch(err => {
            this.logger.warn(`Audit log failed: ${err.message}`);
          });

          // Set addedByName / modifiedByName on the record
          const userName = [user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'System';
          const recordId = entityId || resId;
          if (recordId && userName) {
            const table = entityType.toLowerCase();
            const prismaModel = (this.prisma as any)[table === 'ingestions' ? 'ingestion' : table.endsWith('s') ? table.slice(0, -1) : table];
            if (prismaModel?.update) {
              const nameField = action === 'CREATE' ? 'addedByName' : 'modifiedByName';
              prismaModel.update({
                where: { id: recordId },
                data: { [nameField]: userName, modifiedByName: userName },
              }).catch(() => {}); // Silent — field may not exist on all models
            }
          }
        },
        error: (err) => {
          const orgId = user?.organizationId;
          if (!orgId) return;

          this.prisma.auditLog.create({
            data: {
              organizationId: orgId,
              userId: user?.sub || user?.id || null,
              action: `${action}_FAILED`,
              entityType,
              entityId: entityId || null,
              newValues: { error: err?.message, body: this.sanitize(body) } as any,
              ipAddress: request.ip || request.headers?.['x-forwarded-for'] || null,
              userAgent: (request.headers?.['user-agent'] || '').substring(0, 255) || null,
            },
          }).catch(() => {});
        },
      }),
    );
  }

  private sanitize(body: unknown): unknown {
    if (!body || typeof body !== 'object') return body;
    const obj = { ...(body as Record<string, unknown>) };
    delete obj.password;
    delete obj.currentPassword;
    delete obj.newPassword;
    delete obj.token;
    delete obj.refreshToken;
    delete obj.fileBase64;
    for (const [k, v] of Object.entries(obj)) {
      if (typeof v === 'string' && v.length > 500) {
        obj[k] = v.substring(0, 500) + '...[truncated]';
      }
    }
    return obj;
  }

  private extractId(body: unknown): string | null {
    if (!body || typeof body !== 'object') return null;
    const obj = body as Record<string, unknown>;
    return (obj.id as string) || ((obj.data as Record<string, unknown>)?.id as string) || null;
  }
}

results matching ""

    No results matching ""