Dealer_Onboarding_Backend/src/modules/audit/audit.controller.ts

466 lines
20 KiB
TypeScript

import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { AuditLog, User } = db;
import { AuthRequest } from '../../types/express.types.js';
import { resolveEntityUuidByType, normalizeRequestType } from '../../common/utils/requestResolver.js';
// Human-readable descriptions for audit actions
const ACTION_DESCRIPTIONS: Record<string, string> = {
CREATED: 'Record created',
UPDATED: 'Record updated',
APPROVED: 'Approval',
REJECTED: 'Rejected',
DELETED: 'Record deleted',
LOGIN: 'User logged in',
LOGOUT: 'User logged out',
REGISTERED: 'User registered',
PASSWORD_CHANGED: 'Password changed',
PROFILE_UPDATED: 'Profile updated',
STAGE_CHANGED: 'Stage changed',
SHORTLISTED: 'Application shortlisted',
DISQUALIFIED: 'Application disqualified',
QUESTIONNAIRE_SUBMITTED: 'Questionnaire response submitted',
QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant',
DOCUMENT_UPLOADED: 'Document uploaded',
DOCUMENT_VERIFIED: 'Document verified',
DOCUMENT_REJECTED: 'Document rejected',
WORKNOTE_ADDED: 'Work note added',
ATTACHMENT_UPLOADED: 'Attachment uploaded',
PARTICIPANT_ADDED: 'Participant added',
PARTICIPANT_REMOVED: 'Participant removed',
INTERVIEW_SCHEDULED: 'Interview scheduled',
INTERVIEW_UPDATED: 'Interview updated',
INTERVIEW_COMPLETED: 'Interview completed',
KT_MATRIX_SUBMITTED: 'KT Matrix score submitted',
FEEDBACK_SUBMITTED: 'Interview feedback submitted',
RECOMMENDATION_UPDATED: 'Recommendation updated',
DECISION_MADE: 'Interview decision made',
FDD_ASSIGNED: 'FDD agency assigned',
FDD_REPORT_UPLOADED: 'FDD report uploaded',
LOI_REQUESTED: 'LOI requested',
LOI_APPROVED: 'LOI approved',
LOI_REJECTED: 'LOI rejected',
LOI_GENERATED: 'LOI document generated',
LOA_REQUESTED: 'LOA requested',
LOA_APPROVED: 'LOA approved',
LOA_GENERATED: 'LOA document generated',
EOR_CHECKLIST_CREATED: 'EOR checklist initiated',
EOR_ITEM_UPDATED: 'EOR checklist item updated',
EOR_AUDIT_SUBMITTED: 'EOR audit submitted',
DEALER_CREATED: 'Dealer profile created',
DEALER_UPDATED: 'Dealer profile updated',
DEALER_CODE_GENERATED: 'Dealer code generated',
PAYMENT_UPDATED: 'Payment record updated',
SECURITY_DEPOSIT_UPDATED: 'Security deposit updated',
FNF_UPDATED: 'F&F settlement updated',
CLEARANCE_UPDATED: 'Departmental clearance response recorded',
STAKEHOLDER_CLEARANCE_UPDATED: 'F&F stakeholder clearance synced',
USER_CREATED: 'User account created',
USER_UPDATED: 'User account updated',
USER_STATUS_CHANGED: 'User status changed',
ROLE_CREATED: 'Role created',
ROLE_UPDATED: 'Role updated',
RESIGNATION_SUBMITTED: 'Resignation submitted',
RESIGNATION_APPROVED: 'Resignation approved',
RESIGNATION_REJECTED: 'Resignation rejected',
RESIGNATION_REVOKED: 'Resignation request revoked',
RESIGNATION_SENT_BACK: 'Resignation sent back for clarification',
TERMINATION_REVOKED: 'Termination request revoked',
TERMINATION_SENT_BACK: 'Termination sent back for clarification',
RELOCATION_SENT_BACK: 'Relocation sent back',
RELOCATION_REVOKED: 'Relocation revoked',
CONSTITUTIONAL_SENT_BACK: 'Constitutional change sent back',
CONSTITUTIONAL_REVOKED: 'Constitutional change revoked',
EMAIL_SENT: 'Email notification sent',
REMINDER_SENT: 'Reminder sent',
FDD_FLAGGED_NON_RESPONSIVE: 'APPLICANT FLAGGED: Non-responsive to audit queries'
};
const isIdleParallelStatus = (v: unknown) => {
const s = String(v ?? '')
.trim()
.toUpperCase();
return !s || s === 'PENDING' || s === 'NULL' || s === 'NOT_STARTED';
};
/** Readable copy for onboarding status transitions (avoids repeating unchanged parallel tracks). */
function buildFriendlyApplicationUpdatedDescription(logData: any, payload: any): string {
const oldD = logData.oldData || {};
const oldCtx = (oldD.context || {}) as Record<string, unknown>;
const newCtx = (payload.context || {}) as Record<string, unknown>;
const pipeline = payload.pipelineStage as string | undefined;
const status = payload.status as string | undefined;
const oldStatus = oldD.status as string | undefined;
const reason = payload.reason != null ? String(payload.reason).trim() : '';
const parts: string[] = [];
if (pipeline) {
parts.push(`Onboarding progressed to ${pipeline}`);
if (oldStatus && status && oldStatus !== status) {
parts.push(`Overall status: ${oldStatus}${status}`);
}
} else if (status && oldStatus && oldStatus !== status) {
parts.push(`Application status: ${oldStatus}${status}`);
} else if (status) {
parts.push(`Application status: ${status}`);
} else {
parts.push('Application updated');
}
const statChanged = oldCtx.statutoryStatus !== newCtx.statutoryStatus;
const archChanged = oldCtx.architectureStatus !== newCtx.architectureStatus;
if (statChanged) {
parts.push(
`Statutory: ${oldCtx.statutoryStatus ?? '—'}${newCtx.statutoryStatus ?? '—'}`
);
} else if (!isIdleParallelStatus(newCtx.statutoryStatus) && String(newCtx.statutoryStatus)) {
parts.push(`Statutory: ${newCtx.statutoryStatus}`);
}
if (archChanged) {
parts.push(
`Architecture: ${oldCtx.architectureStatus ?? '—'}${newCtx.architectureStatus ?? '—'}`
);
} else if (!isIdleParallelStatus(newCtx.architectureStatus) && String(newCtx.architectureStatus)) {
parts.push(`Architecture: ${newCtx.architectureStatus}`);
}
const src = payload.transitionSource as string | undefined;
if (src && !/WorkflowService\.transitionApplication/i.test(src) && !/^WorkflowService$/i.test(src.trim())) {
parts.push(`Note: ${src}`);
}
if (reason && !/^Transitioned to\b/i.test(reason) && reason.length < 200) {
parts.push(reason);
}
return parts.join(' · ');
}
function buildFriendlyInterviewUpdatedDescription(logData: any, payload: any): string {
const eventType = String(payload?.eventType || '').toLowerCase();
const oldData = logData?.oldData || {};
const oldSchedule = oldData?.scheduleDate ? new Date(oldData.scheduleDate).toLocaleString() : null;
const newSchedule = payload?.scheduleDate ? new Date(payload.scheduleDate).toLocaleString() : null;
const level = payload?.interviewLevel ? `Level ${payload.interviewLevel}` : 'Interview';
if (eventType === 'interview_cancelled') {
return `${level} interview cancelled`;
}
if (eventType === 'interview_rescheduled') {
if (oldSchedule && newSchedule && oldSchedule !== newSchedule) {
return `${level} interview rescheduled from ${oldSchedule} to ${newSchedule}`;
}
if (newSchedule) {
return `${level} interview rescheduled to ${newSchedule}`;
}
return `${level} interview rescheduled`;
}
return `${level} interview updated`;
}
const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => {
const payload = logData.details || logData.newData || {};
const actorName = logData.user?.fullName || logData.userName || 'System';
const action = logData.action || 'UPDATED';
const et = String(entityType || '').toLowerCase();
let description = ACTION_DESCRIPTIONS[action] ||
String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ');
if (et === 'application' && action === 'UPDATED') {
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
} else if (et === 'application' && action === 'INTERVIEW_UPDATED') {
description = buildFriendlyInterviewUpdatedDescription(logData, payload);
} else {
if (payload?.stage) description += ` - Stage: ${payload.stage}`;
else if (payload?.department) description += ` - ${payload.department}`;
else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`;
if (payload?.transitionSource && et !== 'application') {
description += ` · Source: ${payload.transitionSource}`;
}
if (payload?.pipelineStage && et === 'application' && action !== 'UPDATED') {
description += ` · Pipeline: ${payload.pipelineStage}`;
}
}
if (payload?.documentType && action === 'DOCUMENT_UPLOADED') {
description += ` · ${payload.documentType}`;
}
if (payload?.paymentType && action === 'PAYMENT_UPDATED') {
description += ` · ${payload.paymentType}`;
}
return {
id: logData.id,
action,
description,
entityType,
entityId,
stage:
payload?.pipelineStage ||
payload?.stage ||
payload?.targetStage ||
(payload?.context as any)?.currentStage ||
null,
actor: {
id: logData.userId || logData.actorId || logData.user?.id || null,
name: actorName,
email: logData.user?.email || logData.userEmail || null
},
actorId: logData.userId || logData.actorId || logData.user?.id || null,
userName: actorName,
userEmail: logData.user?.email || logData.userEmail || null,
remarks: logData.remarks || payload?.remarks || '',
newData: logData.newData ?? payload,
details: payload,
oldData: logData.oldData ?? null,
timestamp: logData.createdAt || logData.timestamp
};
};
/**
* Get audit logs for a specific entity (e.g., application)
* Query params: entityType, entityId, page, limit
*/
export const getAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { entityType, entityId, page = '1', limit = '50' } = req.query;
console.log(`[AuditController] Fetching logs for ${entityType} ID: ${entityId}`);
if (!entityType || !entityId) {
return res.status(400).json({
success: false,
message: 'entityType and entityId are required'
});
}
const pageNum = Math.max(1, parseInt(page as string));
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string)));
const offset = (pageNum - 1) * limitNum;
let count = 0;
let logs: any[] = [];
// Dynamic Table Switching based on Module
// Case-insensitive entity type routing
const type = normalizeRequestType(entityType as string);
const { resolvedId } = await resolveEntityUuidByType(db as any, entityId as string, type);
if (type === 'resignation') {
const result = await db.ResignationAudit.findAndCountAll({
where: { resignationId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
} else if (type === 'termination') {
const result = await db.TerminationAudit.findAndCountAll({
where: { terminationRequestId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
} else if (type === 'fnf') {
const result = await db.FnFAudit.findAndCountAll({
where: { fnfId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
} else if (type === 'constitutional') {
const result = await db.ConstitutionalAudit.findAndCountAll({
where: { constitutionalChangeId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
} else if (type === 'relocation') {
const result = await db.RelocationAudit.findAndCountAll({
where: { relocationRequestId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
} else {
console.log(`[AuditController] Falling back to global AuditLog for type: ${type}`);
const result = await db.AuditLog.findAndCountAll({
where: { entityType: entityType as string, entityId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']],
limit: limitNum, offset
});
count = result.count;
logs = result.rows;
}
console.log(`[AuditController] Found ${count} logs for ${entityType}`);
// Format the response with human-readable descriptions and consistent mapping
const formattedLogs = logs.map((log: any) => {
const logData = log.get ? log.get({ plain: true }) : log;
if (logData.details?.statutoryStatus === 'Flagged') {
logData.action = 'FDD_FLAGGED_NON_RESPONSIVE';
}
return getNormalizedAuditPayload(logData, entityType as string, entityId as string);
});
res.json({
success: true,
data: formattedLogs,
pagination: {
total: count,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(count / limitNum)
}
});
} catch (error) {
console.error('Get audit logs error:', error);
res.status(500).json({ success: false, message: 'Error fetching audit logs' });
}
};
/**
* Get audit log summary/stats for an entity
*/
export const getAuditSummary = async (req: AuthRequest, res: Response) => {
try {
const { entityType, entityId } = req.query;
if (!entityType || !entityId) {
return res.status(400).json({
success: false,
message: 'entityType and entityId are required'
});
}
let totalLogs = 0;
let latestLog: any = null;
const type = normalizeRequestType(entityType as string);
const { resolvedId } = await resolveEntityUuidByType(db as any, entityId as string, type);
// Dynamic Table Switching
if (type === 'resignation') {
totalLogs = await db.ResignationAudit.count({ where: { resignationId: resolvedId } });
latestLog = await db.ResignationAudit.findOne({
where: { resignationId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
} else if (type === 'termination') {
totalLogs = await db.TerminationAudit.count({ where: { terminationRequestId: resolvedId } });
latestLog = await db.TerminationAudit.findOne({
where: { terminationRequestId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
} else if (type === 'fnf') {
totalLogs = await db.FnFAudit.count({ where: { fnfId: resolvedId } });
latestLog = await db.FnFAudit.findOne({
where: { fnfId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
} else if (type === 'constitutional') {
totalLogs = await db.ConstitutionalAudit.count({ where: { constitutionalChangeId: resolvedId } });
latestLog = await db.ConstitutionalAudit.findOne({
where: { constitutionalChangeId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
} else if (type === 'relocation') {
totalLogs = await db.RelocationAudit.count({ where: { relocationRequestId: resolvedId } });
latestLog = await db.RelocationAudit.findOne({
where: { relocationRequestId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
} else {
totalLogs = await db.AuditLog.count({ where: { entityType: entityType as string, entityId: resolvedId } });
latestLog = await db.AuditLog.findOne({
where: { entityType: entityType as string, entityId: resolvedId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']]
});
}
res.json({
success: true,
data: {
totalEntries: totalLogs,
lastActivity: latestLog ? {
action: (latestLog as any).action,
description: ACTION_DESCRIPTIONS[(latestLog as any).action] || (latestLog as any).action.split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '),
user: (latestLog as any).user?.fullName || 'System',
timestamp: (latestLog as any).createdAt
} : null
}
});
} catch (error) {
console.error('Get audit summary error:', error);
res.status(500).json({ success: false, message: 'Error fetching audit summary' });
}
};
/**
* Format changes between old and new data into a readable list
*/
function formatChanges(oldData: any, newData: any): string[] {
const changes: string[] = [];
if (!oldData && newData) {
// New record created
if (typeof newData === 'object') {
Object.entries(newData).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
changes.push(`${formatFieldName(key)}: ${value}`);
}
});
}
return changes;
}
if (oldData && newData && typeof oldData === 'object' && typeof newData === 'object') {
// Compare old vs new
const allKeys = new Set([...Object.keys(oldData), ...Object.keys(newData)]);
allKeys.forEach(key => {
const oldVal = oldData[key];
const newVal = newData[key];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
if (oldVal !== undefined && newVal !== undefined) {
changes.push(`${formatFieldName(key)}: "${oldVal}" → "${newVal}"`);
} else if (newVal !== undefined) {
changes.push(`${formatFieldName(key)} set to "${newVal}"`);
}
}
});
}
return changes;
}
/**
* Convert camelCase field names to human-readable format
*/
function formatFieldName(field: string): string {
return field
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}