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',
|
||||
CONSTITUTIONAL: 'constitutional',
|
||||
RELOCATION: 'relocation',
|
||||
TERMINATION: 'termination'
|
||||
TERMINATION: 'termination',
|
||||
FNF: 'fnf'
|
||||
} as const;
|
||||
|
||||
// Standardized Offboarding Actions
|
||||
|
||||
@ -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<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'
|
||||
| 'constitutional'
|
||||
| 'resignation'
|
||||
| 'termination';
|
||||
| 'termination'
|
||||
| 'fnf';
|
||||
|
||||
/**
|
||||
* 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.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;
|
||||
|
||||
@ -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<any>[] = [];
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
|
||||
@ -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,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 roleAssignment = (u.userRoles || []).find((r: any) => (r.role?.roleCode === 'DD Lead'));
|
||||
const leadCode = roleAssignment?.managerCode || u.employeeId || 'N/A';
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
const isActive = roleAssignment?.isActive || false;
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
@ -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) {
|
||||
// Create/Activate the single National DD Lead role assignment
|
||||
await db.UserRole.create({
|
||||
userId,
|
||||
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),
|
||||
isActive: true,
|
||||
isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : 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) {
|
||||
console.error('Save DD Lead error:', error);
|
||||
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 (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 }
|
||||
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'];
|
||||
|
||||
@ -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,20 +154,18 @@ 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) {
|
||||
// Stage 5: DD Lead (National Singleton)
|
||||
const ddLead = await User.findOne({
|
||||
where: { roleCode: 'DD Lead', status: 'active' },
|
||||
include: [{
|
||||
model: db.UserRole,
|
||||
as: 'userRoles',
|
||||
where: { zoneId: zone.id, isActive: true }
|
||||
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)
|
||||
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
|
||||
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) {
|
||||
// Get DD Lead (National Singleton)
|
||||
const ddLead = await User.findOne({
|
||||
where: { roleCode: 'DD Lead', status: 'active' },
|
||||
include: [{
|
||||
model: db.UserRole,
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@ -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<string, string> = {
|
||||
'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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
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)
|
||||
|
||||
@ -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<string>();
|
||||
|
||||
// 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
|
||||
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
|
||||
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({
|
||||
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<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) {
|
||||
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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,21 +122,24 @@ 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,
|
||||
}
|
||||
|
||||
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,
|
||||
ctaLabel: 'View details'
|
||||
}
|
||||
});
|
||||
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
|
||||
@ -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({
|
||||
// 2. Resolve or Create FnF Settlement
|
||||
let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } });
|
||||
let fnfId = fnf?.id;
|
||||
|
||||
if (!fnf) {
|
||||
fnf = await db.FnF.create({
|
||||
settlementId: NomenclatureService.generateFnFId(),
|
||||
terminationRequestId: termination.id,
|
||||
terminationRequestId: terminationId,
|
||||
dealerId: termination.dealerId,
|
||||
outletId: primaryOutlet?.id || null,
|
||||
status: 'Initiated',
|
||||
totalReceivables: 0,
|
||||
totalPayables: 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 });
|
||||
|
||||
// 4. Initialize individual FffClearance records for tracking (Unified Dashboard)
|
||||
await FffClearance.bulkCreate(
|
||||
await db.FffClearance.bulkCreate(
|
||||
FNF_DEPARTMENTS.map(dept => ({
|
||||
fnfId: fnf.id,
|
||||
department: dept,
|
||||
status: 'Pending'
|
||||
})),
|
||||
{ transaction }
|
||||
}))
|
||||
);
|
||||
|
||||
// 5. Sync Deactivation to SAP
|
||||
fnfId = fnf.id;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user