system log table added and feew bugs coverd from the tracker

This commit is contained in:
laxman H 2026-05-13 20:43:59 +05:30
parent eeae163782
commit fb07f7ab61
14 changed files with 949 additions and 40 deletions

View File

@ -1,28 +1,30 @@
version: '3.8'
services: services:
# Redis - Required for BullMQ background jobs (notifications, SLAs) # Redis - Required for BullMQ background jobs (notifications, SLAs)
# Uses host networking so Compose does not create a bridge (avoids DOCKER-FORWARD / iptables failures on some hosts).
# Redis listens on the host on port 6379 (same as REDIS_PORT in .env).
redis: redis:
image: redis:7.2-alpine image: redis:7.2-alpine
container_name: re-onboarding-redis container_name: re-onboarding-redis
restart: always restart: always
ports: network_mode: host
- "6379:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
command: redis-server --save 60 1 --loglevel warning command: redis-server --save 60 1 --loglevel warning
# RedisInsight - GUI for monitoring Redis/BullMQ (Optional) # RedisInsight - GUI for monitoring Redis/BullMQ (Optional)
# Connect in the UI to host.docker.internal:6379 (extra_hosts maps that to the host where Redis runs).
redis-insight: redis-insight:
image: redislabs/redisinsight:latest image: redislabs/redisinsight:latest
container_name: re-onboarding-redis-insight container_name: re-onboarding-redis-insight
restart: always restart: always
ports: ports:
- "8001:8001" - "8001:8001"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
- redis - redis
# PostgreSQL - Application Database # PostgreSQL - Uses the default bridge; `docker compose up` (full stack) still needs iptables/Docker networking working.
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: re-onboarding-db container_name: re-onboarding-db

View File

@ -0,0 +1,40 @@
/**
* Create System Audit Log Table
*
* Bootstraps the new `system_audit_logs` table on environments where the
* full `migrate.ts` (sequelize.sync({ force: true })) cannot be run because
* the database already holds production / shared data.
*
* Safe to re-run: uses `SystemAuditLog.sync()` (no `force`, no `alter`),
* which is a no-op once the table exists.
*
* Run: npx tsx scripts/create-system-audit-log-table.ts
*/
import 'dotenv/config';
import db from '../src/database/models/index.js';
async function run() {
console.log('🔄 Ensuring system_audit_logs table exists...');
try {
await db.sequelize.authenticate();
console.log('📡 Database connection OK');
await db.SystemAuditLog.sync();
const [rows] = await db.sequelize.query(
`SELECT COUNT(*)::int AS total FROM system_audit_logs`
);
const total = (rows as any[])[0]?.total ?? 0;
console.log('✅ system_audit_logs is ready');
console.log(` Existing rows: ${total}`);
process.exit(0);
} catch (err: any) {
console.error('❌ Failed to ensure system_audit_logs table:', err.message || err);
if (err.stack) console.error(err.stack);
process.exit(1);
}
}
run();

View File

@ -426,6 +426,34 @@ export const AUDIT_ACTIONS = {
REMINDER_SENT: 'REMINDER_SENT' REMINDER_SENT: 'REMINDER_SENT'
} as const; } as const;
// System / Configuration audit modules — written to `system_audit_logs`,
// kept separate from per-application audit so config changes can be filtered
// without scanning the much larger application audit volume.
export const SYSTEM_AUDIT_MODULES = {
QUESTIONNAIRE: 'QUESTIONNAIRE',
INTERVIEW_CONFIG: 'INTERVIEW_CONFIG',
SYSTEM_CONFIG: 'SYSTEM_CONFIG',
SLA_CONFIG: 'SLA_CONFIG',
EMAIL_TEMPLATE: 'EMAIL_TEMPLATE',
MASTER_HIERARCHY: 'MASTER_HIERARCHY',
ROLE_ASSIGNMENT: 'ROLE_ASSIGNMENT',
USER_ADMIN: 'USER_ADMIN',
DEALER_MAPPING: 'DEALER_MAPPING'
} as const;
export const SYSTEM_AUDIT_ACTIONS = {
CREATED: 'CREATED',
UPDATED: 'UPDATED',
DELETED: 'DELETED',
ACTIVATED: 'ACTIVATED',
DEACTIVATED: 'DEACTIVATED',
INITIALIZED: 'INITIALIZED',
SUBMITTED: 'SUBMITTED',
ASSIGNED: 'ASSIGNED',
UNASSIGNED: 'UNASSIGNED',
REORDERED: 'REORDERED'
} as const;
// Document Types // Document Types
export const DOCUMENT_TYPES = { export const DOCUMENT_TYPES = {
GST_CERTIFICATE: 'GST Certificate', GST_CERTIFICATE: 'GST Certificate',

View File

@ -0,0 +1,127 @@
import { Model, DataTypes, Sequelize } from 'sequelize';
/**
* SystemAuditLog
* ----------------
* Dedicated, segregated audit trail for *system-level / configuration* changes
* (questionnaire versions, interview configs, master data zones / regions /
* districts, SLA configs, system configs, role assignments, hierarchy syncs).
*
* Application-lifecycle audit (per-application stage transitions, documents,
* interviews) continues to live in `audit_logs`. Module-specific tables
* (`resignation_audit_logs`, `termination_audit_logs`, `fnf_audit_logs`,
* `relocation_audit_logs`, `constitutional_audit_logs`) remain unchanged.
*/
export interface SystemAuditLogAttributes {
id: string;
userId: string | null;
actorName: string | null;
actorRole: string | null;
module: string;
entityType: string;
entityId: string | null;
entityLabel: string | null;
action: string;
description: string | null;
oldData: any | null;
newData: any | null;
metadata: any | null;
ipAddress: string | null;
userAgent: string | null;
}
export interface SystemAuditLogInstance
extends Model<SystemAuditLogAttributes>,
SystemAuditLogAttributes { }
export default (sequelize: Sequelize) => {
const SystemAuditLog = sequelize.define<SystemAuditLogInstance>(
'SystemAuditLog',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
userId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'id' },
onDelete: 'SET NULL'
},
actorName: {
type: DataTypes.STRING(150),
allowNull: true
},
actorRole: {
type: DataTypes.STRING(80),
allowNull: true
},
module: {
type: DataTypes.STRING(60),
allowNull: false
},
entityType: {
type: DataTypes.STRING(80),
allowNull: false
},
entityId: {
type: DataTypes.STRING(80),
allowNull: true
},
entityLabel: {
type: DataTypes.STRING(255),
allowNull: true
},
action: {
type: DataTypes.STRING(60),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
oldData: {
type: DataTypes.JSON,
allowNull: true
},
newData: {
type: DataTypes.JSON,
allowNull: true
},
metadata: {
type: DataTypes.JSON,
allowNull: true
},
ipAddress: {
type: DataTypes.STRING(64),
allowNull: true
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true
}
},
{
tableName: 'system_audit_logs',
timestamps: true,
indexes: [
{ fields: ['module'] },
{ fields: ['entityType'] },
{ fields: ['entityId'] },
{ fields: ['action'] },
{ fields: ['userId'] },
{ fields: ['createdAt'] }
]
}
);
(SystemAuditLog as any).associate = (models: any) => {
SystemAuditLog.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user'
});
};
return SystemAuditLog;
};

