import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common"; import { Observable } from "rxjs"; import { tap } from "rxjs/operators"; import { ActivityService } from "../activity.service"; import { ActivityAction, EntityType } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import type { AuthenticatedRequest } from "../../common/types/user.types"; /** * Interceptor for automatic activity logging * Logs CREATE, UPDATE, DELETE actions based on HTTP methods */ @Injectable() export class ActivityLoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(ActivityLoggingInterceptor.name); constructor(private readonly activityService: ActivityService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, user } = request; // Only log for authenticated requests if (!user) { return next.handle(); } // Skip GET requests (read-only) if (method === "GET") { return next.handle(); } return next.handle().pipe( tap((result: unknown): void => { // Use void to satisfy no-misused-promises rule void this.logActivity(context, request, result); }) ); } /** * Logs activity asynchronously (not awaited to avoid blocking response) */ private async logActivity( context: ExecutionContext, request: AuthenticatedRequest, result: unknown ): Promise { try { const { method, params, body, user, ip, headers } = request; if (!user) { return; } const action = this.mapMethodToAction(method); if (!action) { return; } // Extract entity information const resultObj = result as Record | undefined; const entityId = params.id ?? (resultObj?.id as string | undefined); const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined); if (!entityId || !workspaceId) { this.logger.warn("Cannot log activity: missing entityId or workspaceId"); return; } // Determine entity type from controller/handler const controllerName = context.getClass().name; const handlerName = context.getHandler().name; const entityType = this.inferEntityType(controllerName, handlerName); // Build activity details with sanitized body const sanitizedBody = this.sanitizeSensitiveData(body); const details: Prisma.JsonObject = { method, controller: controllerName, handler: handlerName, }; if (method === "POST") { details.data = sanitizedBody; } else if (method === "PATCH" || method === "PUT") { details.changes = sanitizedBody; } // Extract user agent header const userAgentHeader = headers["user-agent"]; const userAgent = typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0]; // Log the activity await this.activityService.logActivity({ workspaceId, userId: user.id, action, entityType, entityId, details, ipAddress: ip ?? undefined, userAgent: userAgent ?? undefined, }); } catch (error) { // Don't fail the request if activity logging fails this.logger.error( "Failed to log activity", error instanceof Error ? error.message : "Unknown error" ); } } /** * Map HTTP method to ActivityAction */ private mapMethodToAction(method: string): ActivityAction | null { switch (method) { case "POST": return ActivityAction.CREATED; case "PATCH": case "PUT": return ActivityAction.UPDATED; case "DELETE": return ActivityAction.DELETED; default: return null; } } /** * Infer entity type from controller/handler names */ private inferEntityType(controllerName: string, handlerName: string): EntityType { const combined = `${controllerName} ${handlerName}`.toLowerCase(); if (combined.includes("task")) { return EntityType.TASK; } else if (combined.includes("event")) { return EntityType.EVENT; } else if (combined.includes("project")) { return EntityType.PROJECT; } else if (combined.includes("workspace")) { return EntityType.WORKSPACE; } else if (combined.includes("user")) { return EntityType.USER; } // Default to TASK if cannot determine return EntityType.TASK; } /** * Sanitize sensitive data from objects before logging * Redacts common sensitive field names */ private sanitizeSensitiveData(data: unknown): Prisma.JsonValue { if (typeof data !== "object" || data === null) { return data as Prisma.JsonValue; } // List of sensitive field names (case-insensitive) const sensitiveFields = [ "password", "token", "secret", "apikey", "api_key", "authorization", "creditcard", "credit_card", "cvv", "ssn", "privatekey", "private_key", ]; const sanitize = (obj: unknown): Prisma.JsonValue => { if (Array.isArray(obj)) { return obj.map((item) => sanitize(item)) as Prisma.JsonArray; } if (obj && typeof obj === "object") { const sanitized: Prisma.JsonObject = {}; const objRecord = obj as Record; for (const key in objRecord) { const lowerKey = key.toLowerCase(); const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field)); if (isSensitive) { sanitized[key] = "[REDACTED]"; } else if (typeof objRecord[key] === "object") { sanitized[key] = sanitize(objRecord[key]); } else { sanitized[key] = objRecord[key] as Prisma.JsonValue; } } return sanitized; } return obj as Prisma.JsonValue; }; return sanitize(data); } }