466 lines
20 KiB
TypeScript
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();
|
|
}
|