File

apps/recallassess/recallassess-api/src/api/shared/services/system-log.service.ts

Description

Common System Log Service Provides easy-to-use methods for logging system activities across all modules Automatically extracts request context (user, IP, user agent, endpoint, method)

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, http500Notification: SystemLogHttp500NotificationService, request?: Request)
Parameters :
Name Type Optional
prisma BNestPrismaService No
http500Notification SystemLogHttp500NotificationService No
request Request Yes

Methods

Async applyResponseStatusToPendingLogs
applyResponseStatusToPendingLogs(statusCode: number, responseTimeMs?: number)

Called by an interceptor after the handler completes. Stamps status_code/response_time_ms into system_log rows that were created during this request but don't yet have those fields populated.

Parameters :
Name Type Optional
statusCode number No
responseTimeMs number Yes
Returns : Promise<void>
Static calculateChangedFields
calculateChangedFields(oldData: Record<string | unknown>, newData: Record<string | unknown>)

Helper method to calculate changed fields between old and new data

Parameters :
Name Type Optional
oldData Record<string | unknown> No
newData Record<string | unknown> No
Async createLog
createLog(data: CreateSystemLogData)

Create a system log entry Automatically extracts request context (user, IP, user agent, endpoint, method)

Parameters :
Name Type Optional
data CreateSystemLogData No
Returns : Promise<void>
Private extractRequestContext
extractRequestContext()

Extract request context from both admin and client contexts

Returns : literal type
Async logDelete
logDelete(entity_type: SystemLogEntityType, entity_id: number, old_data?: Record<string | unknown>, additionalData?: Partial<CreateSystemLogData>)

Log a DELETE operation

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
entity_id number No
old_data Record<string | unknown> Yes
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logExport
logExport(entity_type: SystemLogEntityType, additionalData?: Partial<CreateSystemLogData>)

Log an EXPORT operation

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logImport
logImport(entity_type: SystemLogEntityType, additionalData?: Partial<CreateSystemLogData>)

Log an IMPORT operation

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logInsert
logInsert(entity_type: SystemLogEntityType, entity_id: number, new_data?: Record<string | unknown>, additionalData?: Partial<CreateSystemLogData>)

Log an INSERT operation

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
entity_id number No
new_data Record<string | unknown> Yes
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logLogin
logLogin(user_id: number, additionalData?: Partial<CreateSystemLogData>)

Log a LOGIN operation

Parameters :
Name Type Optional
user_id number No
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logLogout
logLogout(user_id: number, additionalData?: Partial<CreateSystemLogData>)

Log a LOGOUT operation

Parameters :
Name Type Optional
user_id number No
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logSelect
logSelect(entity_type: SystemLogEntityType, entity_id?: number, additionalData?: Partial<CreateSystemLogData>)

SELECT/READ operations are not persisted to system_log (by policy).

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
entity_id number Yes
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Async logUpdate
logUpdate(entity_type: SystemLogEntityType, entity_id: number, old_data?: Record<string | unknown>, new_data?: Record<string | unknown>, changed_fields?: Record<string | unknown>, additionalData?: Partial<CreateSystemLogData>)

Log an UPDATE operation

Parameters :
Name Type Optional
entity_type SystemLogEntityType No
entity_id number No
old_data Record<string | unknown> Yes
new_data Record<string | unknown> Yes
changed_fields Record<string | unknown> Yes
additionalData Partial<CreateSystemLogData> Yes
Returns : Promise<void>
Private setEntityForeignKey
setEntityForeignKey(logData: Prisma.SystemLogCreateInput, entity_type: SystemLogEntityType, entity_id: number)

Set the appropriate entity foreign key based on entity_type Note: We only use relation connect syntax - Prisma automatically sets the foreign key

Parameters :
Name Type Optional
logData Prisma.SystemLogCreateInput No
entity_type SystemLogEntityType No
entity_id number No
Returns : void

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(SystemLogService.name)
Private Readonly pendingStatusCodeLogIds
Type : number[]
Default value : []
import { BNestAppRequestContext } from "@bish-nest/core/request-context/app-request-context";
import { BNestPrismaService } from "@bish-nest/core/services";
import { getCLRequestContext } from "@api/shared/context";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
import { Request } from "express";
import {
  Prisma,
  SysmtemLogOperationType,
  SystemLogEntityType,
} from "@prisma/client";
import { RequestContext } from "@medibloc/nestjs-request-context";
import { SystemLogHttp500NotificationService } from "./system-log-http500-notification.service";

