system log table added and feew bugs coverd from the tracker
This commit is contained in:
parent
eeae163782
commit
fb07f7ab61
@ -1,28 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 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:
|
||||
image: redis:7.2-alpine
|
||||
container_name: re-onboarding-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
|
||||
# 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:
|
||||
image: redislabs/redisinsight:latest
|
||||
container_name: re-onboarding-redis-insight
|
||||
restart: always
|
||||
ports:
|
||||
- "8001:8001"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# PostgreSQL - Application Database
|
||||
# PostgreSQL - Uses the default bridge; `docker compose up` (full stack) still needs iptables/Docker networking working.
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: re-onboarding-db
|
||||
|
||||
40
scripts/create-system-audit-log-table.ts
Normal file
40
scripts/create-system-audit-log-table.ts
Normal 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();
|
||||
@ -426,6 +426,34 @@ export const AUDIT_ACTIONS = {
|
||||
REMINDER_SENT: 'REMINDER_SENT'
|
||||
} 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
|
||||
export const DOCUMENT_TYPES = {
|
||||
GST_CERTIFICATE: 'GST Certificate',
|
||||
|
||||
127
src/database/models/activity/SystemAuditLog.ts
Normal file
127
src/database/models/activity/SystemAuditLog.ts
Normal 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;
|
||||
};
|
||||
@ -102,6 +102,7 @@ import createRequestParticipant from './compliance/RequestParticipant.js';
|
||||
|
||||
// Activity
|
||||
import createAuditLog from './activity/AuditLog.js';
|
||||
import createSystemAuditLog from './activity/SystemAuditLog.js';
|
||||
import createWorknote from './activity/Worknote.js';
|
||||
import createWorkNoteAttachment from './activity/WorkNoteAttachment.js';
|
||||
import createWorkNoteTag from './activity/WorkNoteTag.js';
|
||||
@ -136,6 +137,7 @@ db.Outlet = createOutlet(sequelize);
|
||||
db.Worknote = createWorknote(sequelize);
|
||||
db.OnboardingDocument = createOnboardingDocument(sequelize);
|
||||
db.AuditLog = createAuditLog(sequelize);
|
||||
db.SystemAuditLog = createSystemAuditLog(sequelize);
|
||||
db.FinancePayment = createFinancePayment(sequelize);
|
||||
db.RelocationDocument = createRelocationDocument(sequelize);
|
||||
db.ResignationDocument = createResignationDocument(sequelize);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import * as auditController from './audit.controller.js';
|
||||
import * as systemAuditController from './systemAudit.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
// All audit routes require authentication
|
||||
@ -12,4 +13,11 @@ router.get('/logs', auditController.getAuditLogs);
|
||||
// GET /api/audit/summary?entityType=application&entityId=<uuid>
|
||||
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;
|
||||
|
||||
185
src/modules/audit/systemAudit.controller.ts
Normal file
185
src/modules/audit/systemAudit.controller.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
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;
|
||||
|
||||
@ -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 });
|
||||
} catch (error) {
|
||||
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' });
|
||||
}
|
||||
|
||||
const previousSnapshot = {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
isActive: config.isActive
|
||||
};
|
||||
|
||||
await config.update({ name, version, isActive }, { transaction });
|
||||
|
||||
// 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 });
|
||||
} catch (error) {
|
||||
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' });
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
configType: config.configType,
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
isActive: config.isActive
|
||||
};
|
||||
|
||||
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' });
|
||||
} catch (error) {
|
||||
console.error('Delete interview config error:', error);
|
||||
@ -399,6 +455,21 @@ export const initializeDefaultInterviewConfigs = async (req: Request, res: Respo
|
||||
}
|
||||
|
||||
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' });
|
||||
} catch (error) {
|
||||
console.error('Initialize defaults error:', error);
|
||||
|
||||
@ -2,8 +2,13 @@ import { Request, Response } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import { syncDistrictsByRegion, syncDistrictsByZone, syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.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 { logSystemAudit } from '../../services/systemAuditLog.service.js';
|
||||
const { User } = db;
|
||||
|
||||
const deriveZonePrefix = (zone: any): string => {
|
||||
@ -263,7 +268,23 @@ export const createDistrict = async (req: Request, res: Response) => {
|
||||
capacity: 'Standard',
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Create area error:', error);
|
||||
@ -397,6 +418,27 @@ export const createRegion = async (req: Request, res: Response) => {
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
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);
|
||||
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 zoneChanged = Boolean(targetZoneId && targetZoneId !== region.zoneId);
|
||||
const generatedCode =
|
||||
@ -489,6 +537,25 @@ export const updateRegion = async (req: Request, res: Response) => {
|
||||
await syncRegionManager(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' });
|
||||
} catch (error: any) {
|
||||
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.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 });
|
||||
} catch (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 zone = await db.Zone.findByPk(id);
|
||||
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 });
|
||||
|
||||
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 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' });
|
||||
} catch (error) {
|
||||
console.error('Update zone error:', error);
|
||||
@ -744,7 +849,17 @@ export const createState = async (req: Request, res: Response) => {
|
||||
if (zoneId) {
|
||||
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 });
|
||||
} catch (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);
|
||||
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
|
||||
await db.Opportunity.destroy({ where: { areaId: id } });
|
||||
|
||||
// Delete the location itself
|
||||
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' });
|
||||
} catch (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' }]
|
||||
});
|
||||
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;
|
||||
|
||||
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'
|
||||
});
|
||||
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' });
|
||||
|
||||
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' });
|
||||
} catch (error) {
|
||||
console.error('Save ZM error:', error);
|
||||
@ -1192,6 +1373,8 @@ export const saveDDLead = async (req: Request, res: Response) => {
|
||||
where: { userId, roleId: leadRole.id }
|
||||
});
|
||||
|
||||
const resolvedActive = isActive !== undefined ? (isActive === true || isActive === 'true') : true;
|
||||
|
||||
// Create/Activate the single National DD Lead role assignment
|
||||
await db.UserRole.create({
|
||||
userId,
|
||||
@ -1200,10 +1383,23 @@ export const saveDDLead = async (req: Request, res: Response) => {
|
||||
regionId: null, // Global scope
|
||||
districtId: null, // Global scope
|
||||
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
|
||||
isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : true,
|
||||
isActive: resolvedActive,
|
||||
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' });
|
||||
} catch (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;
|
||||
|
||||
let config;
|
||||
let previousSnapshot: Record<string, unknown> | null = null;
|
||||
|
||||
// Use key as the unique identifier if id isn't present
|
||||
if (id) {
|
||||
@ -1257,7 +1454,15 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
|
||||
config = await db.SystemConfiguration.findOne({ where: { key } });
|
||||
}
|
||||
|
||||
const isUpdate = Boolean(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 });
|
||||
} else {
|
||||
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 });
|
||||
} catch (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 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' });
|
||||
} catch (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;
|
||||
|
||||
let config;
|
||||
let previousSnapshot: Record<string, unknown> | null = null;
|
||||
const isUpdate = Boolean(id);
|
||||
if (id) {
|
||||
config = await db.SLAConfiguration.findByPk(id, { transaction });
|
||||
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 });
|
||||
} else {
|
||||
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 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 });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
@ -1552,6 +1813,17 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
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' });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
|
||||
@ -44,60 +44,73 @@ import {
|
||||
initializeDefaultInterviewConfigs
|
||||
} from './interviewConfig.controller.js';
|
||||
|
||||
import { authenticate, optionalAuth } from '../../common/middleware/auth.js';
|
||||
|
||||
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 ---
|
||||
router.get('/districts', getDistricts);
|
||||
router.post('/districts', createDistrict);
|
||||
router.put('/districts/:id', updateLocation);
|
||||
router.delete('/districts/:id', deleteLocation);
|
||||
router.post('/districts', requireAuth, createDistrict);
|
||||
router.put('/districts/:id', requireAuth, updateLocation);
|
||||
router.delete('/districts/:id', requireAuth, deleteLocation);
|
||||
|
||||
// --- Areas ---
|
||||
router.get('/areas', getAreas);
|
||||
router.post('/areas', createDistrict);
|
||||
router.put('/areas/:id', updateLocation);
|
||||
router.delete('/areas/:id', deleteLocation);
|
||||
router.post('/areas', requireAuth, createDistrict);
|
||||
router.put('/areas/:id', requireAuth, updateLocation);
|
||||
router.delete('/areas/:id', requireAuth, deleteLocation);
|
||||
|
||||
// --- Regions ---
|
||||
router.get('/regions', getRegions);
|
||||
router.post('/regions', createRegion);
|
||||
router.put('/regions/:id', updateRegion);
|
||||
router.post('/regions', requireAuth, createRegion);
|
||||
router.put('/regions/:id', requireAuth, updateRegion);
|
||||
|
||||
// --- Zones ---
|
||||
router.get('/zones', getZones);
|
||||
router.post('/zones', createZone);
|
||||
router.put('/zones/:id', updateZone);
|
||||
router.post('/zones', requireAuth, createZone);
|
||||
router.put('/zones/:id', requireAuth, updateZone);
|
||||
|
||||
// --- States ---
|
||||
router.get('/states', getStates);
|
||||
router.post('/states', createState);
|
||||
router.post('/states', requireAuth, createState);
|
||||
|
||||
// --- Managers ---
|
||||
router.get('/managers', getManagersByRole);
|
||||
router.get('/area-managers', getAreaManagers);
|
||||
router.get('/asms', getASMs);
|
||||
router.get('/zonal-managers', getZonalManagers);
|
||||
router.post('/zonal-managers', saveZM);
|
||||
router.post('/zonal-managers', requireAuth, saveZM);
|
||||
router.get('/dd-leads', getDDLeads);
|
||||
router.post('/dd-leads', saveDDLead);
|
||||
router.post('/dd-leads', requireAuth, saveDDLead);
|
||||
router.get('/system-configs', getSystemConfigs);
|
||||
router.post('/system-configs', saveSystemConfig);
|
||||
router.post('/system-configs', requireAuth, saveSystemConfig);
|
||||
router.get('/dealer-asm-mappings', getDealerAsmMappings);
|
||||
router.post('/dealer-asm-mappings', saveDealerAsmMapping);
|
||||
router.post('/dealer-asm-mappings', requireAuth, saveDealerAsmMapping);
|
||||
|
||||
// --- SLA Configuration ---
|
||||
router.get('/sla-configs', getSlaConfigs);
|
||||
router.post('/sla-configs', saveSlaConfig);
|
||||
router.post('/sla-configs/initialize', initializeDefaultSlas);
|
||||
router.post('/sla-configs', requireAuth, saveSlaConfig);
|
||||
router.post('/sla-configs/initialize', requireAuth, initializeDefaultSlas);
|
||||
|
||||
// --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) ---
|
||||
router.get('/interview-configs', getInterviewConfigs);
|
||||
router.get('/interview-configs/active/:configType', getInterviewConfigByType);
|
||||
router.get('/interview-configs/:id', getInterviewConfigById);
|
||||
router.post('/interview-configs', createInterviewConfig);
|
||||
router.put('/interview-configs/:id', updateInterviewConfig);
|
||||
router.delete('/interview-configs/:id', deleteInterviewConfig);
|
||||
router.post('/interview-configs/initialize', initializeDefaultInterviewConfigs);
|
||||
router.post('/interview-configs', requireAuth, createInterviewConfig);
|
||||
router.put('/interview-configs/:id', requireAuth, updateInterviewConfig);
|
||||
router.delete('/interview-configs/:id', requireAuth, deleteInterviewConfig);
|
||||
router.post('/interview-configs/initialize', requireAuth, initializeDefaultInterviewConfigs);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -3,8 +3,13 @@ import db from '../../database/models/index.js';
|
||||
const { Questionnaire, QuestionnaireQuestion, QuestionnaireOption, QuestionnaireResponse, Application, ApplicationStatusHistory } = db;
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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 { logSystemAudit } from '../../services/systemAuditLog.service.js';
|
||||
|
||||
export const getLatestQuestionnaire = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -75,6 +80,20 @@ export const createQuestionnaireVersion = async (req: AuthRequest, res: Response
|
||||
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 });
|
||||
} catch (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)
|
||||
// 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' });
|
||||
} catch (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);
|
||||
}
|
||||
|
||||
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' });
|
||||
} catch (error) {
|
||||
console.error('Submit public response error:', error);
|
||||
|
||||
108
src/services/systemAuditLog.service.ts
Normal file
108
src/services/systemAuditLog.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@ -195,8 +195,10 @@ async function run() {
|
||||
if (actor.stage === 'Legal') {
|
||||
log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`);
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(['Mock Acceptance Letter Content'], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'Acceptance_Letter.txt');
|
||||
// Minimal valid single-page PDF (matches upload middleware's allowed MIME list).
|
||||
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('stage', 'Legal');
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@ async function prospectLogin(phone) {
|
||||
|
||||
async function mockUploadDocument(appId, token, docType) {
|
||||
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' });
|
||||
formData.append('file', blob, 'screenshot.png');
|
||||
formData.append('documentType', docType);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user