DD Lead treated as natiolwide user and in app notification enhanced

This commit is contained in:
laxmanhalaki 2026-04-20 01:51:09 +05:30
parent 4fa1898824
commit 5004508e91
24 changed files with 865 additions and 216 deletions

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@ -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

View File

@ -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);
}
}

View File

@ -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).

View File

@ -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;

View File

@ -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>[] = [];

View File

@ -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' });
}

View File

@ -1108,8 +1108,6 @@ export const getDDLeads = async (req: Request, res: Response) => {
{
model: db.UserRole,
as: 'userRoles',
where: { isActive: true },
required: true,
include: [{
model: db.Role,
as: 'role',
@ -1117,24 +1115,14 @@ export const getDDLeads = async (req: Request, res: Response) => {
}]
}
],
order: [['fullName', 'ASC']]
order: [['status', 'ASC'], ['fullName', 'ASC']]
});
const result = (ddLeads || []).map((u: any) => {
const roleAssignment = (u.userRoles || []).find((r: any) => (r.role?.roleCode === 'DD Lead'));
const leadCode = roleAssignment?.managerCode || u.employeeId || 'N/A';
const isActive = roleAssignment?.isActive || false;
// Collect unique zones from all active DD Lead roles for this user
const zoneMap = new Map();
(u.userRoles || []).forEach((ur: any) => {
if (ur.role?.roleCode === 'DD Lead' && ur.zoneId) {
// We need the zone name, but it's not included in this query.
// We'll rely on the frontend to map names or fetch them if missing.
// However, it's better to include Zone in the include.
zoneMap.set(ur.zoneId, ur.zoneId);
}
});
return {
id: u.id,
name: u.fullName,
@ -1142,13 +1130,10 @@ export const getDDLeads = async (req: Request, res: Response) => {
employeeId: u.employeeId,
leadCode: leadCode,
status: u.status,
assignedZoneIds: Array.from(zoneMap.values())
isActiveLead: isActive
};
});
// To get zone names, we'd need another query or better include.
// Let's refine the include to get zone names.
res.json({ success: true, data: result });
} catch (error) {
console.error('Get DD Leads error:', error);
@ -1158,7 +1143,7 @@ export const getDDLeads = async (req: Request, res: Response) => {
export const saveDDLead = async (req: Request, res: Response) => {
try {
const { userId, leadCode, zoneIds, status } = req.body;
const { userId, leadCode, status, isActive } = req.body;
if (!userId) return res.status(400).json({ success: false, message: 'userId is required' });
const leadRole = await db.Role.findOne({ where: { roleCode: 'DD Lead' } });
@ -1168,36 +1153,44 @@ export const saveDDLead = async (req: Request, res: Response) => {
await db.User.update({ status }, { where: { id: userId } });
}
// Deactivate existing DD Lead roles for this user
// --- SINGLETON VALIDATION ---
// If we are trying to activate this user as DD Lead, check if another active lead exists
if (isActive === true || isActive === 'true') {
const existingActiveLead = await db.UserRole.findOne({
where: {
roleId: leadRole.id,
isActive: true,
userId: { [db.Sequelize.Op.ne]: userId } // Exclude current user
},
include: [{ model: db.User, as: 'user' }]
});
if (existingActiveLead) {
return res.status(409).json({
success: false,
message: `An active DD Lead already exists (${existingActiveLead.user?.fullName}). Please deactivate the current lead before assigning a new one.`
});
}
}
// Deactivate existing DD Lead roles for this user to start fresh (National approach)
await db.UserRole.update({ isActive: false }, {
where: { userId, roleId: leadRole.id }
});
// Create new role assignments for each zone
if (Array.isArray(zoneIds) && zoneIds.length > 0) {
for (const zoneId of zoneIds) {
await db.UserRole.create({
userId,
roleId: leadRole.id,
zoneId: zoneId,
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
isActive: true,
isPrimary: true
});
}
} else {
// Case with no specific zone
await db.UserRole.create({
userId,
roleId: leadRole.id,
zoneId: null,
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
isActive: true,
isPrimary: true
});
}
// Create/Activate the single National DD Lead role assignment
await db.UserRole.create({
userId,
roleId: leadRole.id,
zoneId: null, // Global scope
regionId: null, // Global scope
districtId: null, // Global scope
managerCode: await resolveManagerCode(leadRole.id, 'DD Lead', null),
isActive: isActive !== undefined ? (isActive === true || isActive === 'true') : true,
isPrimary: true
});
res.json({ success: true, message: 'DD Lead saved successfully' });
res.json({ success: true, message: 'DD Lead updated successfully' });
} catch (error) {
console.error('Save DD Lead error:', error);
res.status(500).json({ success: false, message: 'Error saving DD Lead' });

View File

@ -656,19 +656,18 @@ const assignStageEvaluators = async (appIdOrId: string) => {
if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' });
if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' });
// Level 2: ZBH (Zone manager) + DD Lead (Filtered by Zone)
// Level 2: ZBH (Zone manager) + DD Lead (National Singleton)
if (zone && zone.zbhId) evaluatorMappings[2].push({ id: zone.zbhId, role: 'ZBH' });
if (zone) {
const ddLead = await db.User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { zoneId: zone.id, isActive: true }
}]
});
if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' });
}
const ddLead = await db.User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { isActive: true } // Removed zoneId filter
}]
});
if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' });
// Level 3: NBH + DD Head (National Level Roles)
const level3Roles = ['NBH', 'DD Head'];

