apps/recallassess/recallassess-api/src/api/shared/services/system-log.service.ts
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)
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, http500Notification: SystemLogHttp500NotificationService, request?: Request)
|
||||||||||||
|
Parameters :
|
| Async applyResponseStatusToPendingLogs |
applyResponseStatusToPendingLogs(statusCode: number, responseTimeMs?: number)
|
|
Called by an interceptor after the handler completes.
Stamps
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 :
Returns :
Record<string, unknown>
|
| Async createLog | ||||||
createLog(data: CreateSystemLogData)
|
||||||
|
Create a system log entry Automatically extracts request context (user, IP, user agent, endpoint, method)
Parameters :
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 :
Returns :
Promise<void>
|
| Async logExport | |||||||||
logExport(entity_type: SystemLogEntityType, additionalData?: Partial<CreateSystemLogData>)
|
|||||||||
|
Log an EXPORT operation
Parameters :
Returns :
Promise<void>
|
| Async logImport | |||||||||
logImport(entity_type: SystemLogEntityType, additionalData?: Partial<CreateSystemLogData>)
|
|||||||||
|
Log an IMPORT operation
Parameters :
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 :
Returns :
Promise<void>
|
| Async logLogin | |||||||||
logLogin(user_id: number, additionalData?: Partial<CreateSystemLogData>)
|
|||||||||
|
Log a LOGIN operation
Parameters :
Returns :
Promise<void>
|
| Async logLogout | |||||||||
logLogout(user_id: number, additionalData?: Partial<CreateSystemLogData>)
|
|||||||||
|
Log a LOGOUT operation
Parameters :
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 :
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 :
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 :
Returns :
void
|
| 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;
}
}