View File

@ -102,6 +102,7 @@ import createRequestParticipant from './compliance/RequestParticipant.js';
// Activity // Activity
import createAuditLog from './activity/AuditLog.js'; import createAuditLog from './activity/AuditLog.js';
import createSystemAuditLog from './activity/SystemAuditLog.js';
import createWorknote from './activity/Worknote.js'; import createWorknote from './activity/Worknote.js';
import createWorkNoteAttachment from './activity/WorkNoteAttachment.js'; import createWorkNoteAttachment from './activity/WorkNoteAttachment.js';
import createWorkNoteTag from './activity/WorkNoteTag.js'; import createWorkNoteTag from './activity/WorkNoteTag.js';
@ -136,6 +137,7 @@ db.Outlet = createOutlet(sequelize);
db.Worknote = createWorknote(sequelize); db.Worknote = createWorknote(sequelize);
db.OnboardingDocument = createOnboardingDocument(sequelize); db.OnboardingDocument = createOnboardingDocument(sequelize);
db.AuditLog = createAuditLog(sequelize); db.AuditLog = createAuditLog(sequelize);
db.SystemAuditLog = createSystemAuditLog(sequelize);
db.FinancePayment = createFinancePayment(sequelize); db.FinancePayment = createFinancePayment(sequelize);
db.RelocationDocument = createRelocationDocument(sequelize); db.RelocationDocument = createRelocationDocument(sequelize);
db.ResignationDocument = createResignationDocument(sequelize); db.ResignationDocument = createResignationDocument(sequelize);

View File

@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
const router = express.Router(); const router = express.Router();
import * as auditController from './audit.controller.js'; import * as auditController from './audit.controller.js';
import * as systemAuditController from './systemAudit.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
// All audit routes require authentication // All audit routes require authentication
@ -12,4 +13,11 @@ router.get('/logs', auditController.getAuditLogs);
// GET /api/audit/summary?entityType=application&entityId=<uuid> // GET /api/audit/summary?entityType=application&entityId=<uuid>
router.get('/summary', auditController.getAuditSummary); router.get('/summary', auditController.getAuditSummary);
// --- System / configuration audit (segregated table: system_audit_logs) ---
// GET /api/audit/system-logs?module=&entityType=&entityId=&action=&userId=&search=&dateFrom=&dateTo=&page=1&limit=50
router.get('/system-logs', systemAuditController.getSystemAuditLogs);
// GET /api/audit/system-summary
router.get('/system-summary', systemAuditController.getSystemAuditSummary);
export default router; export default router;

View File