View File

@ -12,6 +12,7 @@ import { AuthRequest } from '../../types/express.types.js';
import { formatDateTime } from '../../common/utils/dateUtils.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { notifyRelocationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
import { ParticipantService } from '../../services/ParticipantService.js';
const resolveRelocationUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'relocation');
@ -153,19 +154,17 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW });
}
// Stage 5: DD Lead (zone-scoped)
if (zone) {
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { zoneId: zone.id, isActive: true }
}]
});
if (ddLead) {
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
}
// Stage 5: DD Lead (National Singleton)
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { isActive: true } // Removed zoneId filter
}]
});
if (ddLead) {
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
}
// Stage 6: DD Head (national)
@ -352,8 +351,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
}]
});
// Auto-assign evaluators based on outlet location hierarchy
await assignRelocationEvaluators(request.id, outletId);
// Auto-assign participants using ParticipantService
await ParticipantService.assignRelocationParticipants(request.id);
await db.RelocationAudit.create({
userId: req.user.id,
@ -527,18 +526,16 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }
];
// Get DD Lead (zone-scoped)
if (zone) {
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { zoneId: zone.id, isActive: true }
}]
});
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
}
// Get DD Lead (National Singleton)
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { isActive: true } // Removed zoneId filter
}]
});
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
// Get DD Head (national)
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
@ -706,7 +703,7 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
return Math.min(100, Math.round(((i + 1) / (RELOCATION_PIPELINE_STAGES.length + 1)) * 100));
};
let actionType = OFFBOARDING_ACTIONS.APPROVE;
let actionType: string = OFFBOARDING_ACTIONS.APPROVE;
if (normalizedAction === 'APPROVE') {
newCurrentStage = stageFlow[request.currentStage as string] || request.currentStage;

View File

@ -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) {

View File

@ -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);

View File

@ -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;

View File