/**
 * Interface for system log creation data
 */
export interface CreateSystemLogData {
  entity_type: SystemLogEntityType;
  operation_type: SysmtemLogOperationType;
  entity_id?: number; // The ID of the affected entity (e.g., company_id, participant_id, etc.)
  /**
   * Optional explicit foreign keys to store on the log row in addition to the primary `entity_type` relation.
   * Useful when `entity_type` is SUBSCRIPTION/INVOICE/etc but we still want the log row to carry `company_id`
   * and/or the actor `participant_id` for easier filtering in the admin UI.
   */
  company_id?: number | null;
  participant_id?: number | null;
  subscription_id?: number | null;
  invoice_id?: number | null;
  old_data?: Record<string, unknown> | null;
  new_data?: Record<string, unknown> | null;
  changed_fields?: Record<string, unknown> | null;
  request_body?: Record<string, unknown> | null;
  status_code?: number | null;
  response_time_ms?: number | null;
  error_message?: string | null;
  system_module_id?: number | null;
  permission_id?: number | null;
}

/**
 * Common System Log Service
 * Provides easy-to-use methods for logging system activities across all modules
 * Automatically extracts request context (user, IP, user agent, endpoint, method)
 */
@Injectable()
export class SystemLogService {
  private readonly logger = new Logger(SystemLogService.name);