@ -0,0 +1,185 @@
import { Response } from 'express';
import { Op } from 'sequelize';
import db from '../../database/models/index.js';
import { AuthRequest } from '../../types/express.types.js';
const { SystemAuditLog, User } = db;
const ACTION_DESCRIPTIONS: Record<string, string> = {
CREATED: 'Created',
UPDATED: 'Updated',
DELETED: 'Deleted',
ACTIVATED: 'Activated',
DEACTIVATED: 'Deactivated',
INITIALIZED: 'Initialized with defaults',
SUBMITTED: 'Submitted',
ASSIGNED: 'Assigned',
UNASSIGNED: 'Unassigned',
REORDERED: 'Reordered'
};
const MODULE_LABELS: Record<string, string> = {
QUESTIONNAIRE: 'Questionnaire',
INTERVIEW_CONFIG: 'Interview Configuration',
SYSTEM_CONFIG: 'System Configuration',
SLA_CONFIG: 'SLA Configuration',
EMAIL_TEMPLATE: 'Email Template',
MASTER_HIERARCHY: 'Master Hierarchy',
ROLE_ASSIGNMENT: 'Role Assignment',
USER_ADMIN: 'User Administration',
DEALER_MAPPING: 'Dealer Mapping'
};
const formatRow = (row: any) => {
const log = row.get ? row.get({ plain: true }) : row;
const actionLabel = ACTION_DESCRIPTIONS[log.action] || log.action;
const moduleLabel = MODULE_LABELS[log.module] || log.module;
const target = log.entityLabel ? ` · ${log.entityLabel}` : '';
const description = log.description || `${moduleLabel}: ${actionLabel}${target}`;
return {
id: log.id,
module: log.module,
moduleLabel,
action: log.action,
actionLabel,
entityType: log.entityType,
entityId: log.entityId,
entityLabel: log.entityLabel,
description,
actor: {
id: log.userId || log.user?.id || null,
name: log.actorName || log.user?.fullName || 'System',
role: log.actorRole || null,
email: log.user?.email || null
},
oldData: log.oldData,
newData: log.newData,
metadata: log.metadata,
ipAddress: log.ipAddress,
userAgent: log.userAgent,
timestamp: log.createdAt
};
};
/**
* GET /api/audit/system-logs
* Filters: module, entityType, entityId, action, userId, search, dateFrom, dateTo
* Pagination: page (default 1), limit (default 50, max 200)
*/
export const getSystemAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const {
module,
entityType,
entityId,
action,
userId,
search,
dateFrom,
dateTo,
page = '1',
limit = '50'
} = req.query as Record<string, string>;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10) || 50));
const offset = (pageNum - 1) * limitNum;
const where: any = {};
if (module) where.module = module;
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (action) where.action = action;
if (userId) where.userId = userId;
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt[Op.gte] = new Date(dateFrom);
if (dateTo) where.createdAt[Op.lte] = new Date(dateTo);
}
if (search) {
where[Op.or] = [
{ entityLabel: { [Op.iLike]: `%${search}%` } },
{ description: { [Op.iLike]: `%${search}%` } },
{ actorName: { [Op.iLike]: `%${search}%` } }
];
}
const { count, rows } = await SystemAuditLog.findAndCountAll({
where,
include: [
{
model: User,
as: 'user',
attributes: ['id', 'fullName', 'email']
}
],
order: [['createdAt', 'DESC']],
limit: limitNum,
offset
});
return res.json({
success: true,
data: rows.map(formatRow),
pagination: {
total: count,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(count / limitNum)
}
});
} catch (error) {
console.error('Get system audit logs error:', error);
return res
.status(500)
.json({ success: false, message: 'Error fetching system audit logs' });
}
};
/**
* GET /api/audit/system-summary
* Returns counts grouped by module + the most recent activity row.
*/
export const getSystemAuditSummary = async (_req: AuthRequest, res: Response) => {
try {
const counts = (await SystemAuditLog.findAll({
attributes: [
'module',
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'total']
],
group: ['module'],
order: [[db.sequelize.literal('total'), 'DESC']]
})) as any[];
const lastRow = await SystemAuditLog.findOne({
include: [{ model: User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']]
});
const total = counts.reduce(
(sum: number, row: any) => sum + Number(row.get('total') || 0),
0
);
return res.json({
success: true,
data: {
totalEntries: total,
byModule: counts.map((row: any) => ({
module: row.module,
moduleLabel: MODULE_LABELS[row.module] || row.module,
total: Number(row.get('total') || 0)
})),
lastActivity: lastRow ? formatRow(lastRow) : null
}
});
} catch (error) {
console.error('Get system audit summary error:', error);
return res
.status(500)
.json({ success: false, message: 'Error fetching system audit summary' });
}
};

View File

@ -1,6 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import {
SYSTEM_AUDIT_MODULES,
SYSTEM_AUDIT_ACTIONS
} from '../../common/config/constants.js';
import { logSystemAudit } from '../../services/systemAuditLog.service.js';
const { InterviewConfig, InterviewConfigItem, InterviewConfigItemOption } = db; const { InterviewConfig, InterviewConfigItem, InterviewConfigItemOption } = db;
@ -134,6 +139,20 @@ export const createInterviewConfig = async (req: Request, res: Response) => {
}] }]
}); });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.INTERVIEW_CONFIG,
entityType: configType,
entityId: config.id,
entityLabel: `${name || `${configType} Config`} (${version || 'v1.0'})`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Published new ${configType} interview config; previous active version deactivated`,
newData: {
configType,
version: version || 'v1.0',
itemCount: Array.isArray(items) ? items.length : 0
}
});
res.status(201).json({ success: true, data: fullConfig }); res.status(201).json({ success: true, data: fullConfig });
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -155,6 +174,12 @@ export const updateInterviewConfig = async (req: Request, res: Response) => {
return res.status(404).json({ success: false, message: 'Configuration not found' }); return res.status(404).json({ success: false, message: 'Configuration not found' });
} }
const previousSnapshot = {
name: config.name,
version: config.version,
isActive: config.isActive
};
await config.update({ name, version, isActive }, { transaction }); await config.update({ name, version, isActive }, { transaction });
// If items are provided, replace them // If items are provided, replace them
@ -197,6 +222,19 @@ export const updateInterviewConfig = async (req: Request, res: Response) => {
}] }]
}); });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.INTERVIEW_CONFIG,
entityType: config.configType,
entityId: id as string,
entityLabel: `${name || config.name} (${version || config.version})`,
action: SYSTEM_AUDIT_ACTIONS.UPDATED,
description: `Updated ${config.configType} interview config${
Array.isArray(items) ? ` and replaced ${items.length} item(s)` : ''
}`,
oldData: previousSnapshot,
newData: { name, version, isActive }
});
res.json({ success: true, data: fullConfig }); res.json({ success: true, data: fullConfig });
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -214,7 +252,25 @@ export const deleteInterviewConfig = async (req: Request, res: Response) => {
return res.status(404).json({ success: false, message: 'Configuration not found' }); return res.status(404).json({ success: false, message: 'Configuration not found' });
} }
const snapshot = {
configType: config.configType,
name: config.name,
version: config.version,
isActive: config.isActive
};
await config.destroy(); await config.destroy();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.INTERVIEW_CONFIG,
entityType: snapshot.configType,
entityId: id as string,
entityLabel: `${snapshot.name} (${snapshot.version})`,
action: SYSTEM_AUDIT_ACTIONS.DELETED,
description: `Deleted interview config ${snapshot.name} (${snapshot.version})`,
oldData: snapshot
});
res.json({ success: true, message: 'Configuration deleted successfully' }); res.json({ success: true, message: 'Configuration deleted successfully' });
} catch (error) { } catch (error) {
console.error('Delete interview config error:', error); console.error('Delete interview config error:', error);
@ -399,6 +455,21 @@ export const initializeDefaultInterviewConfigs = async (req: Request, res: Respo
} }
await transaction.commit(); await transaction.commit();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.INTERVIEW_CONFIG,
entityType: 'BULK_DEFAULTS',
entityId: null,
entityLabel: 'KT_MATRIX + LEVEL2_FEEDBACK + LEVEL3_FEEDBACK',
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
description: 'Initialized default interview configs (KT Matrix, Level 2 Feedback, Level 3 Feedback) — previous active versions were deactivated',
metadata: {
ktMatrixItems: ktDefaults.length,
level2Items: l2Defaults.length,
level3Items: l3Defaults.length
}
});
res.json({ success: true, message: 'Default interview configurations initialized successfully' }); res.json({ success: true, message: 'Default interview configurations initialized successfully' });
} catch (error) { } catch (error) {
console.error('Initialize defaults error:', error); console.error('Initialize defaults error:', error);

View File

@ -2,8 +2,13 @@ import { Request, Response } from 'express';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { syncDistrictsByRegion, syncDistrictsByZone, syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.js'; import { syncDistrictsByRegion, syncDistrictsByZone, syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.js';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
import { ROLES } from '../../common/config/constants.js'; import {
ROLES,
SYSTEM_AUDIT_MODULES,
SYSTEM_AUDIT_ACTIONS
} from '../../common/config/constants.js';
import { resolveManagerCode } from '../../services/userRoleCode.service.js'; import { resolveManagerCode } from '../../services/userRoleCode.service.js';
import { logSystemAudit } from '../../services/systemAuditLog.service.js';
const { User } = db; const { User } = db;
const deriveZonePrefix = (zone: any): string => { const deriveZonePrefix = (zone: any): string => {
@ -263,7 +268,23 @@ export const createDistrict = async (req: Request, res: Response) => {
capacity: 'Standard', capacity: 'Standard',
priority: 'Medium' priority: 'Medium'
}); });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'district',
entityId: district.id,
entityLabel: `District ${district.name}${district.code ? ` (${district.code})` : ''}`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Created district ${district.name}${city ? ` · area ${city}` : ''}`,
newData: {
districtId: district.id,
districtName: district.name,
areaId: area.id,
city: city || name,
isOpportunity: finalIsOpportunity
}
});
res.status(201).json({ success: true, data: area }); res.status(201).json({ success: true, data: area });
} catch (error) { } catch (error) {
console.error('Create area error:', error); console.error('Create area error:', error);
@ -397,6 +418,27 @@ export const createRegion = async (req: Request, res: Response) => {
await syncDistrictsByRegion(region.id); await syncDistrictsByRegion(region.id);
} }
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'region',
entityId: region.id,
entityLabel: `Region ${region.name} (${generatedCode})`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Created region ${region.name} under zone ${targetZoneId}${
Array.isArray(targetDistrictIds) && targetDistrictIds.length > 0
? `; assigned ${targetDistrictIds.length} district(s)`
: ''
}`,
newData: {
regionId: region.id,
regionName: region.name,
regionCode: generatedCode,
zoneId: targetZoneId,
managerId: managerId || null,
districtIds: targetDistrictIds || []
}
});
res.status(201).json({ success: true, message: 'Region created', data: region }); res.status(201).json({ success: true, message: 'Region created', data: region });
} catch (error: any) { } catch (error: any) {
console.error('Create region error:', error); console.error('Create region error:', error);
@ -420,7 +462,13 @@ export const updateRegion = async (req: Request, res: Response) => {
const region = await db.Region.findByPk(id); const region = await db.Region.findByPk(id);
if (!region) return res.status(404).json({ success: false, message: 'Region not found' }); if (!region) return res.status(404).json({ success: false, message: 'Region not found' });
const previousRegionSnapshot = {
name: region.name,
code: region.code,
zoneId: region.zoneId
};
const nextZoneId = targetZoneId || region.zoneId; const nextZoneId = targetZoneId || region.zoneId;
const zoneChanged = Boolean(targetZoneId && targetZoneId !== region.zoneId); const zoneChanged = Boolean(targetZoneId && targetZoneId !== region.zoneId);
const generatedCode = const generatedCode =
@ -489,6 +537,25 @@ export const updateRegion = async (req: Request, res: Response) => {
await syncRegionManager(id as string); await syncRegionManager(id as string);
await syncDistrictsByRegion(id as string); await syncDistrictsByRegion(id as string);
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'region',
entityId: id as string,
entityLabel: `Region ${name || region.name} (${generatedCode})`,
action: SYSTEM_AUDIT_ACTIONS.UPDATED,
description: `Updated region ${name || region.name}${
zoneChanged ? ` (moved to zone ${nextZoneId})` : ''
}${managerId ? ` · manager set to ${managerId}` : ''}`,
oldData: previousRegionSnapshot,
newData: {
name,
code: generatedCode,
zoneId: nextZoneId,
managerId: managerId || null,
districtIds: targetDistrictIds || null
}
});
res.json({ success: true, message: 'Region updated' }); res.json({ success: true, message: 'Region updated' });
} catch (error: any) { } catch (error: any) {
console.error('Update region error:', error); console.error('Update region error:', error);
@ -634,7 +701,25 @@ export const createZone = async (req: Request, res: Response) => {
await db.State.update({ zoneId: zone.id }, { where: { id: { [db.Sequelize.Op.in]: stateIds } } }); await db.State.update({ zoneId: zone.id }, { where: { id: { [db.Sequelize.Op.in]: stateIds } } });
await db.District.update({ zoneId: zone.id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); await db.District.update({ zoneId: zone.id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } });
} }
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'zone',
entityId: zone.id,
entityLabel: `Zone ${name}${code ? ` (${code})` : ''}`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Created zone ${name}${managerId && managerId !== 'none' ? ` · ZBH ${managerId}` : ''}${
Array.isArray(stateIds) && stateIds.length > 0 ? ` · linked ${stateIds.length} state(s)` : ''
}`,
newData: {
zoneId: zone.id,
name,
code,
managerId: managerId && managerId !== 'none' ? managerId : null,
stateIds: stateIds || []
}
});
res.status(201).json({ success: true, message: 'Zone created', data: zone }); res.status(201).json({ success: true, message: 'Zone created', data: zone });
} catch (error) { } catch (error) {
console.error('Create zone error:', error); console.error('Create zone error:', error);
@ -648,7 +733,9 @@ export const updateZone = async (req: Request, res: Response) => {
const { name, code, stateIds, managerId } = req.body; const { name, code, stateIds, managerId } = req.body;
const zone = await db.Zone.findByPk(id); const zone = await db.Zone.findByPk(id);
if (!zone) return res.status(404).json({ success: false, message: 'Zone not found' }); if (!zone) return res.status(404).json({ success: false, message: 'Zone not found' });
const previousZoneSnapshot = { name: zone.name, code: zone.code };
await zone.update({ name, code }); await zone.update({ name, code });
const { syncZoneManager } = await import('./syncHierarchy.service.js'); const { syncZoneManager } = await import('./syncHierarchy.service.js');
@ -713,7 +800,25 @@ export const updateZone = async (req: Request, res: Response) => {
await db.District.update({ zoneId: id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); await db.District.update({ zoneId: id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } });
} }
} }
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'zone',
entityId: id as string,
entityLabel: `Zone ${name || zone.name}${code ? ` (${code})` : ''}`,
action: SYSTEM_AUDIT_ACTIONS.UPDATED,
description: `Updated zone ${name || zone.name}${
managerId && managerId !== 'none' ? ` · ZBH set to ${managerId}` : ''
}${managerId === null || managerId === 'none' ? ' · ZBH cleared' : ''}`,
oldData: previousZoneSnapshot,
newData: {
name,
code,
managerId: managerId === 'none' ? null : managerId ?? undefined,
stateIds: Array.isArray(stateIds) ? stateIds : undefined
}
});
res.json({ success: true, message: 'Zone updated' }); res.json({ success: true, message: 'Zone updated' });
} catch (error) { } catch (error) {
console.error('Update zone error:', error); console.error('Update zone error:', error);
@ -744,7 +849,17 @@ export const createState = async (req: Request, res: Response) => {
if (zoneId) { if (zoneId) {
await db.District.update({ zoneId }, { where: { stateId: state.id } }); await db.District.update({ zoneId }, { where: { stateId: state.id } });
} }
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'state',
entityId: state.id,
entityLabel: `State ${name}`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Created state ${name}${zoneId ? ` under zone ${zoneId}` : ''}`,
newData: { stateId: state.id, name, zoneId: zoneId || null }
});
res.status(201).json({ success: true, data: state }); res.status(201).json({ success: true, data: state });
} catch (error) { } catch (error) {
console.error('Create state error:', error); console.error('Create state error:', error);
@ -794,12 +909,29 @@ export const deleteLocation = async (req: Request, res: Response) => {
const area = await db.Location.findByPk(id); const area = await db.Location.findByPk(id);
if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); if (!area) return res.status(404).json({ success: false, message: 'Area not found' });
const snapshot = {
id: area.id,
name: area.name,
districtId: area.districtId,
city: area.city
};
// Delete associated opportunities if they belong to this granular location // Delete associated opportunities if they belong to this granular location
await db.Opportunity.destroy({ where: { areaId: id } }); await db.Opportunity.destroy({ where: { areaId: id } });
// Delete the location itself // Delete the location itself
await area.destroy(); await area.destroy();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'area',
entityId: id as string,
entityLabel: `Area ${snapshot.name}`,
action: SYSTEM_AUDIT_ACTIONS.DELETED,
description: `Deleted area ${snapshot.name} and any linked opportunities`,
oldData: snapshot
});
res.json({ success: true, message: 'Area deleted successfully' }); res.json({ success: true, message: 'Area deleted successfully' });
} catch (error) { } catch (error) {
console.error('Delete area error:', error); console.error('Delete area error:', error);
@ -817,7 +949,16 @@ export const updateLocation = async (req: Request, res: Response) => {
include: [{ model: db.District, as: 'district' }] include: [{ model: db.District, as: 'district' }]
}); });
if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); if (!area) return res.status(404).json({ success: false, message: 'Area not found' });
const previousAreaSnapshot = {
name: area.name,
districtId: area.districtId,
city: area.city,
isOpportunity: area.isOpportunity,
openFrom: area.openFrom,
openTo: area.openTo
};
let district = area.district; let district = area.district;
if (districtId && (!district || String(district.id) !== String(districtId))) { if (districtId && (!district || String(district.id) !== String(districtId))) {
@ -871,7 +1012,26 @@ export const updateLocation = async (req: Request, res: Response) => {
status: status || opportunity.status || 'inactive' status: status || opportunity.status || 'inactive'
}); });
await opportunity.save(); await opportunity.save();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.MASTER_HIERARCHY,
entityType: 'area',
entityId: id as string,
entityLabel: `Area ${name || area.name}`,
action: SYSTEM_AUDIT_ACTIONS.UPDATED,
description: `Updated area ${name || area.name}`,
oldData: previousAreaSnapshot,
newData: {
name: name || area.name,
districtId: district?.id || area.districtId,
city: city || area.city,
isOpportunity: finalIsOpportunity,
openFrom,
openTo,
status
}
});
res.json({ success: true, message: 'Area updated' }); res.json({ success: true, message: 'Area updated' });
if (district && typeof district.id === 'string') { if (district && typeof district.id === 'string') {
@ -1106,6 +1266,27 @@ export const saveZM = async (req: Request, res: Response) => {
} }
} }
const targetUser = await db.User.findByPk(userId, { attributes: ['fullName', 'email'] });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.ROLE_ASSIGNMENT,
entityType: 'zonal_manager',
entityId: userId,
entityLabel: targetUser?.fullName ? `ZM · ${targetUser.fullName}` : `ZM · ${userId}`,
action: SYSTEM_AUDIT_ACTIONS.ASSIGNED,
description: `Assigned ${targetUser?.fullName || userId} as Zonal Manager${
Array.isArray(regionIds) && regionIds.length > 0 ? ` over ${regionIds.length} region(s)` : zoneId ? ` for zone ${zoneId}` : ''
}`,
newData: {
userId,
userName: targetUser?.fullName,
userEmail: targetUser?.email,
zmCode,
zoneId: zoneId || null,
regionIds: regionIds || [],
status
}
});
res.json({ success: true, message: 'Zonal Manager saved successfully' }); res.json({ success: true, message: 'Zonal Manager saved successfully' });
} catch (error) { } catch (error) {
console.error('Save ZM error:', error); console.error('Save ZM error:', error);
@ -1192,6 +1373,8 @@ export const saveDDLead = async (req: Request, res: Response) => {
where: { userId, roleId: leadRole.id } where: { userId, roleId: leadRole.id }
}); });
const resolvedActive = isActive !== undefined ? (isActive === true || isActive === 'true') : true;
// Create/Activate the single National DD Lead role assignment // Create/Activate the single National DD Lead role assignment
await db.UserRole.create({ await db.UserRole.create({
userId, userId,
@ -1200,10 +1383,23 @@ export const saveDDLead = async (req: Request, res: Response) => {
regionId: null, // Global scope regionId: null, // Global scope
districtId: null, // Global scope districtId: null, // Global scope
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null), managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : true, isActive: resolvedActive,
isPrimary: true isPrimary: true
}); });
const targetUser = await db.User.findByPk(userId, { attributes: ['fullName', 'email'] });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.ROLE_ASSIGNMENT,
entityType: 'dd_lead',
entityId: userId,
entityLabel: targetUser?.fullName ? `DD Lead · ${targetUser.fullName}` : `DD Lead · ${userId}`,
action: resolvedActive ? SYSTEM_AUDIT_ACTIONS.ASSIGNED : SYSTEM_AUDIT_ACTIONS.UNASSIGNED,
description: `${
resolvedActive ? 'Activated' : 'Deactivated'
} national DD Lead role for ${targetUser?.fullName || userId}`,
newData: { userId, leadCode, status, isActive: resolvedActive }
});
res.json({ success: true, message: 'DD Lead updated successfully' }); res.json({ success: true, message: 'DD Lead updated successfully' });
} catch (error) { } catch (error) {
console.error('Save DD Lead error:', error); console.error('Save DD Lead error:', error);
@ -1249,6 +1445,7 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
const { id, key, value, category, description, isActive } = req.body; const { id, key, value, category, description, isActive } = req.body;
let config; let config;
let previousSnapshot: Record<string, unknown> | null = null;
// Use key as the unique identifier if id isn't present // Use key as the unique identifier if id isn't present
if (id) { if (id) {
@ -1257,7 +1454,15 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
config = await db.SystemConfiguration.findOne({ where: { key } }); config = await db.SystemConfiguration.findOne({ where: { key } });
} }
const isUpdate = Boolean(config);
if (config) { if (config) {
previousSnapshot = {
key: config.key,
value: config.value,
category: config.category,
description: config.description,
isActive: config.isActive
};
await config.update({ key, value, category, description, isActive }); await config.update({ key, value, category, description, isActive });
} else { } else {
config = await db.SystemConfiguration.create({ config = await db.SystemConfiguration.create({
@ -1269,6 +1474,17 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
}); });
} }
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.SYSTEM_CONFIG,
entityType: 'system_configuration',
entityId: config.id,
entityLabel: `${category ? `${category} · ` : ''}${key}`,
action: isUpdate ? SYSTEM_AUDIT_ACTIONS.UPDATED : SYSTEM_AUDIT_ACTIONS.CREATED,
description: `${isUpdate ? 'Updated' : 'Created'} system config ${key}${category ? ` (${category})` : ''}`,
oldData: previousSnapshot,
newData: { key, value, category, description, isActive }
});
res.json({ success: true, data: config }); res.json({ success: true, data: config });
} catch (error) { } catch (error) {
console.error('Save system config error:', error); console.error('Save system config error:', error);
@ -1359,8 +1575,22 @@ export const saveDealerAsmMapping = async (req: Request, res: Response) => {
} }
} }
const previousAsmId = dealer.asmId;
await dealer.update({ asmId: asmUser ? asmUser.id : null }); await dealer.update({ asmId: asmUser ? asmUser.id : null });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.DEALER_MAPPING,
entityType: 'dealer_asm_mapping',
entityId: dealer.id,
entityLabel: `Dealer ${dealer.businessName || dealer.legalName || dealer.id}`,
action: asmUser ? SYSTEM_AUDIT_ACTIONS.ASSIGNED : SYSTEM_AUDIT_ACTIONS.UNASSIGNED,
description: asmUser
? `Assigned ASM ${asmUser.fullName || asmUser.id} to dealer ${dealer.businessName || dealer.legalName || dealer.id}`
: `Removed ASM mapping from dealer ${dealer.businessName || dealer.legalName || dealer.id}`,
oldData: { asmId: previousAsmId },
newData: { asmId: asmUser ? asmUser.id : null, asmName: asmUser?.fullName || null }
});
res.json({ success: true, message: asmUserId ? 'ASM assigned to dealer successfully' : 'ASM mapping removed successfully' }); res.json({ success: true, message: asmUserId ? 'ASM assigned to dealer successfully' : 'ASM mapping removed successfully' });
} catch (error) { } catch (error) {
console.error('Save dealer ASM mapping error:', error); console.error('Save dealer ASM mapping error:', error);
@ -1391,9 +1621,18 @@ export const saveSlaConfig = async (req: Request, res: Response) => {
const { id, activityName, ownerRole, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body; const { id, activityName, ownerRole, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body;
let config; let config;
let previousSnapshot: Record<string, unknown> | null = null;
const isUpdate = Boolean(id);
if (id) { if (id) {
config = await db.SLAConfiguration.findByPk(id, { transaction }); config = await db.SLAConfiguration.findByPk(id, { transaction });
if (!config) throw new Error('SLA Configuration not found'); if (!config) throw new Error('SLA Configuration not found');
previousSnapshot = {
activityName: config.activityName,
ownerRole: config.ownerRole,
tatHours: config.tatHours,
tatUnit: config.tatUnit,
isActive: config.isActive
};
await config.update({ activityName, ownerRole, tatHours, tatUnit, isActive }, { transaction }); await config.update({ activityName, ownerRole, tatHours, tatUnit, isActive }, { transaction });
} else { } else {
config = await db.SLAConfiguration.create({ activityName, ownerRole, tatHours, tatUnit, isActive: isActive !== undefined ? isActive : true }, { transaction }); config = await db.SLAConfiguration.create({ activityName, ownerRole, tatHours, tatUnit, isActive: isActive !== undefined ? isActive : true }, { transaction });
@ -1428,6 +1667,28 @@ export const saveSlaConfig = async (req: Request, res: Response) => {
} }
await transaction.commit(); await transaction.commit();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
entityType: 'sla_configuration',
entityId: config.id,
entityLabel: `SLA · ${activityName}`,
action: isUpdate ? SYSTEM_AUDIT_ACTIONS.UPDATED : SYSTEM_AUDIT_ACTIONS.CREATED,
description: `${isUpdate ? 'Updated' : 'Created'} SLA "${activityName}" — owner ${ownerRole}, ${tatHours} ${tatUnit}, reminders ${
Array.isArray(reminders) ? reminders.length : 0
}, escalations ${Array.isArray(escalationConfigs) ? escalationConfigs.length : 0}`,
oldData: previousSnapshot,
newData: {
activityName,
ownerRole,
tatHours,
tatUnit,
isActive,
reminderCount: Array.isArray(reminders) ? reminders.length : 0,
escalationCount: Array.isArray(escalationConfigs) ? escalationConfigs.length : 0
}
});
res.json({ success: true, data: config }); res.json({ success: true, data: config });
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -1552,6 +1813,17 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
} }
await transaction.commit(); await transaction.commit();
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
entityType: 'sla_defaults',
entityId: null,
entityLabel: `Default SLA matrix (${defaults.length} stages)`,
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
description: `Initialized comprehensive SLA defaults across onboarding, resignation, termination, relocation, and constitutional flows (${defaults.length} stages)`,
metadata: { stageCount: defaults.length }
});
res.json({ success: true, message: 'Comprehensive default SLA configurations initialized across all modules' }); res.json({ success: true, message: 'Comprehensive default SLA configurations initialized across all modules' });
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();

View File

@ -44,60 +44,73 @@ import {
initializeDefaultInterviewConfigs initializeDefaultInterviewConfigs
} from './interviewConfig.controller.js'; } from './interviewConfig.controller.js';
import { authenticate, optionalAuth } from '../../common/middleware/auth.js';
const router = Router(); const router = Router();
// Read endpoints stay open for the public application form (states / districts /
// areas) and for un-authenticated bootstrap calls; `optionalAuth` still attaches
// `req.user` when a token IS present, so the system-audit helper can snapshot
// the actor for any reads we later decide to log.
router.use(optionalAuth as any);
// All mutating endpoints (POST/PUT/DELETE) below require a logged-in user.
// This is what populates `req.user` so the system-audit log records userId,
// actorName, and actorRole. Without this, IP/UA are captured but the actor
// columns stay null.
const requireAuth = authenticate as any;
// --- Districts --- // --- Districts ---
router.get('/districts', getDistricts); router.get('/districts', getDistricts);
router.post('/districts', createDistrict); router.post('/districts', requireAuth, createDistrict);
router.put('/districts/:id', updateLocation); router.put('/districts/:id', requireAuth, updateLocation);
router.delete('/districts/:id', deleteLocation); router.delete('/districts/:id', requireAuth, deleteLocation);
// --- Areas --- // --- Areas ---
router.get('/areas', getAreas); router.get('/areas', getAreas);
router.post('/areas', createDistrict); router.post('/areas', requireAuth, createDistrict);
router.put('/areas/:id', updateLocation); router.put('/areas/:id', requireAuth, updateLocation);
router.delete('/areas/:id', deleteLocation); router.delete('/areas/:id', requireAuth, deleteLocation);
// --- Regions --- // --- Regions ---
router.get('/regions', getRegions); router.get('/regions', getRegions);
router.post('/regions', createRegion); router.post('/regions', requireAuth, createRegion);
router.put('/regions/:id', updateRegion); router.put('/regions/:id', requireAuth, updateRegion);
// --- Zones --- // --- Zones ---
router.get('/zones', getZones); router.get('/zones', getZones);
router.post('/zones', createZone); router.post('/zones', requireAuth, createZone);
router.put('/zones/:id', updateZone); router.put('/zones/:id', requireAuth, updateZone);
// --- States --- // --- States ---
router.get('/states', getStates); router.get('/states', getStates);
router.post('/states', createState); router.post('/states', requireAuth, createState);
// --- Managers --- // --- Managers ---
router.get('/managers', getManagersByRole); router.get('/managers', getManagersByRole);
router.get('/area-managers', getAreaManagers); router.get('/area-managers', getAreaManagers);
router.get('/asms', getASMs); router.get('/asms', getASMs);
router.get('/zonal-managers', getZonalManagers); router.get('/zonal-managers', getZonalManagers);
router.post('/zonal-managers', saveZM); router.post('/zonal-managers', requireAuth, saveZM);
router.get('/dd-leads', getDDLeads); router.get('/dd-leads', getDDLeads);
router.post('/dd-leads', saveDDLead); router.post('/dd-leads', requireAuth, saveDDLead);
router.get('/system-configs', getSystemConfigs); router.get('/system-configs', getSystemConfigs);
router.post('/system-configs', saveSystemConfig); router.post('/system-configs', requireAuth, saveSystemConfig);
router.get('/dealer-asm-mappings', getDealerAsmMappings); router.get('/dealer-asm-mappings', getDealerAsmMappings);
router.post('/dealer-asm-mappings', saveDealerAsmMapping); router.post('/dealer-asm-mappings', requireAuth, saveDealerAsmMapping);
// --- SLA Configuration --- // --- SLA Configuration ---
router.get('/sla-configs', getSlaConfigs); router.get('/sla-configs', getSlaConfigs);
router.post('/sla-configs', saveSlaConfig); router.post('/sla-configs', requireAuth, saveSlaConfig);
router.post('/sla-configs/initialize', initializeDefaultSlas); router.post('/sla-configs/initialize', requireAuth, initializeDefaultSlas);
// --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) --- // --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) ---
router.get('/interview-configs', getInterviewConfigs); router.get('/interview-configs', getInterviewConfigs);
router.get('/interview-configs/active/:configType', getInterviewConfigByType); router.get('/interview-configs/active/:configType', getInterviewConfigByType);
router.get('/interview-configs/:id', getInterviewConfigById); router.get('/interview-configs/:id', getInterviewConfigById);
router.post('/interview-configs', createInterviewConfig); router.post('/interview-configs', requireAuth, createInterviewConfig);
router.put('/interview-configs/:id', updateInterviewConfig); router.put('/interview-configs/:id', requireAuth, updateInterviewConfig);
router.delete('/interview-configs/:id', deleteInterviewConfig); router.delete('/interview-configs/:id', requireAuth, deleteInterviewConfig);
router.post('/interview-configs/initialize', initializeDefaultInterviewConfigs); router.post('/interview-configs/initialize', requireAuth, initializeDefaultInterviewConfigs);
export default router; export default router;

View File

@ -3,8 +3,13 @@ import db from '../../database/models/index.js';
const { Questionnaire, QuestionnaireQuestion, QuestionnaireOption, QuestionnaireResponse, Application, ApplicationStatusHistory } = db; const { Questionnaire, QuestionnaireQuestion, QuestionnaireOption, QuestionnaireResponse, Application, ApplicationStatusHistory } = db;
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { APPLICATION_STATUS } from '../../common/config/constants.js'; import {
APPLICATION_STATUS,
SYSTEM_AUDIT_MODULES,
SYSTEM_AUDIT_ACTIONS
} from '../../common/config/constants.js';
import { sendQuestionnaireAckEmail } from '../../common/utils/email.service.js'; import { sendQuestionnaireAckEmail } from '../../common/utils/email.service.js';
import { logSystemAudit } from '../../services/systemAuditLog.service.js';
export const getLatestQuestionnaire = async (req: Request, res: Response) => { export const getLatestQuestionnaire = async (req: Request, res: Response) => {
try { try {
@ -75,6 +80,20 @@ export const createQuestionnaireVersion = async (req: AuthRequest, res: Response
include: [{ model: QuestionnaireQuestion, as: 'questions' }] include: [{ model: QuestionnaireQuestion, as: 'questions' }]
}); });
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.QUESTIONNAIRE,
entityType: 'questionnaire',
entityId: newQuestionnaire.id,
entityLabel: `Questionnaire ${version}`,
action: SYSTEM_AUDIT_ACTIONS.CREATED,
description: `Published questionnaire version ${version} with ${questions?.length || 0} question(s); previous active version deactivated`,
newData: {
version,
isActive: true,
questionCount: questions?.length || 0
}
});
res.status(201).json({ success: true, data: fullQuestionnaire }); res.status(201).json({ success: true, data: fullQuestionnaire });
} catch (error) { } catch (error) {
console.error('Create questionnaire error:', error); console.error('Create questionnaire error:', error);
@ -117,6 +136,20 @@ export const submitResponse = async (req: AuthRequest, res: Response) => {
// Calculate Score Logic (Placeholder for ONB-04) // Calculate Score Logic (Placeholder for ONB-04)
// calculateAndSaveScore(applicationId, questionnaire.id); // calculateAndSaveScore(applicationId, questionnaire.id);
await logSystemAudit(req, {
module: SYSTEM_AUDIT_MODULES.QUESTIONNAIRE,
entityType: 'questionnaire_response',
entityId: application.id,
entityLabel: `Application ${application.applicationId || application.id} · ${application.applicantName || 'Applicant'}`,
action: SYSTEM_AUDIT_ACTIONS.SUBMITTED,
description: `Authenticated questionnaire response submitted (${responses.length} answer(s))`,
metadata: {
questionnaireId: questionnaire.id,
applicationId: application.id,
responseCount: responses.length
}
});
res.json({ success: true, message: 'Responses submitted successfully' }); res.json({ success: true, message: 'Responses submitted successfully' });
} catch (error) { } catch (error) {
console.error('Submit response error:', error); console.error('Submit response error:', error);
@ -294,6 +327,24 @@ export const submitPublicResponse = async (req: Request, res: Response) => {
console.error('[submitPublicResponse] acknowledgement email:', mailErr); console.error('[submitPublicResponse] acknowledgement email:', mailErr);
} }
await logSystemAudit(req, {
actorName: application.applicantName || 'Public Applicant',
actorRole: 'Prospective Dealer',
module: SYSTEM_AUDIT_MODULES.QUESTIONNAIRE,
entityType: 'questionnaire_response',
entityId: application.id,
entityLabel: `Application ${application.applicationId || application.id} · ${application.applicantName || 'Applicant'}`,
action: SYSTEM_AUDIT_ACTIONS.SUBMITTED,
description: `Public questionnaire submitted; status ${previousStatus}${newStatus}; total score ${totalScore}`,
oldData: { overallStatus: previousStatus },
newData: { overallStatus: newStatus, score: totalScore },
metadata: {
questionnaireId: questionnaire.id,
applicationId: application.id,
responseCount: responses.length
}
});
res.json({ success: true, message: 'Responses submitted successfully' }); res.json({ success: true, message: 'Responses submitted successfully' });
} catch (error) { } catch (error) {
console.error('Submit public response error:', error); console.error('Submit public response error:', error);

View File

@ -0,0 +1,108 @@
import type { Request } from 'express';
import db from '../database/models/index.js';
import type { AuthRequest } from '../types/express.types.js';
const { SystemAuditLog } = db;
export type SystemAuditPayload = {
userId?: string | null;
actorName?: string | null;
actorRole?: string | null;
module: string;
entityType: string;
entityId?: string | null;
entityLabel?: string | null;
action: string;
description?: string | null;
oldData?: Record<string, unknown> | null;
newData?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
ipAddress?: string | null;
userAgent?: string | null;
};
export type RequestContext = {
userId: string | null;
actorName: string | null;
actorRole: string | null;
ipAddress: string | null;
userAgent: string | null;
};
/**
* Snapshot the actor + transport context from an Express request so the
* caller doesn't have to repeat the same property plucking every time.
* Returns nulls for unauthenticated requests (e.g. public questionnaire).
*/
export function extractRequestContext(req: Request | AuthRequest | undefined): RequestContext {
const r: any = req || {};
const user = r.user || {};
const ipAddress =
(r.headers?.['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
r.ip ||
r.connection?.remoteAddress ||
null;
const userAgent =
(r.get && typeof r.get === 'function' && r.get('user-agent')) ||
r.headers?.['user-agent'] ||
null;
return {
userId: user.id || null,
actorName: user.fullName || user.firstName || null,
actorRole: user.roleCode || user.role || null,
ipAddress: ipAddress ? String(ipAddress).slice(0, 64) : null,
userAgent: userAgent ? String(userAgent).slice(0, 500) : null
};
}
/**
* Writes a system-audit row. Never throws system/config writes must not
* break the workflow that triggered them. Failures are logged to stdout/err
* so they show up in the Winston combined log.
*/
export async function safeSystemAuditLogCreate(payload: SystemAuditPayload): Promise<void> {
try {
await SystemAuditLog.create({
userId: payload.userId ?? null,
actorName: payload.actorName ?? null,
actorRole: payload.actorRole ?? null,
module: payload.module,
entityType: payload.entityType,
entityId: payload.entityId ? String(payload.entityId) : null,
entityLabel: payload.entityLabel ?? null,
action: payload.action,
description: payload.description ?? null,
oldData: payload.oldData ?? null,
newData: payload.newData ?? null,
metadata: payload.metadata ?? null,
ipAddress: payload.ipAddress ?? null,
userAgent: payload.userAgent ?? null
} as any);
} catch (err) {
console.error(
'[safeSystemAuditLogCreate] Non-fatal system-audit failure:',
(err as Error)?.message || err
);
}
}
/**
* Convenience wrapper that pulls the actor + transport context from `req`
* and merges it into the payload. Caller still supplies module / action /
* entity fields and any old/new data they want recorded.
*/
export async function logSystemAudit(
req: Request | AuthRequest | undefined,
payload: Omit<
SystemAuditPayload,
'userId' | 'actorName' | 'actorRole' | 'ipAddress' | 'userAgent'
> &
Partial<Pick<SystemAuditPayload, 'userId' | 'actorName' | 'actorRole'>>
): Promise<void> {
const ctx = extractRequestContext(req);
await safeSystemAuditLogCreate({
...ctx,
...payload
});
}

View File

@ -195,8 +195,10 @@ async function run() {
if (actor.stage === 'Legal') { if (actor.stage === 'Legal') {
log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`); log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`);
const formData = new FormData(); const formData = new FormData();
const blob = new Blob(['Mock Acceptance Letter Content'], { type: 'text/plain' }); // Minimal valid single-page PDF (matches upload middleware's allowed MIME list).
formData.append('file', blob, 'Acceptance_Letter.txt'); const MINIMAL_PDF = '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]/Contents 4 0 R/Resources<<>>>>endobj\n4 0 obj<</Length 50>>stream\nBT /F1 12 Tf 36 100 Td (Mock Acceptance Letter) Tj ET\nendstream endobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000053 00000 n \n0000000100 00000 n \n0000000180 00000 n \ntrailer<</Size 5/Root 1 0 R>>\nstartxref\n260\n%%EOF';
const blob = new Blob([MINIMAL_PDF], { type: 'application/pdf' });
formData.append('file', blob, 'Acceptance_Letter.pdf');
formData.append('documentType', 'Resignation Acceptance Letter'); formData.append('documentType', 'Resignation Acceptance Letter');
formData.append('stage', 'Legal'); formData.append('stage', 'Legal');

View File

@ -123,7 +123,7 @@ async function prospectLogin(phone) {
async function mockUploadDocument(appId, token, docType) { async function mockUploadDocument(appId, token, docType) {
const formData = new FormData(); const formData = new FormData();
const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG'); const fileBuffer = fs.readFileSync('/home/laxman/Pictures/Screenshots/Screenshot from 2026-05-13 20-23-30.png');
const blob = new Blob([fileBuffer], { type: 'image/png' }); const blob = new Blob([fileBuffer], { type: 'image/png' });
formData.append('file', blob, 'screenshot.png'); formData.append('file', blob, 'screenshot.png');
formData.append('documentType', docType); formData.append('documentType', docType);