From 5004508e91c253d1f7fb8f2ef60f205e2681e1d1 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Mon, 20 Apr 2026 01:51:09 +0530 Subject: [PATCH] DD Lead treated as natiolwide user and in app notification enhanced --- scratch/check_participants.ts | 44 ++++ scratch/cleanup_fnf_dealer.ts | 22 ++ scratch/global_backfill_fnf.ts | 36 +++ scratch/global_participant_sync.ts | 40 ++++ scratch/hard_reset_fnf_participants.ts | 32 +++ scratch/verify_fnf_resolve.ts | 43 ++++ src/common/config/constants.ts | 3 +- .../utils/workflow-email-notifications.ts | 146 +++++++++++++ src/common/utils/workflowWorknote.ts | 3 +- src/database/models/financial/FnF.ts | 8 + .../assessment/assessment.controller.ts | 24 +- .../communication/communication.controller.ts | 28 ++- src/modules/master/master.controller.ts | 81 ++++--- .../onboarding/onboarding.controller.ts | 23 +- .../self-service/relocation.controller.ts | 53 +++-- .../self-service/resignation.controller.ts | 14 +- .../settlement/settlement.controller.ts | 50 ++++- src/services/ConstitutionalWorkflowService.ts | 24 +- src/services/NotificationService.ts | 34 ++- src/services/ParticipantService.ts | 206 ++++++++++++++++-- src/services/RelocationWorkflowService.ts | 25 ++- src/services/ResignationWorkflowService.ts | 24 +- src/services/TerminationWorkflowService.ts | 90 ++++---- src/services/WorkflowService.ts | 28 +++ 24 files changed, 865 insertions(+), 216 deletions(-) create mode 100644 scratch/check_participants.ts create mode 100644 scratch/cleanup_fnf_dealer.ts create mode 100644 scratch/global_backfill_fnf.ts create mode 100644 scratch/global_participant_sync.ts create mode 100644 scratch/hard_reset_fnf_participants.ts create mode 100644 scratch/verify_fnf_resolve.ts diff --git a/scratch/check_participants.ts b/scratch/check_participants.ts new file mode 100644 index 0000000..5a8c83d --- /dev/null +++ b/scratch/check_participants.ts @@ -0,0 +1,44 @@ +import db from '../src/database/models/index.js'; +import { ParticipantService } from '../src/services/ParticipantService.js'; + +async function run() { + const fnfId = process.argv[2]; + if (!fnfId) { + console.error('Usage: npx tsx check_participants.ts '); + process.exit(1); + } + + console.log(`Checking participants for F&F: ${fnfId}`); + + try { + const participants = await db.RequestParticipant.findAll({ + where: { + requestId: fnfId, + requestType: 'fnf' + }, + include: [{ model: db.User, as: 'user', attributes: ['fullName', 'roleCode'] }] + }); + + console.log(`Found ${participants.length} database entries for participants.`); + + if (participants.length === 0) { + console.log('No participants found in DB. Attempting manual backfill...'); + await ParticipantService.assignFnFParticipants(fnfId); + + const recheck = await db.RequestParticipant.findAll({ + where: { requestId: fnfId, requestType: 'fnf' } + }); + console.log(`After backfill: Found ${recheck.length} participants.`); + } else { + participants.forEach((p: any) => { + console.log(`- ${p.user?.fullName || 'Unknown'} (${p.user?.roleCode}), Type: ${p.participantType}`); + }); + } + } catch (error) { + console.error('Error:', error); + } finally { + process.exit(); + } +} + +run(); diff --git a/scratch/cleanup_fnf_dealer.ts b/scratch/cleanup_fnf_dealer.ts new file mode 100644 index 0000000..d71abab --- /dev/null +++ b/scratch/cleanup_fnf_dealer.ts @@ -0,0 +1,22 @@ +import db from '../src/database/models/index.js'; + +async function cleanup() { + console.log('Starting Cleanup: Removing Dealer from F&F Participant lists...'); + + try { + const result = await db.RequestParticipant.destroy({ + where: { + requestType: 'fnf', + participantType: 'owner' + } + }); + + console.log(`Cleanup complete! Removed ${result} dealer entries from F&F collections.`); + } catch (error) { + console.error('Error during cleanup:', error); + } finally { + process.exit(); + } +} + +cleanup(); diff --git a/scratch/global_backfill_fnf.ts b/scratch/global_backfill_fnf.ts new file mode 100644 index 0000000..08eaa2d --- /dev/null +++ b/scratch/global_backfill_fnf.ts @@ -0,0 +1,36 @@ +import db from '../src/database/models/index.js'; +import { ParticipantService } from '../src/services/ParticipantService.js'; + +async function run() { + console.log('--- GLOBAL F&F PARTICIPANT AUDIT ---'); + + try { + const initiatedFnFs = await db.FnF.findAll({ + where: { status: 'Initiated' }, + attributes: ['id', 'settlementId', 'resignationId', 'terminationRequestId'] + }); + + console.log(`Found ${initiatedFnFs.length} initiated F&F records.`); + + let fixesCount = 0; + for (const fnf of initiatedFnFs) { + const count = await db.RequestParticipant.count({ + where: { requestId: fnf.id, requestType: 'fnf' } + }); + + if (count === 0) { + console.log(`Fixing missing participants for F&F: ${fnf.settlementId || fnf.id}`); + await ParticipantService.assignFnFParticipants(fnf.id); + fixesCount++; + } + } + + console.log(`Global audit complete. Fixed ${fixesCount} records.`); + } catch (error) { + console.error('Audit Error:', error); + } finally { + process.exit(); + } +} + +run(); diff --git a/scratch/global_participant_sync.ts b/scratch/global_participant_sync.ts new file mode 100644 index 0000000..5921996 --- /dev/null +++ b/scratch/global_participant_sync.ts @@ -0,0 +1,40 @@ +import db from '../src/database/models/index.js'; +import { ParticipantService } from '../src/services/ParticipantService.js'; + +async function run() { + console.log('Starting Global Participant Synchronization...'); + + try { + // 1. Sync F&F Settlements + console.log('\n--- Syncing F&F Settlement Participants ---'); + const fnfs = await db.FnF.findAll({ attributes: ['id', 'settlementId'] }); + for (const fnf of fnfs) { + console.log(`Syncing F&F: ${fnf.settlementId} (${fnf.id})`); + await ParticipantService.assignFnFParticipants(fnf.id); + } + + // 2. Sync Resignations + console.log('\n--- Syncing Resignation Participants ---'); + const resignations = await db.Resignation.findAll({ attributes: ['id', 'resignationId'] }); + for (const res of resignations) { + console.log(`Syncing Resignation: ${res.resignationId} (${res.id})`); + await ParticipantService.assignResignationParticipants(res.id); + } + + // 3. Sync Terminations + console.log('\n--- Syncing Termination Participants ---'); + const terminations = await db.TerminationRequest.findAll({ attributes: ['id', 'requestId'] }); + for (const term of terminations) { + console.log(`Syncing Termination: ${term.requestId} (${term.id})`); + await ParticipantService.assignTerminationParticipants(term.id); + } + + console.log('\nGlobal Synchronization Complete!'); + } catch (error) { + console.error('Error during global sync:', error); + } finally { + process.exit(); + } +} + +run(); diff --git a/scratch/hard_reset_fnf_participants.ts b/scratch/hard_reset_fnf_participants.ts new file mode 100644 index 0000000..0aadfcf --- /dev/null +++ b/scratch/hard_reset_fnf_participants.ts @@ -0,0 +1,32 @@ +import db from '../src/database/models/index.js'; +import { ParticipantService } from '../src/services/ParticipantService.js'; + +async function hardReset() { + console.log('Starting Hard Reset: Standardizing F&F Participants to 8 National Roles...'); + + try { + // 1. Wipe ALL participants for F&F requests + console.log('Wiping existing F&F participants...'); + const deleteCount = await db.RequestParticipant.destroy({ + where: { requestType: 'fnf' } + }); + console.log(`Deleted ${deleteCount} legacy participant records.`); + + // 2. Re-sync all F&F records using the new strict logic + const fnfs = await db.FnF.findAll({ attributes: ['id', 'settlementId'] }); + console.log(`Resyncing ${fnfs.length} F&F settlements...`); + + for (const fnf of fnfs) { + console.log(`Syncing ${fnf.settlementId}...`); + await ParticipantService.assignFnFParticipants(fnf.id); + } + + console.log('\nHard Reset Complete! All F&F records are now limited to 8 National Roles.'); + } catch (error) { + console.error('Error during hard reset:', error); + } finally { + process.exit(); + } +} + +hardReset(); diff --git a/scratch/verify_fnf_resolve.ts b/scratch/verify_fnf_resolve.ts new file mode 100644 index 0000000..6e9af52 --- /dev/null +++ b/scratch/verify_fnf_resolve.ts @@ -0,0 +1,43 @@ +import db from '../src/database/models/index.js'; +import { resolveEntityUuidByType } from '../src/common/utils/requestResolver.js'; + +async function run() { + const id = 'FNF-2026-614'; + console.log(`Resolving ID: ${id}`); + + try { + const { resolvedId, normalizedType } = await resolveEntityUuidByType(db as any, id, 'fnf'); + console.log(`Resolved ID: ${resolvedId}, Type: ${normalizedType}`); + + if (resolvedId !== id) { + console.log('Lookup successful!'); + + const fnf = await db.FnF.findByPk(resolvedId, { + include: [ + { + model: db.RequestParticipant, + as: 'participants', + include: [{ model: db.User, as: 'user', attributes: ['fullName'] }] + } + ] + }); + + if (fnf && fnf.participants) { + console.log(`Found ${fnf.participants.length} participants for ${id}`); + fnf.participants.forEach((p: any) => { + console.log(`- ${p.user?.fullName}`); + }); + } else { + console.log('No participants found in the Eager Load.'); + } + } else { + console.log('Lookup failed to find a different UUID.'); + } + } catch (error) { + console.error('Error:', error); + } finally { + process.exit(); + } +} + +run(); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index dc50a81..a00a92e 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -542,7 +542,8 @@ export const REQUEST_TYPES = { RESIGNATION: 'resignation', CONSTITUTIONAL: 'constitutional', RELOCATION: 'relocation', - TERMINATION: 'termination' + TERMINATION: 'termination', + FNF: 'fnf' } as const; // Standardized Offboarding Actions diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index 5d1d481..636b986 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -144,3 +144,149 @@ export async function notifyRelocationSubmittedEmails( } }).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err)); } + +/** + * Resolves the user IDs of the required 'next actor' based on the workflow stage. + */ +export async function resolveNextActors(requestId: string, requestType: string, newStage: string): Promise { + try { + const participants = await RequestParticipant.findAll({ + where: { requestId, requestType }, + include: [{ model: User, as: 'user' }] + }); + + const actorIds = new Set(); + + // We try to match the new stage to specific user roles + const stageRoleMap: Record = { + // Onboarding Specific + 'Level 1 Interview': ['DD-ZM', 'RBM'], + 'Level 1 Interview Pending': ['DD-ZM', 'RBM'], + 'Interview Level 1': ['DD-ZM', 'RBM'], + 'Level 2 Interview': ['ZBH', 'DD Lead'], + 'Level 2 Interview Pending': ['ZBH', 'DD Lead'], + 'Interview Level 2': ['ZBH', 'DD Lead'], + 'Level 3 Interview': ['NBH', 'DD Head'], + 'Level 3 Interview Pending': ['NBH', 'DD Head'], + 'Interview Level 3': ['NBH', 'DD Head'], + 'LOI Approval': ['NBH', 'DD Head'], + 'LOI In Progress': ['NBH', 'DD Head'], + 'LOA Approval': ['NBH', 'DD Head'], + 'LOA Pending': ['NBH', 'DD Head'], + + // Relocation / Resignation / termination common + 'ASM': ['ASM'], + 'ASM Review': ['ASM'], + 'RBM': ['RBM'], + 'RBM Review': ['RBM'], + 'ZM Review': ['DD-ZM'], + 'DD ZM Review': ['DD-ZM'], // Fixed role mapping for DD-ZM + 'ZBH': ['ZBH'], + 'ZBH Review': ['ZBH'], + 'DD Lead': ['DD Lead'], + 'DD Lead Review': ['DD Lead'], + 'DD Head': ['DD Head'], + 'DD Head Review': ['DD Head'], + 'DD Head Approval': ['DD Head'], + 'NBH': ['NBH'], + 'NBH Approval': ['NBH'], + 'NBH Evaluation': ['NBH'], + 'NBH Final Approval': ['NBH'], + 'Legal': ['Legal Admin'], + 'Legal Clearance': ['Legal Admin'], + 'Legal Review': ['Legal Admin'], + 'Legal Verification': ['Legal Admin'], + 'Finance': ['Finance'], + 'Finance Review': ['Finance'], + 'CCO': ['CCO'], + 'CCO Approval': ['CCO'], + 'CEO': ['CEO'], + 'CEO Final Approval': ['CEO'] + }; + + const expectedRoles = stageRoleMap[newStage] || []; + + for (const p of participants) { + const user = (p as any).user; + if (user && user.roleCode && expectedRoles.includes(user.roleCode)) { + actorIds.add(user.id); + } + } + + return Array.from(actorIds); + } catch (error) { + console.error('[resolveNextActors] error:', error); + return []; + } +} + +/** + * Sends turn-based notifications to the Next Actor(s), the Dealer, and all Participants. + */ +export async function notifyStakeholdersOnTransition( + requestId: string, + requestType: string, + targetStage: string, + metadata: { + code: string; + dealerName: string; + dealerId: string; + actionUserFullName: string; + action: string; + remarks: string; + link: string; + } +): Promise { + try { + const participants = await RequestParticipant.findAll({ + where: { requestId, requestType }, + include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'roleCode'] }] + }); + + const nextActorIds = await resolveNextActors(requestId, requestType, targetStage); + + for (const p of participants) { + const u = (p as any).user; + if (!u || !u.id) continue; + + const isNextActor = nextActorIds.includes(u.id); + const isDealer = u.id === metadata.dealerId; + + // Don't clutter the history of the person who just acted + if (u.fullName === metadata.actionUserFullName) { + continue; + } + + if (isNextActor) { + // Next Approver Notification + await NotificationService.notify(u.id, u.email, { + title: `Action Required: ${metadata.code}`, + message: `Application has reached ${targetStage} and requires your action.`, + channels: ['system', 'email'], + templateCode: 'WORKFLOW_ACTION_REQUIRED', + placeholders: { + dealerName: metadata.dealerName, + requestId: metadata.code, + link: metadata.link, + targetStage + } + }).catch(e => console.error(e)); + } else if (isDealer) { + // Status Update for Dealer + await NotificationService.notify(u.id, u.email, { + title: `Application Update: ${metadata.code}`, + message: `Your application is now at ${targetStage}. ${metadata.action}`, + channels: ['system'], + templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER', + placeholders: { + requestId: metadata.code, + link: metadata.link, + targetStage + } + }).catch(e => console.error(e)); + } + } + } catch (error) { + console.error('[notifyStakeholdersOnTransition] error:', error); + } +} diff --git a/src/common/utils/workflowWorknote.ts b/src/common/utils/workflowWorknote.ts index eeef75b..43f623f 100644 --- a/src/common/utils/workflowWorknote.ts +++ b/src/common/utils/workflowWorknote.ts @@ -5,7 +5,8 @@ export type WorkflowActivityRequestType = | 'relocation' | 'constitutional' | 'resignation' - | 'termination'; + | 'termination' + | 'fnf'; /** * Persists a workflow / decision line for Work Notes (UI: activity strip when noteType is internal | workflow). diff --git a/src/database/models/financial/FnF.ts b/src/database/models/financial/FnF.ts index fbd20dd..b4f1533 100644 --- a/src/database/models/financial/FnF.ts +++ b/src/database/models/financial/FnF.ts @@ -139,6 +139,14 @@ export default (sequelize: Sequelize) => { FnF.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' }); FnF.hasMany(models.FnFLineItem, { foreignKey: 'fnfId', as: 'lineItems' }); FnF.hasMany(models.FffClearance, { foreignKey: 'fnfId', as: 'clearances' }); + + // Stakeholders/Participants for WorkNote chat logic + FnF.hasMany(models.RequestParticipant, { + foreignKey: 'requestId', + constraints: false, + scope: { requestType: 'fnf' }, + as: 'participants' + }); }; return FnF; diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 49a26e7..642cec6 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -435,18 +435,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { }); console.log('Interview created with ID:', interview.id); - // Update Application Status - const statusMap: any = { - 1: 'Level 1 Interview Pending', - 2: 'Level 2 Interview Pending', - 3: 'Level 3 Interview Pending' - }; - - const newStatus = statusMap[levelNum] || 'Interview Scheduled'; - - await WorkflowService.transitionApplication(application, newStatus, req.user?.id || null, { - reason: `Interview Level ${levelNum} Scheduled` - }); + // Note: WorkflowTransition relocated below participant insertion. // MOCK INTEGRATIONS // 1. Google Calendar Mock @@ -506,6 +495,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { })); } + // Update Application Status (Moved after participants to ensure notification system can see the new participants) + const statusMap: any = { + 1: 'Level 1 Interview Pending', + 2: 'Level 2 Interview Pending', + 3: 'Level 3 Interview Pending' + }; + const newStatus = statusMap[levelNum] || 'Interview Scheduled'; + await WorkflowService.transitionApplication(application, newStatus, req.user?.id || null, { + reason: `Interview Level ${levelNum} Scheduled` + }); + // Fire and forget non-critical notifications to keep response fast, or use Promise.all // For now, using Promise.all to ensure we catch errors but execute concurrently const notificationPromises: Promise[] = []; diff --git a/src/modules/communication/communication.controller.ts b/src/modules/communication/communication.controller.ts index 03743c6..f3403e6 100644 --- a/src/modules/communication/communication.controller.ts +++ b/src/modules/communication/communication.controller.ts @@ -25,12 +25,34 @@ export const createTemplate = async (req: AuthRequest, res: Response) => { export const getNotifications = async (req: AuthRequest, res: Response) => { try { - const notifications = await Notification.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 50; + const offset = (page - 1) * limit; + + const { count, rows } = await Notification.findAndCountAll({ where: { userId: req.user?.id }, order: [['createdAt', 'DESC']], - limit: 50 + limit, + offset + }); + + const totalPages = Math.ceil(count / limit); + + const unreadCount = await Notification.count({ + where: { userId: req.user?.id, isRead: false } + }); + + res.json({ + success: true, + data: rows, + pagination: { + totalElements: count, + totalPages, + currentPage: page, + pageSize: limit, + unreadCount + } }); - res.json({ success: true, data: notifications }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching notifications' }); } diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index ee86dba..20ccf51 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1108,8 +1108,6 @@ export const getDDLeads = async (req: Request, res: Response) => { { model: db.UserRole, as: 'userRoles', - where: { isActive: true }, - required: true, include: [{ model: db.Role, as: 'role', @@ -1117,24 +1115,14 @@ export const getDDLeads = async (req: Request, res: Response) => { }] } ], - order: [['fullName', 'ASC']] + order: [['status', 'ASC'], ['fullName', 'ASC']] }); const result = (ddLeads || []).map((u: any) => { const roleAssignment = (u.userRoles || []).find((r: any) => (r.role?.roleCode === 'DD Lead')); const leadCode = roleAssignment?.managerCode || u.employeeId || 'N/A'; + const isActive = roleAssignment?.isActive || false; - // Collect unique zones from all active DD Lead roles for this user - const zoneMap = new Map(); - (u.userRoles || []).forEach((ur: any) => { - if (ur.role?.roleCode === 'DD Lead' && ur.zoneId) { - // We need the zone name, but it's not included in this query. - // We'll rely on the frontend to map names or fetch them if missing. - // However, it's better to include Zone in the include. - zoneMap.set(ur.zoneId, ur.zoneId); - } - }); - return { id: u.id, name: u.fullName, @@ -1142,13 +1130,10 @@ export const getDDLeads = async (req: Request, res: Response) => { employeeId: u.employeeId, leadCode: leadCode, status: u.status, - assignedZoneIds: Array.from(zoneMap.values()) + isActiveLead: isActive }; }); - // To get zone names, we'd need another query or better include. - // Let's refine the include to get zone names. - res.json({ success: true, data: result }); } catch (error) { console.error('Get DD Leads error:', error); @@ -1158,7 +1143,7 @@ export const getDDLeads = async (req: Request, res: Response) => { export const saveDDLead = async (req: Request, res: Response) => { try { - const { userId, leadCode, zoneIds, status } = req.body; + const { userId, leadCode, status, isActive } = req.body; if (!userId) return res.status(400).json({ success: false, message: 'userId is required' }); const leadRole = await db.Role.findOne({ where: { roleCode: 'DD Lead' } }); @@ -1168,36 +1153,44 @@ export const saveDDLead = async (req: Request, res: Response) => { await db.User.update({ status }, { where: { id: userId } }); } - // Deactivate existing DD Lead roles for this user + // --- SINGLETON VALIDATION --- + // If we are trying to activate this user as DD Lead, check if another active lead exists + if (isActive === true || isActive === 'true') { + const existingActiveLead = await db.UserRole.findOne({ + where: { + roleId: leadRole.id, + isActive: true, + userId: { [db.Sequelize.Op.ne]: userId } // Exclude current user + }, + include: [{ model: db.User, as: 'user' }] + }); + + if (existingActiveLead) { + return res.status(409).json({ + success: false, + message: `An active DD Lead already exists (${existingActiveLead.user?.fullName}). Please deactivate the current lead before assigning a new one.` + }); + } + } + + // Deactivate existing DD Lead roles for this user to start fresh (National approach) await db.UserRole.update({ isActive: false }, { where: { userId, roleId: leadRole.id } }); - // Create new role assignments for each zone - if (Array.isArray(zoneIds) && zoneIds.length > 0) { - for (const zoneId of zoneIds) { - await db.UserRole.create({ - userId, - roleId: leadRole.id, - zoneId: zoneId, - managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null), - isActive: true, - isPrimary: true - }); - } - } else { - // Case with no specific zone - await db.UserRole.create({ - userId, - roleId: leadRole.id, - zoneId: null, - managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null), - isActive: true, - isPrimary: true - }); - } + // Create/Activate the single National DD Lead role assignment + await db.UserRole.create({ + userId, + roleId: leadRole.id, + zoneId: null, // Global scope + regionId: null, // Global scope + districtId: null, // Global scope + managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null), + isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : true, + isPrimary: true + }); - res.json({ success: true, message: 'DD Lead saved successfully' }); + res.json({ success: true, message: 'DD Lead updated successfully' }); } catch (error) { console.error('Save DD Lead error:', error); res.status(500).json({ success: false, message: 'Error saving DD Lead' }); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index d3722a3..ecf582c 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -656,19 +656,18 @@ const assignStageEvaluators = async (appIdOrId: string) => { if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' }); if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' }); - // Level 2: ZBH (Zone manager) + DD Lead (Filtered by Zone) + // Level 2: ZBH (Zone manager) + DD Lead (National Singleton) if (zone && zone.zbhId) evaluatorMappings[2].push({ id: zone.zbhId, role: 'ZBH' }); - if (zone) { - const ddLead = await db.User.findOne({ - where: { roleCode: 'DD Lead', status: 'active' }, - include: [{ - model: db.UserRole, - as: 'userRoles', - where: { zoneId: zone.id, isActive: true } - }] - }); - if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' }); - } + + const ddLead = await db.User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { isActive: true } // Removed zoneId filter + }] + }); + if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' }); // Level 3: NBH + DD Head (National Level Roles) const level3Roles = ['NBH', 'DD Head']; diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index 2a5051f..47bb7a5 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -12,6 +12,7 @@ import { AuthRequest } from '../../types/express.types.js'; import { formatDateTime } from '../../common/utils/dateUtils.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { notifyRelocationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; +import { ParticipantService } from '../../services/ParticipantService.js'; const resolveRelocationUuid = async (id: string) => { const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'relocation'); @@ -153,19 +154,17 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) = evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }); } - // Stage 5: DD Lead (zone-scoped) - if (zone) { - const ddLead = await User.findOne({ - where: { roleCode: 'DD Lead', status: 'active' }, - include: [{ - model: db.UserRole, - as: 'userRoles', - where: { zoneId: zone.id, isActive: true } - }] - }); - if (ddLead) { - evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); - } + // Stage 5: DD Lead (National Singleton) + const ddLead = await User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { isActive: true } // Removed zoneId filter + }] + }); + if (ddLead) { + evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); } // Stage 6: DD Head (national) @@ -352,8 +351,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { }] }); - // Auto-assign evaluators based on outlet location hierarchy - await assignRelocationEvaluators(request.id, outletId); + // Auto-assign participants using ParticipantService + await ParticipantService.assignRelocationParticipants(request.id); await db.RelocationAudit.create({ userId: req.user.id, @@ -527,18 +526,16 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { { id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW } ]; - // Get DD Lead (zone-scoped) - if (zone) { - const ddLead = await User.findOne({ - where: { roleCode: 'DD Lead', status: 'active' }, - include: [{ - model: db.UserRole, - as: 'userRoles', - where: { zoneId: zone.id, isActive: true } - }] - }); - if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); - } + // Get DD Lead (National Singleton) + const ddLead = await User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { isActive: true } // Removed zoneId filter + }] + }); + if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); // Get DD Head (national) const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } }); @@ -706,7 +703,7 @@ export const takeAction = async (req: AuthRequest, res: Response) => { return Math.min(100, Math.round(((i + 1) / (RELOCATION_PIPELINE_STAGES.length + 1)) * 100)); }; - let actionType = OFFBOARDING_ACTIONS.APPROVE; + let actionType: string = OFFBOARDING_ACTIONS.APPROVE; if (normalizedAction === 'APPROVE') { newCurrentStage = stageFlow[request.currentStage as string] || request.currentStage; diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index b25a5ab..855c7b0 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -20,7 +20,7 @@ import { ParticipantService } from '../../services/ParticipantService.js'; import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; -import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js'; import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; // Removed generateResignationId and moved to NomenclatureService @@ -377,6 +377,8 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) { const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction }); + let fnfId = existingFnF?.id; + if (!existingFnF) { const dealerProfileId = (resignation as any).dealer?.dealerId; @@ -401,6 +403,13 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: })), { transaction } ); + + fnfId = fnf.id; + } + + // Always assign/sync Participants for F&F (Sub-application chat) to ensure robustness + if (fnfId) { + await ParticipantService.assignFnFParticipants(fnfId); } } @@ -649,7 +658,8 @@ export const assignResignation = async (req: AuthRequest, res: Response, next: N const roleIdMap: Record = { 'nbh': ROLES.NBH, 'legal': ROLES.LEGAL_ADMIN, - 'dd_admin': ROLES.DD_ADMIN + 'dd_admin': ROLES.DD_ADMIN, + 'dd_lead': ROLES.DD_LEAD }; const targetRole = roleIdMap[assignTo]; if (targetRole) { diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index 69c227e..dc7a49d 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -1,12 +1,15 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db; +const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog, RequestParticipant, Dealer } = db; import { AuthRequest } from '../../types/express.types.js'; import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES, ROLES } from '../../common/config/constants.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; +import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; +import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; +import { NomenclatureService } from '../../services/NomenclatureService.js'; const LINE_ITEM_DESCRIPTION_PREFIX = { DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', @@ -236,6 +239,10 @@ export const getFnFSettlements = async (req: Request, res: Response) => { export const getFnFById = async (req: Request, res: Response) => { try { const { id } = req.params; + + // Resolve UUID if human-readable ID (FNF-*) is passed + const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'fnf'); + const includeConfig = [ { model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] }, { model: TerminationRequest, as: 'terminationRequest' }, @@ -246,7 +253,7 @@ export const getFnFById = async (req: Request, res: Response) => { model: User, as: 'dealer', include: [{ - model: db.Dealer, + model: Dealer, as: 'dealerProfile', include: [ { model: db.DealerCode, as: 'dealerCode' }, @@ -256,7 +263,7 @@ export const getFnFById = async (req: Request, res: Response) => { }] }, { - model: db.Dealer, + model: Dealer, as: 'dealer', include: [ { model: db.DealerCode, as: 'dealerCode' }, @@ -264,24 +271,30 @@ export const getFnFById = async (req: Request, res: Response) => { ] }, { model: FnFLineItem, as: 'lineItems' }, - { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] } + { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }, + { + model: RequestParticipant, + as: 'participants', + separate: true, + include: [{ model: User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] + } ]; - const fnf = await FnF.findByPk(id, { - include: includeConfig + const fnf = await FnF.findByPk(resolvedId, { + include: includeConfig as any }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); - await ensureFinanceDraftsFromDepartmentClaims(id, null); + await ensureFinanceDraftsFromDepartmentClaims(resolvedId, null); - const fnfWithDrafts = await FnF.findByPk(id, { - include: [ - ...includeConfig - ] + const fnfWithDrafts = await FnF.findByPk(resolvedId, { + include: includeConfig as any }); - if (!fnfWithDrafts) return res.status(404).json({ success: false, message: 'F&F not found' }); + + if (!fnfWithDrafts) return res.status(404).json({ success: false, message: 'F&F not found after sync' }); res.json({ success: true, fnf: fnfWithDrafts }); } catch (error) { + console.error('Error fetching F&F:', error); res.status(500).json({ success: false, message: 'Error fetching F&F' }); } }; @@ -682,6 +695,19 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { } } + // 3. F&F Dashboard Chat Trail (Worknotes for the unified stakeholder view) + try { + await writeWorkflowActivityWorknote({ + requestId: id, + requestType: 'fnf', + userId: req.user?.id || '', + noteText: `[Auto] Clearance updated for ${clearance.department}: Status: ${normalizedStatus}, Amount: ${enteredAmount} (${itemType})`, + noteType: 'workflow' + }); + } catch (worknoteError) { + console.error('[SettlementController] Worknote recording failed:', worknoteError); + } + res.json({ success: true, message: 'Clearance updated successfully', clearance }); } catch (error) { console.error('Update clearance error:', error); diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index 658e0c9..a2cf535 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -104,19 +104,23 @@ export class ConstitutionalWorkflowService { if (dealerUser?.email) { const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; const remarkText = String(remarks ?? '').trim() || 'N/A'; - await NotificationService.notify(dealerUser.id, dealerUser.email, { - title: `Constitutional change update: ${targetStage}`, - message: `Your constitutional change request ${request.requestId} has been updated.`, - channels: ['email', 'whatsapp', 'system'], - templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE', - placeholders: { + + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + + await notifyStakeholdersOnTransition( + request.id, + REQUEST_TYPES.CONSTITUTIONAL, + targetStage, + { + code: request.requestId, dealerName: dealerUser.fullName || 'Dealer', - status: targetStage, + dealerId: dealerUser.id, + actionUserFullName: userFullName || 'System', + action: action || `Moved to ${targetStage}`, remarks: remarkText, - link: `${portalBase}/constitutional-change/${request.id}`, - ctaLabel: 'View request' + link: `${portalBase}/constitutional-change/${request.id}` } - }).catch((err) => console.error('[ConstitutionalWorkflowService] notify failed:', err)); + ); } return request; diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index db88f35..5ffe37c 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -22,13 +22,33 @@ export class NotificationService { // 1. System Notification (In-app) - Always synchronous for immediate feedback if (channels.includes('system') && userId) { - await Notification.create({ - userId, - title, - message, - type: metadata?.type || 'info', - isRead: false - }); + try { + const notification = await Notification.create({ + userId, + title, + message, + type: metadata?.type || 'info', + link: placeholders?.link || metadata?.link || null, + isRead: false + }); + + // Emit realtime update via Socket.io + const { getIO } = await import('../common/utils/socket.js'); + const io = getIO(); + if (io) { + const roomName = `user_${userId}`; + io.to(roomName).emit('notification', { + id: notification.id, + title, + message, + type: notification.type, + link: notification.link, + createdAt: notification.createdAt + }); + } + } catch (err) { + console.error('[NotificationService] Failed to create system notification:', err); + } } // 2. Offload other channels to Job Queue (BullMQ) diff --git a/src/services/ParticipantService.ts b/src/services/ParticipantService.ts index 0f0a13f..711ac36 100644 --- a/src/services/ParticipantService.ts +++ b/src/services/ParticipantService.ts @@ -11,7 +11,9 @@ const { Application, District, Region, - Zone + Zone, + Outlet, + RelocationRequest } = db; export class ParticipantService { @@ -76,32 +78,49 @@ export class ParticipantService { /** * Assign participants for Termination Request */ - static async assignTerminationParticipants(terminationId: string) { + static async assignTerminationParticipants(requestId: string) { try { - const termination = await TerminationRequest.findByPk(terminationId); - if (!termination) return; + const termination = await db.TerminationRequest.findByPk(requestId); + if (!termination) { + console.error(`[ParticipantService] Termination Request not found: ${requestId}`); + return; + } const participantIds = new Set(); - - // 0. The Dealer (Requester) should be a participant + + // 0. The Dealer themselves (Affected Party) should be a participant if (termination.dealerId) { - // Find user account for this dealer - const dealerUser = await User.findOne({ where: { dealerId: termination.dealerId } }); + // In Termination, dealerId is likely the Dealer Profile ID, + // need to resolve to User ID for participants + const dealerUser = await User.findOne({ + where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER } + }); if (dealerUser) participantIds.add(dealerUser.id); } - - // The Initiator (Admin who started termination) - if (termination.initiatedBy) participantIds.add(termination.initiatedBy); // 1. Location based managers - const managers = await this.getDealerLocationManagers(termination.dealerId); - if (managers) { - if (managers.rbmId) participantIds.add(managers.rbmId); - if (managers.zbhId) participantIds.add(managers.zbhId); + if (termination.dealerId) { + const managers = await this.getDealerLocationManagers(termination.dealerId); + if (managers) { + if (managers.asmId) participantIds.add(managers.asmId); + if (managers.rbmId) participantIds.add(managers.rbmId); + if (managers.zbhId) participantIds.add(managers.zbhId); + } } - // 2. National roles - Crucial for Termination Review - const nationalRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.CCO, ROLES.CEO, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]; + // 2. National roles + const nationalRoles = [ + ROLES.DD_LEAD, + ROLES.DD_HEAD, + ROLES.NBH, + ROLES.CCO, + ROLES.CEO, + ROLES.DD_ADMIN, + ROLES.FINANCE, + ROLES.LEGAL_ADMIN, + ROLES.SUPER_ADMIN + ]; + const nationalUsers = await User.findAll({ where: { roleCode: { [Op.in]: nationalRoles }, @@ -115,11 +134,16 @@ export class ParticipantService { // 3. Add all unique participants let addedCount = 0; for (const userId of participantIds) { - await this.addParticipant(termination.id, REQUEST_TYPES.TERMINATION, userId); + // Determine type (Dealer profile id is not userId) + // We'll check if the userId matches the resolved dealer user + const isDealer = termination.dealerId && (await User.findByPk(userId))?.dealerId === termination.dealerId; + const pType = isDealer ? 'owner' : 'contributor'; + + await this.addParticipant(termination.id, REQUEST_TYPES.TERMINATION, userId, pType); addedCount++; } - - console.log(`[ParticipantService] Added ${addedCount} participants to termination ${terminationId}`); + + console.log(`[ParticipantService] Added ${addedCount} participants to termination ${requestId}`); } catch (error) { console.error('Error assigning termination participants:', error); } @@ -152,7 +176,17 @@ export class ParticipantService { } // 2. National roles - const nationalRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]; + const nationalRoles = [ + ROLES.DD_LEAD, + ROLES.DD_HEAD, + ROLES.NBH, + ROLES.CCO, + ROLES.CEO, + ROLES.FINANCE, + ROLES.DD_ADMIN, + ROLES.LEGAL_ADMIN, + ROLES.SUPER_ADMIN + ]; const nationalUsers = await User.findAll({ where: { roleCode: { [Op.in]: nationalRoles }, @@ -212,7 +246,10 @@ export class ParticipantService { // 2. National roles - Essential for workflow transparency const nationalRoles = [ ROLES.DD_LEAD, + ROLES.DD_HEAD, ROLES.NBH, + ROLES.CCO, + ROLES.CEO, ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.LEGAL_ADMIN, @@ -243,4 +280,131 @@ export class ParticipantService { console.error('Error assigning resignation participants:', error); } } + + /** + * Assign participants for Relocation Request + */ + static async assignRelocationParticipants(requestId: string) { + try { + const relocation = await db.RelocationRequest.findByPk(requestId, { + include: [{ + model: Outlet, + as: 'outlet', + include: [{ + model: District, + as: 'district', + include: [ + { model: Region, as: 'region' }, + { model: Zone, as: 'zone' } + ] + }] + }] + }); + + if (!relocation) { + console.error(`[ParticipantService] Relocation not found: ${requestId}`); + return; + } + + const participantIds = new Set(); + + // 0. The Dealer (Requester) + if (relocation.dealerId) { + participantIds.add(relocation.dealerId); + } + + // 1. Location-based managers from Outlet + const outlet = (relocation as any).outlet; + if (outlet && outlet.district) { + const district = outlet.district; + if (district.asmId) participantIds.add(district.asmId); + if (district.zmId) participantIds.add(district.zmId); + if (district.region?.rbmId) participantIds.add(district.region.rbmId); + if (district.zone?.zbhId) participantIds.add(district.zone.zbhId); + } + + // 2. National roles + const nationalRoles = [ + ROLES.DD_LEAD, + ROLES.DD_HEAD, + ROLES.NBH, + ROLES.CCO, + ROLES.CEO, + ROLES.DD_ADMIN, + ROLES.LEGAL_ADMIN, + ROLES.SUPER_ADMIN + ]; + + const nationalUsers = await User.findAll({ + where: { + roleCode: { [Op.in]: nationalRoles }, + status: 'active' + }, + attributes: ['id'] + }); + + nationalUsers.forEach((u: any) => participantIds.add(u.id)); + + // 3. Add all unique participants + let addedCount = 0; + for (const userId of participantIds) { + const pType = userId === relocation.dealerId ? 'owner' : 'contributor'; + await this.addParticipant(relocation.id, REQUEST_TYPES.RELOCATION, userId, pType); + addedCount++; + } + + console.log(`[ParticipantService] Added ${addedCount} participants to relocation ${requestId}`); + } catch (error) { + console.error('Error assigning relocation participants:', error); + } + } + + /** + * Assign participants for F&F Settlement (Sub-application) + */ + /** + * Assign participants for F&F Settlement (Sub-application) - Strictly limited to 8 National Roles + */ + static async assignFnFParticipants(fnfId: string) { + try { + const fnf = await db.FnF.findByPk(fnfId); + if (!fnf) return; + + const participantIds = new Set(); + + // 1. National roles ONLY (Requested by user) + const nationalRoles = [ + ROLES.DD_LEAD, + ROLES.DD_HEAD, + ROLES.NBH, + ROLES.CCO, + ROLES.CEO, + ROLES.FINANCE, + ROLES.LEGAL_ADMIN, + ROLES.SUPER_ADMIN + ]; + + const nationalUsers = await User.findAll({ + where: { + roleCode: { [Op.in]: nationalRoles }, + status: 'active' + }, + attributes: ['id'] + }); + + nationalUsers.forEach((u: any) => participantIds.add(u.id)); + + // 2. Add all unique participants as contributors + let addedCount = 0; + for (const userId of participantIds) { + await this.addParticipant(fnf.id, REQUEST_TYPES.FNF, userId, 'contributor'); + addedCount++; + } + + console.log(`[ParticipantService] Added ${addedCount} participants to F&F settlement ${fnfId}`); + } catch (error) { + console.error('Error assigning F&F participants:', error); + } + } + } diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index 6c6e6b8..11d95c9 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -93,20 +93,23 @@ export class RelocationWorkflowService { if (dealerUser?.email) { const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; const stageLabel = request.currentStage || request.status || targetStatus; - await NotificationService.notify(dealerUser.id, dealerUser.email, { - title: `Relocation update: ${request.requestId}`, - message: `Your relocation request status changed — ${stageLabel}.`, - channels: ['email', 'whatsapp', 'system'], - templateCode: 'RELOCATION_UPDATE', - placeholders: { + + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + + await notifyStakeholdersOnTransition( + request.id, + REQUEST_TYPES.RELOCATION, + stageLabel, + { + code: request.requestId, dealerName: dealerUser.fullName || 'Dealer', - requestId: request.requestId, - status: stageLabel, + dealerId: dealerUser.id, + actionUserFullName: user ? user.fullName : 'System', + action: action || `Transitioned to ${targetStatus}`, remarks: reason || 'N/A', - link: `${portalBase}/relocation-requests/${request.id}`, - ctaLabel: 'View request' + link: `${portalBase}/relocation-requests/${request.id}` } - }).catch((err) => logger.error('[RelocationWorkflowService] email notify failed:', err)); + ); } return request; diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index 90ddcb4..8a8f632 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -89,19 +89,23 @@ export class ResignationWorkflowService { if (user) { const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - await NotificationService.notify(user.id, user.email, { - title: `Resignation Update: ${targetStage}`, - message: `Your resignation request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`, - channels: ['email', 'whatsapp', 'system'], - templateCode: 'RESIGNATION_UPDATE', - placeholders: { - status: targetStage, + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + + await notifyStakeholdersOnTransition( + resignation.id, + REQUEST_TYPES.RESIGNATION, + targetStage, + { + code: resignation.resignationId || resignation.id, dealerName: user.fullName || 'Dealer', + dealerId: user.id, + actionUserFullName: actor ? actor.fullName : 'System', + action: action || `Approved to ${targetStage}`, remarks: remarks || 'N/A', - link: `${portalBase}/dealer-resignation/${resignation.id}`, - ctaLabel: 'View request', + link: `${portalBase}/dealer-resignation/${resignation.id}` } - }); + ); + // 6. Deactivate User Account on final completion (SRS 1.1.5 / 2.3.5) if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) { diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index 1ec676c..a0008c1 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -9,6 +9,7 @@ import logger from '../common/utils/logger.js'; import { NomenclatureService } from '../common/utils/nomenclature.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; +import { ParticipantService } from './ParticipantService.js'; export class TerminationWorkflowService { /** @@ -121,22 +122,25 @@ export class TerminationWorkflowService { ctaLabel: 'Submit response' } }); - } else { - await NotificationService.notify(user.id, user.email, { - title: `Termination Status Update: ${targetStage}`, - message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`, - channels: ['email', 'whatsapp', 'system'], - templateCode: 'TERMINATION_UPDATE', - placeholders: { - status: targetStage, - dealerName: user.fullName || 'Dealer', - remarks: remarks || 'N/A', - link: dealerPortalLink, - ctaLabel: 'View details' - } - }); } + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + + await notifyStakeholdersOnTransition( + termination.id, + REQUEST_TYPES.TERMINATION, + targetStage, + { + code: termination.requestId, + dealerName: user.fullName || 'Dealer', + dealerId: user.id, + actionUserFullName: actor ? actor.fullName : 'System', + action: action || `Approved to ${targetStage}`, + remarks: remarks || 'N/A', + link: dealerPortalLink + } + ); + // 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5) // We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) { @@ -168,40 +172,42 @@ export class TerminationWorkflowService { const dealerProfile = await Dealer.findByPk(termination.dealerId); if (!dealerProfile) throw new Error('Dealer record not found for termination'); - // 2. Create FnF with zero totals — line items only from explicit clearance / finance entry (no mock SAP seed rows). - const fnf = await FnF.create({ - settlementId: NomenclatureService.generateFnFId(), - terminationRequestId: termination.id, - dealerId: termination.dealerId, - outletId: primaryOutlet?.id || null, - status: 'Initiated', - totalReceivables: 0, - totalPayables: 0, - netAmount: 0 - }, { transaction }); + // 2. Resolve or Create FnF Settlement + let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } }); + let fnfId = fnf?.id; - // 3. Initialize CLEARANCE JSON Structure in TerminationRequest (Matching Resignation module) - const initialClearances: Record = {}; - FNF_DEPARTMENTS.forEach(dept => { - initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' }; - }); + if (!fnf) { + fnf = await db.FnF.create({ + settlementId: NomenclatureService.generateFnFId(), + terminationRequestId: terminationId, + dealerId: termination.dealerId, + outletId: primaryOutlet?.id || null, + status: 'Initiated', + totalReceivables: 0, + totalPayables: 0, + netAmount: 0 + }); - await termination.update({ departmentalClearances: initialClearances }, { transaction }); + await db.FffClearance.bulkCreate( + FNF_DEPARTMENTS.map(dept => ({ + fnfId: fnf.id, + department: dept, + status: 'Pending' + })) + ); + + fnfId = fnf.id; + } - // 4. Initialize individual FffClearance records for tracking (Unified Dashboard) - await FffClearance.bulkCreate( - FNF_DEPARTMENTS.map(dept => ({ - fnfId: fnf.id, - department: dept, - status: 'Pending' - })), - { transaction } - ); - - // 5. Sync Deactivation to SAP + // 3. External SAP Sync ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive') .catch(err => console.error('Error syncing termination deactivation to SAP:', err)); + // 4. Assign Participants for F&F (Sub-application chat) + if (fnfId) { + await ParticipantService.assignFnFParticipants(fnfId); + } + return fnf; } diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index 3ba29ea..569107f 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -148,6 +148,34 @@ export class WorkflowService { } } + try { + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + let actionUserFullName = 'System'; + if (userId) { + const actionUser = await User.findByPk(userId, { attributes: ['fullName'] }); + if (actionUser) actionUserFullName = actionUser.fullName; + } + + await notifyStakeholdersOnTransition( + application.id, + 'application', + targetStatus, + { + code: application.applicationId, + dealerName: application.applicantName || 'Applicant', + dealerId: '', // Applications might not map cleanly to user ID until onboarding finishes + actionUserFullName, + action: reason || `Transitioned to ${targetStatus}`, + remarks: reason || 'N/A', + link: `${portalBase}/applications/${application.id}` + } + ); + } catch (err) { + console.error('[WorkflowService] Failed to notify stakeholders:', err); + } + console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`); return application;