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 = { 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; const newCtx = (payload.context || {}) as Record; 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(); }