DD Lead treated as natiolwide user and in app notification enhanced
This commit is contained in:
parent
4fa1898824
commit
5004508e91
44
scratch/check_participants.ts
Normal file
44
scratch/check_participants.ts
Normal file
@ -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 <fnfId>');
|
||||||
|
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();
|
||||||
22
scratch/cleanup_fnf_dealer.ts
Normal file
22
scratch/cleanup_fnf_dealer.ts
Normal file
@ -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();
|
||||||
36
scratch/global_backfill_fnf.ts
Normal file
36
scratch/global_backfill_fnf.ts
Normal file
@ -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();
|
||||||
40
scratch/global_participant_sync.ts
Normal file
40
scratch/global_participant_sync.ts
Normal file
@ -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();
|
||||||
32
scratch/hard_reset_fnf_participants.ts
Normal file
32
scratch/hard_reset_fnf_participants.ts
Normal file
@ -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();
|
||||||
43
scratch/verify_fnf_resolve.ts
Normal file
43
scratch/verify_fnf_resolve.ts
Normal file
@ -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();
|
||||||
@ -542,7 +542,8 @@ export const REQUEST_TYPES = {
|
|||||||
RESIGNATION: 'resignation',
|
RESIGNATION: 'resignation',
|
||||||
CONSTITUTIONAL: 'constitutional',
|
CONSTITUTIONAL: 'constitutional',
|
||||||
RELOCATION: 'relocation',
|
RELOCATION: 'relocation',
|
||||||
TERMINATION: 'termination'
|
TERMINATION: 'termination',
|
||||||
|
FNF: 'fnf'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Standardized Offboarding Actions
|
// Standardized Offboarding Actions
|
||||||
|
|||||||
@ -144,3 +144,149 @@ export async function notifyRelocationSubmittedEmails(
|
|||||||
}
|
}
|
||||||
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
}).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<string[]> {
|
||||||
|
try {
|
||||||
|
const participants = await RequestParticipant.findAll({
|
||||||
|
where: { requestId, requestType },
|
||||||
|
include: [{ model: User, as: 'user' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const actorIds = new Set<string>();
|
||||||
|
|
||||||
|
// We try to match the new stage to specific user roles
|
||||||
|
const stageRoleMap: Record<string, string[]> = {
|
||||||
|
// 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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@ export type WorkflowActivityRequestType =
|
|||||||
| 'relocation'
|
| 'relocation'
|
||||||
| 'constitutional'
|
| 'constitutional'
|
||||||
| 'resignation'
|
| 'resignation'
|
||||||
| 'termination';
|
| 'termination'
|
||||||
|
| 'fnf';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists a workflow / decision line for Work Notes (UI: activity strip when noteType is internal | workflow).
|
* Persists a workflow / decision line for Work Notes (UI: activity strip when noteType is internal | workflow).
|
||||||
|
|||||||
@ -139,6 +139,14 @@ export default (sequelize: Sequelize) => {
|
|||||||
FnF.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' });
|
FnF.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' });
|
||||||
FnF.hasMany(models.FnFLineItem, { foreignKey: 'fnfId', as: 'lineItems' });
|
FnF.hasMany(models.FnFLineItem, { foreignKey: 'fnfId', as: 'lineItems' });
|
||||||
FnF.hasMany(models.FffClearance, { foreignKey: 'fnfId', as: 'clearances' });
|
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;
|
return FnF;
|
||||||
|
|||||||
@ -435,18 +435,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
console.log('Interview created with ID:', interview.id);
|
console.log('Interview created with ID:', interview.id);
|
||||||
|
|
||||||
// Update Application Status
|
// Note: WorkflowTransition relocated below participant insertion.
|
||||||
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`
|
|
||||||
});
|
|
||||||
|
|
||||||
// MOCK INTEGRATIONS
|
// MOCK INTEGRATIONS
|
||||||
// 1. Google Calendar Mock
|
// 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
|
// 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
|
// For now, using Promise.all to ensure we catch errors but execute concurrently
|
||||||
const notificationPromises: Promise<any>[] = [];
|
const notificationPromises: Promise<any>[] = [];
|
||||||
|
|||||||
@ -25,12 +25,34 @@ export const createTemplate = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getNotifications = async (req: AuthRequest, res: Response) => {
|
export const getNotifications = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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 },
|
where: { userId: req.user?.id },
|
||||||
order: [['createdAt', 'DESC']],
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error fetching notifications' });
|
res.status(500).json({ success: false, message: 'Error fetching notifications' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1108,8 +1108,6 @@ export const getDDLeads = async (req: Request, res: Response) => {
|
|||||||
{
|
{
|
||||||
model: db.UserRole,
|
model: db.UserRole,
|
||||||
as: 'userRoles',
|
as: 'userRoles',
|
||||||
where: { isActive: true },
|
|
||||||
required: true,
|
|
||||||
include: [{
|
include: [{
|
||||||
model: db.Role,
|
model: db.Role,
|
||||||
as: 'role',
|
as: 'role',
|
||||||
@ -1117,23 +1115,13 @@ export const getDDLeads = async (req: Request, res: Response) => {
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['fullName', 'ASC']]
|
order: [['status', 'ASC'], ['fullName', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (ddLeads || []).map((u: any) => {
|
const result = (ddLeads || []).map((u: any) => {
|
||||||
const roleAssignment = (u.userRoles || []).find((r: any) => (r.role?.roleCode === 'DD Lead'));
|
const roleAssignment = (u.userRoles || []).find((r: any) => (r.role?.roleCode === 'DD Lead'));
|
||||||
const leadCode = roleAssignment?.managerCode || u.employeeId || 'N/A';
|
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 {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@ -1142,13 +1130,10 @@ export const getDDLeads = async (req: Request, res: Response) => {
|
|||||||
employeeId: u.employeeId,
|
employeeId: u.employeeId,
|
||||||
leadCode: leadCode,
|
leadCode: leadCode,
|
||||||
status: u.status,
|
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 });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get DD Leads error:', 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) => {
|
export const saveDDLead = async (req: Request, res: Response) => {
|
||||||
try {
|
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' });
|
if (!userId) return res.status(400).json({ success: false, message: 'userId is required' });
|
||||||
|
|
||||||
const leadRole = await db.Role.findOne({ where: { roleCode: 'DD Lead' } });
|
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 } });
|
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 }, {
|
await db.UserRole.update({ isActive: false }, {
|
||||||
where: { userId, roleId: leadRole.id }
|
where: { userId, roleId: leadRole.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new role assignments for each zone
|
// Create/Activate the single National DD Lead role assignment
|
||||||
if (Array.isArray(zoneIds) && zoneIds.length > 0) {
|
|
||||||
for (const zoneId of zoneIds) {
|
|
||||||
await db.UserRole.create({
|
await db.UserRole.create({
|
||||||
userId,
|
userId,
|
||||||
roleId: leadRole.id,
|
roleId: leadRole.id,
|
||||||
zoneId: zoneId,
|
zoneId: null, // Global scope
|
||||||
|
regionId: null, // Global scope
|
||||||
|
districtId: null, // Global scope
|
||||||
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
|
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
|
||||||
isActive: true,
|
isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : true,
|
||||||
isPrimary: 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, message: 'DD Lead saved successfully' });
|
res.json({ success: true, message: 'DD Lead updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save DD Lead error:', error);
|
console.error('Save DD Lead error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error saving DD Lead' });
|
res.status(500).json({ success: false, message: 'Error saving DD Lead' });
|
||||||
|
|||||||
@ -656,19 +656,18 @@ const assignStageEvaluators = async (appIdOrId: string) => {
|
|||||||
if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' });
|
if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' });
|
||||||
if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' });
|
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 && zone.zbhId) evaluatorMappings[2].push({ id: zone.zbhId, role: 'ZBH' });
|
||||||
if (zone) {
|
|
||||||
const ddLead = await db.User.findOne({
|
const ddLead = await db.User.findOne({
|
||||||
where: { roleCode: 'DD Lead', status: 'active' },
|
where: { roleCode: 'DD Lead', status: 'active' },
|
||||||
include: [{
|
include: [{
|
||||||
model: db.UserRole,
|
model: db.UserRole,
|
||||||
as: 'userRoles',
|
as: 'userRoles',
|
||||||
where: { zoneId: zone.id, isActive: true }
|
where: { isActive: true } // Removed zoneId filter
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' });
|
if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' });
|
||||||
}
|
|
||||||
|
|
||||||
// Level 3: NBH + DD Head (National Level Roles)
|
// Level 3: NBH + DD Head (National Level Roles)
|
||||||
const level3Roles = ['NBH', 'DD Head'];
|
const level3Roles = ['NBH', 'DD Head'];
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { AuthRequest } from '../../types/express.types.js';
|
|||||||
import { formatDateTime } from '../../common/utils/dateUtils.js';
|
import { formatDateTime } from '../../common/utils/dateUtils.js';
|
||||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { notifyRelocationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
|
import { notifyRelocationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
|
||||||
|
import { ParticipantService } from '../../services/ParticipantService.js';
|
||||||
|
|
||||||
const resolveRelocationUuid = async (id: string) => {
|
const resolveRelocationUuid = async (id: string) => {
|
||||||
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'relocation');
|
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'relocation');
|
||||||
@ -153,20 +154,18 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
|||||||
evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW });
|
evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 5: DD Lead (zone-scoped)
|
// Stage 5: DD Lead (National Singleton)
|
||||||
if (zone) {
|
|
||||||
const ddLead = await User.findOne({
|
const ddLead = await User.findOne({
|
||||||
where: { roleCode: 'DD Lead', status: 'active' },
|
where: { roleCode: 'DD Lead', status: 'active' },
|
||||||
include: [{
|
include: [{
|
||||||
model: db.UserRole,
|
model: db.UserRole,
|
||||||
as: 'userRoles',
|
as: 'userRoles',
|
||||||
where: { zoneId: zone.id, isActive: true }
|
where: { isActive: true } // Removed zoneId filter
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
if (ddLead) {
|
if (ddLead) {
|
||||||
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 6: DD Head (national)
|
// Stage 6: DD Head (national)
|
||||||
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
|
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
|
||||||
@ -352,8 +351,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-assign evaluators based on outlet location hierarchy
|
// Auto-assign participants using ParticipantService
|
||||||
await assignRelocationEvaluators(request.id, outletId);
|
await ParticipantService.assignRelocationParticipants(request.id);
|
||||||
|
|
||||||
await db.RelocationAudit.create({
|
await db.RelocationAudit.create({
|
||||||
userId: req.user.id,
|
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 }
|
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get DD Lead (zone-scoped)
|
// Get DD Lead (National Singleton)
|
||||||
if (zone) {
|
|
||||||
const ddLead = await User.findOne({
|
const ddLead = await User.findOne({
|
||||||
where: { roleCode: 'DD Lead', status: 'active' },
|
where: { roleCode: 'DD Lead', status: 'active' },
|
||||||
include: [{
|
include: [{
|
||||||
model: db.UserRole,
|
model: db.UserRole,
|
||||||
as: 'userRoles',
|
as: 'userRoles',
|
||||||
where: { zoneId: zone.id, isActive: true }
|
where: { isActive: true } // Removed zoneId filter
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
||||||
}
|
|
||||||
|
|
||||||
// Get DD Head (national)
|
// Get DD Head (national)
|
||||||
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
|
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));
|
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') {
|
if (normalizedAction === 'APPROVE') {
|
||||||
newCurrentStage = stageFlow[request.currentStage as string] || request.currentStage;
|
newCurrentStage = stageFlow[request.currentStage as string] || request.currentStage;
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { ParticipantService } from '../../services/ParticipantService.js';
|
|||||||
import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
|
import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
|
||||||
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.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';
|
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||||
|
|
||||||
// Removed generateResignationId and moved to NomenclatureService
|
// 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) {
|
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||||
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
|
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
|
||||||
|
let fnfId = existingFnF?.id;
|
||||||
|
|
||||||
if (!existingFnF) {
|
if (!existingFnF) {
|
||||||
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
||||||
|
|
||||||
@ -401,6 +403,13 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
})),
|
})),
|
||||||
{ transaction }
|
{ 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<string, string> = {
|
const roleIdMap: Record<string, string> = {
|
||||||
'nbh': ROLES.NBH,
|
'nbh': ROLES.NBH,
|
||||||
'legal': ROLES.LEGAL_ADMIN,
|
'legal': ROLES.LEGAL_ADMIN,
|
||||||
'dd_admin': ROLES.DD_ADMIN
|
'dd_admin': ROLES.DD_ADMIN,
|
||||||
|
'dd_lead': ROLES.DD_LEAD
|
||||||
};
|
};
|
||||||
const targetRole = roleIdMap[assignTo];
|
const targetRole = roleIdMap[assignTo];
|
||||||
if (targetRole) {
|
if (targetRole) {
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
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 { AuthRequest } from '../../types/express.types.js';
|
||||||
import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES, ROLES } from '../../common/config/constants.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 { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
||||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||||
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||||
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.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 = {
|
const LINE_ITEM_DESCRIPTION_PREFIX = {
|
||||||
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
||||||
@ -236,6 +239,10 @@ export const getFnFSettlements = async (req: Request, res: Response) => {
|
|||||||
export const getFnFById = async (req: Request, res: Response) => {
|
export const getFnFById = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Resolve UUID if human-readable ID (FNF-*) is passed
|
||||||
|
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'fnf');
|
||||||
|
|
||||||
const includeConfig = [
|
const includeConfig = [
|
||||||
{ model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] },
|
{ model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] },
|
||||||
{ model: TerminationRequest, as: 'terminationRequest' },
|
{ model: TerminationRequest, as: 'terminationRequest' },
|
||||||
@ -246,7 +253,7 @@ export const getFnFById = async (req: Request, res: Response) => {
|
|||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
include: [{
|
include: [{
|
||||||
model: db.Dealer,
|
model: Dealer,
|
||||||
as: 'dealerProfile',
|
as: 'dealerProfile',
|
||||||
include: [
|
include: [
|
||||||
{ model: db.DealerCode, as: 'dealerCode' },
|
{ model: db.DealerCode, as: 'dealerCode' },
|
||||||
@ -256,7 +263,7 @@ export const getFnFById = async (req: Request, res: Response) => {
|
|||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: db.Dealer,
|
model: Dealer,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
include: [
|
include: [
|
||||||
{ model: db.DealerCode, as: 'dealerCode' },
|
{ model: db.DealerCode, as: 'dealerCode' },
|
||||||
@ -264,24 +271,30 @@ export const getFnFById = async (req: Request, res: Response) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ model: FnFLineItem, as: 'lineItems' },
|
{ 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, {
|
const fnf = await FnF.findByPk(resolvedId, {
|
||||||
include: includeConfig
|
include: includeConfig as any
|
||||||
});
|
});
|
||||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
|
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, {
|
const fnfWithDrafts = await FnF.findByPk(resolvedId, {
|
||||||
include: [
|
include: includeConfig as any
|
||||||
...includeConfig
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
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 });
|
res.json({ success: true, fnf: fnfWithDrafts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching F&F:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching F&F' });
|
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 });
|
res.json({ success: true, message: 'Clearance updated successfully', clearance });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update clearance error:', error);
|
console.error('Update clearance error:', error);
|
||||||
|
|||||||
@ -104,19 +104,23 @@ export class ConstitutionalWorkflowService {
|
|||||||
if (dealerUser?.email) {
|
if (dealerUser?.email) {
|
||||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
const remarkText = String(remarks ?? '').trim() || 'N/A';
|
const remarkText = String(remarks ?? '').trim() || 'N/A';
|
||||||
await NotificationService.notify(dealerUser.id, dealerUser.email, {
|
|
||||||
title: `Constitutional change update: ${targetStage}`,
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||||
message: `Your constitutional change request ${request.requestId} has been updated.`,
|
|
||||||
channels: ['email', 'whatsapp', 'system'],
|
await notifyStakeholdersOnTransition(
|
||||||
templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE',
|
request.id,
|
||||||
placeholders: {
|
REQUEST_TYPES.CONSTITUTIONAL,
|
||||||
|
targetStage,
|
||||||
|
{
|
||||||
|
code: request.requestId,
|
||||||
dealerName: dealerUser.fullName || 'Dealer',
|
dealerName: dealerUser.fullName || 'Dealer',
|
||||||
status: targetStage,
|
dealerId: dealerUser.id,
|
||||||
|
actionUserFullName: userFullName || 'System',
|
||||||
|
action: action || `Moved to ${targetStage}`,
|
||||||
remarks: remarkText,
|
remarks: remarkText,
|
||||||
link: `${portalBase}/constitutional-change/${request.id}`,
|
link: `${portalBase}/constitutional-change/${request.id}`
|
||||||
ctaLabel: 'View request'
|
|
||||||
}
|
}
|
||||||
}).catch((err) => console.error('[ConstitutionalWorkflowService] notify failed:', err));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return request;
|
return request;
|
||||||
|
|||||||
@ -22,13 +22,33 @@ export class NotificationService {
|
|||||||
|
|
||||||
// 1. System Notification (In-app) - Always synchronous for immediate feedback
|
// 1. System Notification (In-app) - Always synchronous for immediate feedback
|
||||||
if (channels.includes('system') && userId) {
|
if (channels.includes('system') && userId) {
|
||||||
await Notification.create({
|
try {
|
||||||
|
const notification = await Notification.create({
|
||||||
userId,
|
userId,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
type: metadata?.type || 'info',
|
type: metadata?.type || 'info',
|
||||||
|
link: placeholders?.link || metadata?.link || null,
|
||||||
isRead: false
|
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)
|
// 2. Offload other channels to Job Queue (BullMQ)
|
||||||
|
|||||||
@ -11,7 +11,9 @@ const {
|
|||||||
Application,
|
Application,
|
||||||
District,
|
District,
|
||||||
Region,
|
Region,
|
||||||
Zone
|
Zone,
|
||||||
|
Outlet,
|
||||||
|
RelocationRequest
|
||||||
} = db;
|
} = db;
|
||||||
|
|
||||||
export class ParticipantService {
|
export class ParticipantService {
|
||||||
@ -76,32 +78,49 @@ export class ParticipantService {
|
|||||||
/**
|
/**
|
||||||
* Assign participants for Termination Request
|
* Assign participants for Termination Request
|
||||||
*/
|
*/
|
||||||
static async assignTerminationParticipants(terminationId: string) {
|
static async assignTerminationParticipants(requestId: string) {
|
||||||
try {
|
try {
|
||||||
const termination = await TerminationRequest.findByPk(terminationId);
|
const termination = await db.TerminationRequest.findByPk(requestId);
|
||||||
if (!termination) return;
|
if (!termination) {
|
||||||
|
console.error(`[ParticipantService] Termination Request not found: ${requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const participantIds = new Set<string>();
|
const participantIds = new Set<string>();
|
||||||
|
|
||||||
// 0. The Dealer (Requester) should be a participant
|
// 0. The Dealer themselves (Affected Party) should be a participant
|
||||||
if (termination.dealerId) {
|
if (termination.dealerId) {
|
||||||
// Find user account for this dealer
|
// In Termination, dealerId is likely the Dealer Profile ID,
|
||||||
const dealerUser = await User.findOne({ where: { dealerId: termination.dealerId } });
|
// 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);
|
if (dealerUser) participantIds.add(dealerUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Initiator (Admin who started termination)
|
|
||||||
if (termination.initiatedBy) participantIds.add(termination.initiatedBy);
|
|
||||||
|
|
||||||
// 1. Location based managers
|
// 1. Location based managers
|
||||||
|
if (termination.dealerId) {
|
||||||
const managers = await this.getDealerLocationManagers(termination.dealerId);
|
const managers = await this.getDealerLocationManagers(termination.dealerId);
|
||||||
if (managers) {
|
if (managers) {
|
||||||
|
if (managers.asmId) participantIds.add(managers.asmId);
|
||||||
if (managers.rbmId) participantIds.add(managers.rbmId);
|
if (managers.rbmId) participantIds.add(managers.rbmId);
|
||||||
if (managers.zbhId) participantIds.add(managers.zbhId);
|
if (managers.zbhId) participantIds.add(managers.zbhId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
];
|
||||||
|
|
||||||
// 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];
|
|
||||||
const nationalUsers = await User.findAll({
|
const nationalUsers = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
roleCode: { [Op.in]: nationalRoles },
|
roleCode: { [Op.in]: nationalRoles },
|
||||||
@ -115,11 +134,16 @@ export class ParticipantService {
|
|||||||
// 3. Add all unique participants
|
// 3. Add all unique participants
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
for (const userId of participantIds) {
|
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++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ParticipantService] Added ${addedCount} participants to termination ${terminationId}`);
|
console.log(`[ParticipantService] Added ${addedCount} participants to termination ${requestId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error assigning termination participants:', error);
|
console.error('Error assigning termination participants:', error);
|
||||||
}
|
}
|
||||||
@ -152,7 +176,17 @@ export class ParticipantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. National roles
|
// 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({
|
const nationalUsers = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
roleCode: { [Op.in]: nationalRoles },
|
roleCode: { [Op.in]: nationalRoles },
|
||||||
@ -212,7 +246,10 @@ export class ParticipantService {
|
|||||||
// 2. National roles - Essential for workflow transparency
|
// 2. National roles - Essential for workflow transparency
|
||||||
const nationalRoles = [
|
const nationalRoles = [
|
||||||
ROLES.DD_LEAD,
|
ROLES.DD_LEAD,
|
||||||
|
ROLES.DD_HEAD,
|
||||||
ROLES.NBH,
|
ROLES.NBH,
|
||||||
|
ROLES.CCO,
|
||||||
|
ROLES.CEO,
|
||||||
ROLES.DD_ADMIN,
|
ROLES.DD_ADMIN,
|
||||||
ROLES.FINANCE,
|
ROLES.FINANCE,
|
||||||
ROLES.LEGAL_ADMIN,
|
ROLES.LEGAL_ADMIN,
|
||||||
@ -243,4 +280,131 @@ export class ParticipantService {
|
|||||||
console.error('Error assigning resignation participants:', error);
|
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<string>();
|
||||||
|
|
||||||
|
// 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<string>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,20 +93,23 @@ export class RelocationWorkflowService {
|
|||||||
if (dealerUser?.email) {
|
if (dealerUser?.email) {
|
||||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
const stageLabel = request.currentStage || request.status || targetStatus;
|
const stageLabel = request.currentStage || request.status || targetStatus;
|
||||||
await NotificationService.notify(dealerUser.id, dealerUser.email, {
|
|
||||||
title: `Relocation update: ${request.requestId}`,
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||||
message: `Your relocation request status changed — ${stageLabel}.`,
|
|
||||||
channels: ['email', 'whatsapp', 'system'],
|
await notifyStakeholdersOnTransition(
|
||||||
templateCode: 'RELOCATION_UPDATE',
|
request.id,
|
||||||
placeholders: {
|
REQUEST_TYPES.RELOCATION,
|
||||||
|
stageLabel,
|
||||||
|
{
|
||||||
|
code: request.requestId,
|
||||||
dealerName: dealerUser.fullName || 'Dealer',
|
dealerName: dealerUser.fullName || 'Dealer',
|
||||||
requestId: request.requestId,
|
dealerId: dealerUser.id,
|
||||||
status: stageLabel,
|
actionUserFullName: user ? user.fullName : 'System',
|
||||||
|
action: action || `Transitioned to ${targetStatus}`,
|
||||||
remarks: reason || 'N/A',
|
remarks: reason || 'N/A',
|
||||||
link: `${portalBase}/relocation-requests/${request.id}`,
|
link: `${portalBase}/relocation-requests/${request.id}`
|
||||||
ctaLabel: 'View request'
|
|
||||||
}
|
}
|
||||||
}).catch((err) => logger.error('[RelocationWorkflowService] email notify failed:', err));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return request;
|
return request;
|
||||||
|
|||||||
@ -89,19 +89,23 @@ export class ResignationWorkflowService {
|
|||||||
if (user) {
|
if (user) {
|
||||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
await NotificationService.notify(user.id, user.email, {
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||||
title: `Resignation Update: ${targetStage}`,
|
|
||||||
message: `Your resignation request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`,
|
await notifyStakeholdersOnTransition(
|
||||||
channels: ['email', 'whatsapp', 'system'],
|
resignation.id,
|
||||||
templateCode: 'RESIGNATION_UPDATE',
|
REQUEST_TYPES.RESIGNATION,
|
||||||
placeholders: {
|
targetStage,
|
||||||
status: targetStage,
|
{
|
||||||
|
code: resignation.resignationId || resignation.id,
|
||||||
dealerName: user.fullName || 'Dealer',
|
dealerName: user.fullName || 'Dealer',
|
||||||
|
dealerId: user.id,
|
||||||
|
actionUserFullName: actor ? actor.fullName : 'System',
|
||||||
|
action: action || `Approved to ${targetStage}`,
|
||||||
remarks: remarks || 'N/A',
|
remarks: remarks || 'N/A',
|
||||||
link: `${portalBase}/dealer-resignation/${resignation.id}`,
|
link: `${portalBase}/dealer-resignation/${resignation.id}`
|
||||||
ctaLabel: 'View request',
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
|
||||||
// 6. Deactivate User Account on final completion (SRS 1.1.5 / 2.3.5)
|
// 6. Deactivate User Account on final completion (SRS 1.1.5 / 2.3.5)
|
||||||
if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) {
|
if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import logger from '../common/utils/logger.js';
|
|||||||
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
|
import { ParticipantService } from './ParticipantService.js';
|
||||||
|
|
||||||
export class TerminationWorkflowService {
|
export class TerminationWorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -121,21 +122,24 @@ export class TerminationWorkflowService {
|
|||||||
ctaLabel: 'Submit response'
|
ctaLabel: 'Submit response'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
await NotificationService.notify(user.id, user.email, {
|
|
||||||
title: `Termination Status Update: ${targetStage}`,
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||||
message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`,
|
|
||||||
channels: ['email', 'whatsapp', 'system'],
|
await notifyStakeholdersOnTransition(
|
||||||
templateCode: 'TERMINATION_UPDATE',
|
termination.id,
|
||||||
placeholders: {
|
REQUEST_TYPES.TERMINATION,
|
||||||
status: targetStage,
|
targetStage,
|
||||||
|
{
|
||||||
|
code: termination.requestId,
|
||||||
dealerName: user.fullName || 'Dealer',
|
dealerName: user.fullName || 'Dealer',
|
||||||
|
dealerId: user.id,
|
||||||
|
actionUserFullName: actor ? actor.fullName : 'System',
|
||||||
|
action: action || `Approved to ${targetStage}`,
|
||||||
remarks: remarks || 'N/A',
|
remarks: remarks || 'N/A',
|
||||||
link: dealerPortalLink,
|
link: dealerPortalLink
|
||||||
ctaLabel: 'View details'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5)
|
// 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
|
// We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued
|
||||||
@ -168,40 +172,42 @@ export class TerminationWorkflowService {
|
|||||||
const dealerProfile = await Dealer.findByPk(termination.dealerId);
|
const dealerProfile = await Dealer.findByPk(termination.dealerId);
|
||||||
if (!dealerProfile) throw new Error('Dealer record not found for termination');
|
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).
|
// 2. Resolve or Create FnF Settlement
|
||||||
const fnf = await FnF.create({
|
let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } });
|
||||||
|
let fnfId = fnf?.id;
|
||||||
|
|
||||||
|
if (!fnf) {
|
||||||
|
fnf = await db.FnF.create({
|
||||||
settlementId: NomenclatureService.generateFnFId(),
|
settlementId: NomenclatureService.generateFnFId(),
|
||||||
terminationRequestId: termination.id,
|
terminationRequestId: terminationId,
|
||||||
dealerId: termination.dealerId,
|
dealerId: termination.dealerId,
|
||||||
outletId: primaryOutlet?.id || null,
|
outletId: primaryOutlet?.id || null,
|
||||||
status: 'Initiated',
|
status: 'Initiated',
|
||||||
totalReceivables: 0,
|
totalReceivables: 0,
|
||||||
totalPayables: 0,
|
totalPayables: 0,
|
||||||
netAmount: 0
|
netAmount: 0
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
// 3. Initialize CLEARANCE JSON Structure in TerminationRequest (Matching Resignation module)
|
|
||||||
const initialClearances: Record<string, any> = {};
|
|
||||||
FNF_DEPARTMENTS.forEach(dept => {
|
|
||||||
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await termination.update({ departmentalClearances: initialClearances }, { transaction });
|
await db.FffClearance.bulkCreate(
|
||||||
|
|
||||||
// 4. Initialize individual FffClearance records for tracking (Unified Dashboard)
|
|
||||||
await FffClearance.bulkCreate(
|
|
||||||
FNF_DEPARTMENTS.map(dept => ({
|
FNF_DEPARTMENTS.map(dept => ({
|
||||||
fnfId: fnf.id,
|
fnfId: fnf.id,
|
||||||
department: dept,
|
department: dept,
|
||||||
status: 'Pending'
|
status: 'Pending'
|
||||||
})),
|
}))
|
||||||
{ transaction }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Sync Deactivation to SAP
|
fnfId = fnf.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. External SAP Sync
|
||||||
ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive')
|
ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive')
|
||||||
.catch(err => console.error('Error syncing termination deactivation to SAP:', err));
|
.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;
|
return fnf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`);
|
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
|
||||||
|
|
||||||
return application;
|
return application;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user