@ -22,13 +22,33 @@ export class NotificationService {
// 1. System Notification (In-app) - Always synchronous for immediate feedback
if (channels.includes('system') && userId) {
await Notification.create({
userId,
title,
message,
type: metadata?.type || 'info',
isRead: false
});
try {
const notification = await Notification.create({
userId,
title,
message,
type: metadata?.type || 'info',
link: placeholders?.link || metadata?.link || null,
isRead: false
});
// Emit realtime update via Socket.io
const { getIO } = await import('../common/utils/socket.js');
const io = getIO();
if (io) {
const roomName = `user_${userId}`;
io.to(roomName).emit('notification', {
id: notification.id,
title,
message,
type: notification.type,
link: notification.link,
createdAt: notification.createdAt
});
}
} catch (err) {
console.error('[NotificationService] Failed to create system notification:', err);
}
}
// 2. Offload other channels to Job Queue (BullMQ)

View File

@ -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
const managers = await this.getDealerLocationManagers(termination.dealerId);
if (managers) {
if (managers.rbmId) participantIds.add(managers.rbmId);
if (managers.zbhId) participantIds.add(managers.zbhId);
if (termination.dealerId) {
const managers = await this.getDealerLocationManagers(termination.dealerId);
if (managers) {
if (managers.asmId) participantIds.add(managers.asmId);
if (managers.rbmId) participantIds.add(managers.rbmId);
if (managers.zbhId) participantIds.add(managers.zbhId);
}
}
// 2. National roles - Crucial for Termination Review
const nationalRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.CCO, ROLES.CEO, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
// 2. National roles
const nationalRoles = [
ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH,
ROLES.CCO,
ROLES.CEO,
ROLES.DD_ADMIN,
ROLES.FINANCE,
ROLES.LEGAL_ADMIN,
ROLES.SUPER_ADMIN
];
const nationalUsers = await User.findAll({
where: {
roleCode: { [Op.in]: nationalRoles },
@ -115,11 +134,16 @@ export class ParticipantService {
// 3. Add all unique participants
let addedCount = 0;
for (const userId of participantIds) {
await this.addParticipant(termination.id, REQUEST_TYPES.TERMINATION, userId);
// Determine type (Dealer profile id is not userId)
// We'll check if the userId matches the resolved dealer user
const isDealer = termination.dealerId && (await User.findByPk(userId))?.dealerId === termination.dealerId;
const pType = isDealer ? 'owner' : 'contributor';
await this.addParticipant(termination.id, REQUEST_TYPES.TERMINATION, userId, pType);
addedCount++;
}
console.log(`[ParticipantService] Added ${addedCount} participants to termination ${terminationId}`);
console.log(`[ParticipantService] Added ${addedCount} participants to termination ${requestId}`);
} catch (error) {
console.error('Error assigning termination participants:', error);
}
@ -152,7 +176,17 @@ export class ParticipantService {
}
// 2. National roles
const nationalRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
const nationalRoles = [
ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH,
ROLES.CCO,
ROLES.CEO,
ROLES.FINANCE,
ROLES.DD_ADMIN,
ROLES.LEGAL_ADMIN,
ROLES.SUPER_ADMIN
];
const nationalUsers = await User.findAll({
where: {
roleCode: { [Op.in]: nationalRoles },
@ -212,7 +246,10 @@ export class ParticipantService {
// 2. National roles - Essential for workflow transparency
const nationalRoles = [
ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH,
ROLES.CCO,
ROLES.CEO,
ROLES.DD_ADMIN,
ROLES.FINANCE,
ROLES.LEGAL_ADMIN,
@ -243,4 +280,131 @@ export class ParticipantService {
console.error('Error assigning resignation participants:', error);
}
}
/**
* Assign participants for Relocation Request
*/
static async assignRelocationParticipants(requestId: string) {
try {
const relocation = await db.RelocationRequest.findByPk(requestId, {
include: [{
model: Outlet,
as: 'outlet',
include: [{
model: District,
as: 'district',
include: [
{ model: Region, as: 'region' },
{ model: Zone, as: 'zone' }
]
}]
}]
});
if (!relocation) {
console.error(`[ParticipantService] Relocation not found: ${requestId}`);
return;
}
const participantIds = new Set<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);
}
}
}

View File

@ -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;

View File

@ -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) {

View File

@ -9,6 +9,7 @@ import logger from '../common/utils/logger.js';
import { NomenclatureService } from '../common/utils/nomenclature.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { ParticipantService } from './ParticipantService.js';
export class TerminationWorkflowService {
/**
@ -121,22 +122,25 @@ export class TerminationWorkflowService {
ctaLabel: 'Submit response'
}
});
} else {
await NotificationService.notify(user.id, user.email, {
title: `Termination Status Update: ${targetStage}`,
message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`,
channels: ['email', 'whatsapp', 'system'],
templateCode: 'TERMINATION_UPDATE',
placeholders: {
status: targetStage,
dealerName: user.fullName || 'Dealer',
remarks: remarks || 'N/A',
link: dealerPortalLink,
ctaLabel: 'View details'
}
});
}
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
await notifyStakeholdersOnTransition(
termination.id,
REQUEST_TYPES.TERMINATION,
targetStage,
{
code: termination.requestId,
dealerName: user.fullName || 'Dealer',
dealerId: user.id,
actionUserFullName: actor ? actor.fullName : 'System',
action: action || `Approved to ${targetStage}`,
remarks: remarks || 'N/A',
link: dealerPortalLink
}
);
// 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5)
// We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued
if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
@ -168,40 +172,42 @@ export class TerminationWorkflowService {
const dealerProfile = await Dealer.findByPk(termination.dealerId);
if (!dealerProfile) throw new Error('Dealer record not found for termination');
// 2. Create FnF with zero totals — line items only from explicit clearance / finance entry (no mock SAP seed rows).
const fnf = await FnF.create({
settlementId: NomenclatureService.generateFnFId(),
terminationRequestId: termination.id,
dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null,
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
}, { transaction });
// 2. Resolve or Create FnF Settlement
let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } });
let fnfId = fnf?.id;
// 3. Initialize CLEARANCE JSON Structure in TerminationRequest (Matching Resignation module)
const initialClearances: Record<string, any> = {};
FNF_DEPARTMENTS.forEach(dept => {
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
});
if (!fnf) {
fnf = await db.FnF.create({
settlementId: NomenclatureService.generateFnFId(),
terminationRequestId: terminationId,
dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null,
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
});
await termination.update({ departmentalClearances: initialClearances }, { transaction });
await db.FffClearance.bulkCreate(
FNF_DEPARTMENTS.map(dept => ({
fnfId: fnf.id,
department: dept,
status: 'Pending'
}))
);
fnfId = fnf.id;
}
// 4. Initialize individual FffClearance records for tracking (Unified Dashboard)
await FffClearance.bulkCreate(
FNF_DEPARTMENTS.map(dept => ({
fnfId: fnf.id,
department: dept,
status: 'Pending'
})),
{ transaction }
);
// 5. Sync Deactivation to SAP
// 3. External SAP Sync
ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive')
.catch(err => console.error('Error syncing termination deactivation to SAP:', err));
// 4. Assign Participants for F&F (Sub-application chat)
if (fnfId) {
await ParticipantService.assignFnFParticipants(fnfId);
}
return fnf;
}

View File

@ -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;