diff --git a/docker-compose.yml b/docker-compose.yml index 53f9963..6b0a8db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/create-system-audit-log-table.ts b/scripts/create-system-audit-log-table.ts new file mode 100644 index 0000000..a31dd6e --- /dev/null +++ b/scripts/create-system-audit-log-table.ts @@ -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(); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 6192b15..67390fa 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -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', diff --git a/src/database/models/activity/SystemAuditLog.ts b/src/database/models/activity/SystemAuditLog.ts new file mode 100644 index 0000000..beaea68 --- /dev/null +++ b/src/database/models/activity/SystemAuditLog.ts @@ -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 { } + +export default (sequelize: Sequelize) => { + const SystemAuditLog = sequelize.define( + '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; +}; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 9034f65..d4fd50b 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -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); diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts index 95a060b..5579ef5 100644 --- a/src/modules/audit/audit.routes.ts +++ b/src/modules/audit/audit.routes.ts @@ -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= 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; diff --git a/src/modules/audit/systemAudit.controller.ts b/src/modules/audit/systemAudit.controller.ts new file mode 100644 index 0000000..d905e18 --- /dev/null +++ b/src/modules/audit/systemAudit.controller.ts @@ -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 = { + 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 = { + 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; + + 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' }); + } +}; diff --git a/src/modules/master/interviewConfig.controller.ts b/src/modules/master/interviewConfig.controller.ts index bbf208e..c3e8e8f 100644 --- a/src/modules/master/interviewConfig.controller.ts +++ b/src/modules/master/interviewConfig.controller.ts @@ -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); diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 6ca6052..d87ceb2 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -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 | 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 | 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(); diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 23796a0..f0cc8b4 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -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; diff --git a/src/modules/onboarding/questionnaire.controller.ts b/src/modules/onboarding/questionnaire.controller.ts index 100178b..1e6d414 100644 --- a/src/modules/onboarding/questionnaire.controller.ts +++ b/src/modules/onboarding/questionnaire.controller.ts @@ -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); diff --git a/src/services/systemAuditLog.service.ts b/src/services/systemAuditLog.service.ts new file mode 100644 index 0000000..7d913cb --- /dev/null +++ b/src/services/systemAuditLog.service.ts @@ -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 | null; + newData?: Record | null; + metadata?: Record | 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 { + 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> +): Promise { + const ctx = extractRequestContext(req); + await safeSystemAuditLogCreate({ + ...ctx, + ...payload + }); +} diff --git a/trigger-resignation.js b/trigger-resignation.js index 00b6242..ea312d4 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -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<>endobj\n2 0 obj<>endobj\n3 0 obj<>>>endobj\n4 0 obj<>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<>\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'); diff --git a/trigger-workflow.js b/trigger-workflow.js index 11a529b..cb160be 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -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);