  // Tracks system logs created during the current HTTP request,
  // so an interceptor can stamp `status_code` after the response status is known.
  private readonly pendingStatusCodeLogIds: number[] = [];

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly http500Notification: SystemLogHttp500NotificationService,
    @Inject(REQUEST) private readonly request?: Request,
  ) {}

  /**
   * Create a system log entry
   * Automatically extracts request context (user, IP, user agent, endpoint, method)
   */
  async createLog(data: CreateSystemLogData): Promise<void> {
    try {
      if (data.operation_type === SysmtemLogOperationType.SELECT) {
        return;
      }

      // Extract request context
      const context = this.extractRequestContext();

      // Log warning if critical context is missing (for debugging)
      if (!context.userId && !context.participantId && !context.ipAddress && !context.endpoint) {
        this.logger.warn("System log created with minimal context - user/participant, IP, and endpoint all missing");
      }

      // Helper function to safely convert to JSON
      const toJsonValue = (value: Record<string, unknown> | null | undefined): Prisma.InputJsonValue | undefined => {
        if (!value) return undefined;
        try {
          // Deep clone and ensure it's a valid JSON-serializable object
          return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
        } catch (error) {
          this.logger.error(`Failed to serialize JSON data: ${error instanceof Error ? error.message : String(error)}`);
          return undefined;
        }
      };

      // Debug: Log what data we're about to store
      this.logger.debug(
        `Creating system log: ${data.entity_type} ${data.operation_type}, entity_id: ${data.entity_id}, has_old_data: ${!!data.old_data}, has_new_data: ${!!data.new_data}, old_data_keys: ${data.old_data ? Object.keys(data.old_data).length : 0}, new_data_keys: ${data.new_data ? Object.keys(data.new_data).length : 0}`,
      );

      // Build the log data
      const logData: Prisma.SystemLogCreateInput = {
        entity_type: data.entity_type,
        operation_type: data.operation_type,
        old_data: toJsonValue(data.old_data || undefined),
        new_data: toJsonValue(data.new_data || undefined),
        changed_fields: toJsonValue(data.changed_fields || undefined),
        request_body: toJsonValue(data.request_body || undefined),
        status_code: data.status_code ?? undefined,
        response_time_ms: data.response_time_ms ?? undefined,
        error_message: data.error_message ?? undefined,
        ip_address: context.ipAddress ?? undefined,
        user_agent: context.userAgent ?? undefined,
        request_endpoint: context.endpoint ?? undefined,
        request_method: context.method ?? undefined,
        timestamp: new Date(),
      };

      // Set user_id based on context (for admin requests)
      if (context.userId) {
        logData.user = { connect: { id: context.userId } };
      }

      // For client requests, add the requesting participant ID to new_data or request_body
      // Note: user_id remains NULL for client requests since participants are not users
      // The participant making the request is tracked here for audit purposes
      if (context.participantId && !context.userId) {
        // Add requesting participant to new_data if it exists, otherwise to request_body
        if (logData.new_data) {
          const newDataObj = logData.new_data as Record<string, unknown>;
          newDataObj['requested_by_participant_id'] = context.participantId;
          logData.new_data = newDataObj as Prisma.InputJsonValue;
        } else if (logData.request_body) {
          const requestBodyObj = logData.request_body as Record<string, unknown>;
          requestBodyObj['requested_by_participant_id'] = context.participantId;
          logData.request_body = requestBodyObj as Prisma.InputJsonValue;
        } else {
          // If neither exists, add it to new_data
          logData.new_data = { requested_by_participant_id: context.participantId } as Prisma.InputJsonValue;
        }
      }

      // Set system_module_id via relation
      if (data.system_module_id) {
        logData.systemModule = { connect: { id: data.system_module_id } };
      }

      // Set permission_id via relation
      if (data.permission_id) {
        logData.permission = { connect: { id: data.permission_id } };
      }

      // Set entity-specific foreign key based on entity_type
      if (data.entity_id) {
        this.setEntityForeignKey(logData, data.entity_type, data.entity_id);
      }

      // Explicit additional foreign keys (optional, can coexist with entity_type FK).
      if (data.company_id) {
        logData.company = { connect: { id: data.company_id } };
      }
      if (data.participant_id) {
        logData.participant = { connect: { id: data.participant_id } };
      }
      if (data.subscription_id) {
        logData.subscription = { connect: { id: data.subscription_id } };
      }
      if (data.invoice_id) {
        logData.invoice = { connect: { id: data.invoice_id } };
      }

      // For client (portal) requests, persist the actor on the FK column too so the admin UI can filter by participant_id.
      if (context.participantId && !context.userId && !logData.participant) {
        logData.participant = { connect: { id: context.participantId } };
      }

      // Create the log entry
      const createdLog = await this.prisma.client.systemLog.create({
        data: logData,
      });

      // Stash IDs so we can update status_code after response completes.
      if (createdLog?.id) {
        this.pendingStatusCodeLogIds.push(createdLog.id);
      }

      // Debug log to confirm successful creation with verification
      this.logger.debug(
        `System log created successfully: id=${createdLog.id}, ${data.entity_type} ${data.operation_type}, entity_id=${data.entity_id}, user_id=${createdLog.user_id || 'NULL'}, has_old_data=${!!createdLog.old_data}, has_new_data=${!!createdLog.new_data}`,
      );

      if (data.status_code === 500 && createdLog.id) {
        void this.http500Notification.notifyForLogIds([createdLog.id]).catch((err) => {
          this.logger.warn(
            `HTTP 500 system log email notification failed: ${err instanceof Error ? err.message : String(err)}`,
          );
        });
      }
    } catch (error) {
      // Log error but don't throw - system logging should not break the main flow
      this.logger.error(`Failed to create system log: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined);
      // Also log the data that failed to be logged for debugging
      this.logger.error(`Failed log data: ${JSON.stringify({ entity_type: data.entity_type, operation_type: data.operation_type, entity_id: data.entity_id })}`);
    }
  }

  /**
   * Called by an interceptor after the handler completes.
   * Stamps `status_code`/`response_time_ms` into system_log rows that were created during this request
   * but don't yet have those fields populated.
   */
  async applyResponseStatusToPendingLogs(statusCode: number, responseTimeMs?: number): Promise<void> {
    if (!this.pendingStatusCodeLogIds.length) return;

    const ids = [...this.pendingStatusCodeLogIds];
    this.pendingStatusCodeLogIds.length = 0; // clear for safety in case interceptor runs more than once

    try {
      await this.prisma.client.systemLog.updateMany({
        where: {
          id: { in: ids },
          status_code: null,
        },
        data: {
          status_code: statusCode,
          response_time_ms: responseTimeMs ?? undefined,
        },
      });

      if (statusCode === 500) {
        void this.http500Notification.notifyForLogIds(ids).catch((err) => {
          this.logger.warn(
            `HTTP 500 system log email notification failed: ${err instanceof Error ? err.message : String(err)}`,
          );
        });
      }
    } catch (error) {
      this.logger.warn(`Failed to apply response status to pending system logs: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  /**
   * Log an INSERT operation
   */
  async logInsert(
    entity_type: SystemLogEntityType,
    entity_id: number,
    new_data?: Record<string, unknown>,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.INSERT,
      entity_id,
      new_data,
      ...additionalData,
    });
  }

  /**
   * Log an UPDATE operation
   */
  async logUpdate(
    entity_type: SystemLogEntityType,
    entity_id: number,
    old_data?: Record<string, unknown>,
    new_data?: Record<string, unknown>,
    changed_fields?: Record<string, unknown>,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.UPDATE,
      entity_id,
      old_data,
      new_data,
      changed_fields,
      ...additionalData,
    });
  }

  /**
   * Log a DELETE operation
   */
  async logDelete(
    entity_type: SystemLogEntityType,
    entity_id: number,
    old_data?: Record<string, unknown>,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.DELETE,
      entity_id,
      old_data,
      ...additionalData,
    });
  }

  /**
   * SELECT/READ operations are not persisted to system_log (by policy).
   */
  async logSelect(
    entity_type: SystemLogEntityType,
    entity_id?: number,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.SELECT,
      entity_id,
      ...additionalData,
    });
  }

  /**
   * Log a LOGIN operation
   */
  async logLogin(
    user_id: number,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type: SystemLogEntityType.USER,
      operation_type: SysmtemLogOperationType.LOGIN,
      entity_id: user_id,
      ...additionalData,
    });
  }

  /**
   * Log a LOGOUT operation
   */
  async logLogout(
    user_id: number,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type: SystemLogEntityType.USER,
      operation_type: SysmtemLogOperationType.LOGOUT,
      entity_id: user_id,
      ...additionalData,
    });
  }

  /**
   * Log an EXPORT operation
   */
  async logExport(
    entity_type: SystemLogEntityType,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.EXPORT,
      ...additionalData,
    });
  }

  /**
   * Log an IMPORT operation
   */
  async logImport(
    entity_type: SystemLogEntityType,
    additionalData?: Partial<CreateSystemLogData>,
  ): Promise<void> {
    await this.createLog({
      entity_type,
      operation_type: SysmtemLogOperationType.IMPORT,
      ...additionalData,
    });
  }

  /**
   * Extract request context from both admin and client contexts
   */
  private extractRequestContext(): {
    userId?: number;
    participantId?: number; // For client requests - the participant making the request
    ipAddress?: string;
    userAgent?: string;
    endpoint?: string;
    method?: string;
  } {
    const context: {
      userId?: number;
      participantId?: number; // For client requests - the participant making the request
      ipAddress?: string;
      userAgent?: string;
      endpoint?: string;
      method?: string;
    } = {};

    // Try to get admin context (BNestAppRequestContext)
    try {
      const adminContext = RequestContext.get<BNestAppRequestContext>();
      if (adminContext) {
        if (adminContext.userLoggedIn) {
          context.userId = adminContext.userLoggedIn.id;
        }
        if (adminContext.clientIp) {
          context.ipAddress = adminContext.clientIp;
        }
        if (adminContext.requestUrl) {
          context.endpoint = adminContext.requestUrl;
        }
      }
    } catch {
      // Admin context not available, continue to check client context
      this.logger.debug("Admin context not available for system log");
    }

    // Try to get client context (CLRequestContext)
    try {
      const clientContext = getCLRequestContext();
      if (clientContext) {
        // For client context, we have participantLoggedIn (the participant making the request)
        // This is the participant who performed the action, not the entity being logged
        if (clientContext.participantLoggedIn) {
          context.participantId = clientContext.participantLoggedIn.id;
        }
        if (clientContext.requestUrl && !context.endpoint) {
          context.endpoint = clientContext.requestUrl;
        }
      }
    } catch {
      // Client context not available
      this.logger.debug("Client context not available for system log");
    }

    // Get user agent, method, IP, and endpoint from request object if available
    // This is the most reliable source when REQUEST injection works
    if (this.request) {
      // User agent
      if (!context.userAgent) {
        context.userAgent = this.request.headers["user-agent"] || undefined;
      }

      // HTTP method
      if (!context.method) {
        context.method = this.request.method;
      }

      // Endpoint/URL
      if (!context.endpoint) {
        context.endpoint = this.request.originalUrl || this.request.url || undefined;
      }

      // IP address - try multiple sources
      if (!context.ipAddress) {
        // Priority: X-Forwarded-For > X-Real-IP > req.ip > req.socket.remoteAddress
        const forwardedFor = this.request.headers["x-forwarded-for"] as string | undefined;
        if (forwardedFor) {
          // X-Forwarded-For can contain multiple IPs: "client_ip, proxy1_ip, proxy2_ip"
          const ips = forwardedFor.split(",").map((ip) => ip.trim());
          // Filter out Docker/internal IPs
          const publicIp = ips.find((ip) => {
            if (!ip) return false;
            if (
              ip.startsWith("172.") ||
              ip.startsWith("192.168.") ||
              ip.startsWith("10.") ||
              ip.startsWith("127.")
            ) {
              return false;
            }
            return true;
          });
          context.ipAddress = publicIp || ips[0] || undefined;
        }

        // Fallback to X-Real-IP
        if (!context.ipAddress) {
          context.ipAddress = (this.request.headers["x-real-ip"] as string) || undefined;
        }

        // Fallback to req.ip
        if (!context.ipAddress && this.request.ip) {
          context.ipAddress = this.request.ip;
        }

        // Last resort: socket remote address
        if (!context.ipAddress && this.request.socket?.remoteAddress) {
          const remoteAddr = this.request.socket.remoteAddress;
          // Only use if it's not a Docker/internal IP
          if (
            !remoteAddr.startsWith("172.") &&
            !remoteAddr.startsWith("192.168.") &&
            !remoteAddr.startsWith("10.") &&
            !remoteAddr.startsWith("127.")
          ) {
            context.ipAddress = remoteAddr;
          }
        }
      }
    } else {
      this.logger.warn("Request object not available in SystemLogService - some context may be missing");
    }

    return context;
  }

  /**
   * Set the appropriate entity foreign key based on entity_type
   * Note: We only use relation connect syntax - Prisma automatically sets the foreign key
   */
  private setEntityForeignKey(
    logData: Prisma.SystemLogCreateInput,
    entity_type: SystemLogEntityType,
    entity_id: number,
  ): void {
    switch (entity_type) {
      case SystemLogEntityType.COMPANY:
        logData.company = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.PARTICIPANT:
        logData.participant = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.USER:
        // User is already set via user relation in createLog method
        break;
      case SystemLogEntityType.COURSE:
        logData.course = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.TESTIMONIAL:
        // Note: Testimonial relation might not exist in SystemLog, store in new_data if needed
        this.logger.debug(`Testimonial entity_id ${entity_id} - no direct relation in SystemLog`);
        break;
      case SystemLogEntityType.COURSE_MODULE:
        logData.courseModule = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.COURSE_MODULE_PAGE:
        logData.courseModulePage = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.LEARNING_GROUP:
        logData.learningGroup = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.ASSESSMENT:
        logData.assessment = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.KNOWLEDGE_REVIEW:
        logData.knowledgeReview = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.PARTICIPANT_GROUP:
        logData.participantGroup = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.SUBSCRIPTION:
        logData.subscription = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.INVOICE:
        logData.invoice = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.PACKAGE:
        logData.package = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.ROLE:
        logData.role = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.PERMISSION:
        logData.permission = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.SYSTEM_MODULE:
        logData.systemModule = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.EMAIL_TEMPLATE:
        logData.emailTemplate = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.EMAIL_LOG:
        logData.emailLog = { connect: { id: entity_id } };
        break;
      case SystemLogEntityType.MEDIA:
        logData.media = { connect: { id: entity_id } };
        break;
      default:
        this.logger.warn(`Unknown entity type: ${entity_type}, entity_id not set`);
    }
  }

  /**
   * Helper method to calculate changed fields between old and new data
   */
  static calculateChangedFields(
    oldData: Record<string, unknown>,
    newData: Record<string, unknown>,
  ): Record<string, unknown> {
    const changed: Record<string, unknown> = {};

    // Check for changed or new fields
    for (const key in newData) {
      if (oldData[key] !== newData[key]) {
        changed[key] = {
          old: oldData[key],
          new: newData[key],
        };
      }
    }

    // Check for deleted fields
    for (const key in oldData) {
      if (!(key in newData)) {
        changed[key] = {
          old: oldData[key],
          new: null,
        };
      }
    }

    return changed;
  }
}

results matching ""

    No results matching ""