458 lines
21 KiB
TypeScript
458 lines
21 KiB
TypeScript
import db from '../../database/models/index.js';
|
|
import { Op } from 'sequelize';
|
|
import { sendEmail } from './email.service.js';
|
|
import { NotificationService } from '../../services/NotificationService.js';
|
|
import {
|
|
APPLICATION_STAGES,
|
|
TERMINATION_STAGES,
|
|
CONSTITUTIONAL_STAGES,
|
|
RELOCATION_STAGES,
|
|
REQUEST_TYPES,
|
|
ROLES
|
|
} from '../config/constants.js';
|
|
|
|
const { RequestParticipant, User, Outlet, District } = db;
|
|
|
|
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
|
|
/** Dealer acknowledgement + internal reviewers after resignation is created. */
|
|
export async function notifyResignationSubmittedEmails(resignation: any): Promise<void> {
|
|
const dealerUser = await User.findByPk(resignation.dealerId, {
|
|
attributes: ['id', 'email', 'fullName', 'mobileNumber']
|
|
});
|
|
if (!dealerUser?.email) return;
|
|
|
|
const base = frontendBase();
|
|
const resignationCode = resignation.resignationId || resignation.id;
|
|
const lwd =
|
|
resignation.lastOperationalDateSales ||
|
|
resignation.lastOperationalDateServices ||
|
|
'As per application';
|
|
const dealerName = dealerUser.fullName || 'Dealer';
|
|
const dealerPhone = (dealerUser as any).mobileNumber || (dealerUser as any).phone || null;
|
|
|
|
// SRS §1.1.1 — submission acknowledgement via email + WhatsApp
|
|
await sendEmail(
|
|
dealerUser.email,
|
|
`We received your resignation request — ${resignationCode}`,
|
|
'RESIGNATION_RECEIVED',
|
|
{
|
|
dealerName,
|
|
resignationId: resignationCode,
|
|
lwd: String(lwd),
|
|
link: `${base}/dealer-resignation/${resignation.id}`,
|
|
ctaLabel: 'View request'
|
|
}
|
|
).catch((err) => console.error('[notifyResignationSubmittedEmails] dealer ack:', err));
|
|
|
|
// WhatsApp acknowledgement to dealer
|
|
if (dealerPhone) {
|
|
await NotificationService.notify(dealerUser.id, dealerUser.email, {
|
|
title: `Resignation request received — ${resignationCode}`,
|
|
message: `Hi ${dealerName}, your resignation request has been received.`,
|
|
channels: ['whatsapp'],
|
|
templateCode: 'RESIGNATION_RECEIVED',
|
|
placeholders: { dealerName, resignationId: resignationCode, lwd: String(lwd), link: `${base}/dealer-resignation/${resignation.id}`, phone: dealerPhone }
|
|
}).catch((err) => console.error('[notifyResignationSubmittedEmails] dealer whatsapp:', err));
|
|
}
|
|
|
|
const participants = await RequestParticipant.findAll({
|
|
where: {
|
|
requestId: resignation.id,
|
|
requestType: REQUEST_TYPES.RESIGNATION,
|
|
userId: { [Op.ne]: resignation.dealerId }
|
|
},
|
|
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
|
|
});
|
|
|
|
const internalLink = `${base}/resignation/${resignation.id}`;
|
|
for (const p of participants) {
|
|
const u = (p as any).user;
|
|
if (!u?.email) continue;
|
|
const uPhone = u.mobileNumber || u.phone || null;
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `New resignation request: ${resignationCode}`,
|
|
message: `Submitted by ${dealerName}.`,
|
|
channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: 'RESIGNATION_SUBMITTED',
|
|
placeholders: {
|
|
dealerName,
|
|
resignationId: resignationCode,
|
|
lwd: String(lwd),
|
|
link: internalLink,
|
|
ctaLabel: 'Review resignation',
|
|
phone: uPhone || ''
|
|
}
|
|
}).catch((err) => console.error('[notifyResignationSubmittedEmails] internal:', err));
|
|
}
|
|
}
|
|
|
|
/** Internal reviewers + dealer WhatsApp after constitutional request is created.
|
|
* SRS §1.1.1 — WhatsApp is a supported submission notification channel. */
|
|
export async function notifyConstitutionalSubmittedEmails(request: any, dealerDisplayName: string): Promise<void> {
|
|
const participants = await RequestParticipant.findAll({
|
|
where: {
|
|
requestId: request.id,
|
|
requestType: REQUEST_TYPES.CONSTITUTIONAL,
|
|
userId: { [Op.ne]: request.dealerId }
|
|
},
|
|
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
|
|
});
|
|
|
|
const base = frontendBase();
|
|
const link = `${base}/constitutional-change/${request.id}`;
|
|
|
|
for (const p of participants) {
|
|
const u = (p as any).user;
|
|
if (!u?.email) continue;
|
|
const uPhone = u.mobileNumber || u.phone || null;
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `New constitutional change request: ${request.requestId}`,
|
|
message: `${dealerDisplayName} submitted a request.`,
|
|
channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
|
placeholders: {
|
|
dealerName: dealerDisplayName,
|
|
changeType: request.changeType || '',
|
|
requestId: request.requestId,
|
|
link,
|
|
ctaLabel: 'Review request',
|
|
phone: uPhone || ''
|
|
}
|
|
}).catch((err) => console.error('[notifyConstitutionalSubmittedEmails]:', err));
|
|
}
|
|
}
|
|
|
|
/** Dealer + ASM when relocation request is submitted. */
|
|
export async function notifyRelocationSubmittedEmails(
|
|
request: any,
|
|
submitter: { email: string; fullName?: string | null }
|
|
): Promise<void> {
|
|
const base = frontendBase();
|
|
const code = request.requestId || request.id;
|
|
const dealerName = submitter.fullName?.trim() || 'Dealer';
|
|
|
|
if (submitter.email) {
|
|
await sendEmail(
|
|
submitter.email,
|
|
`Relocation request received — ${code}`,
|
|
'RELOCATION_RECEIVED',
|
|
{
|
|
dealerName,
|
|
requestId: code,
|
|
link: `${base}/relocation-requests/${request.id}`,
|
|
ctaLabel: 'View request',
|
|
distance: request.distance || '0'
|
|
}
|
|
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
|
|
}
|
|
|
|
const outlet = await Outlet.findByPk(request.outletId, {
|
|
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
|
});
|
|
const asmId = (outlet as any)?.district?.asmId;
|
|
if (!asmId) return;
|
|
|
|
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
|
if (!asm?.email) return;
|
|
|
|
const asmPhone = (asm as any).mobileNumber || (asm as any).phone || null;
|
|
await NotificationService.notify(asm.id, asm.email, {
|
|
title: `New relocation request: ${code}`,
|
|
message: 'A dealer submitted an outlet relocation request.',
|
|
channels: asmPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: 'RELOCATION_SUBMITTED',
|
|
placeholders: {
|
|
dealerName,
|
|
requestId: code,
|
|
outletCode: outlet?.code || 'N/A',
|
|
link: `${base}/relocation-requests/${request.id}`,
|
|
ctaLabel: 'Review request',
|
|
distance: request.distance || '0',
|
|
phone: asmPhone || ''
|
|
}
|
|
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
|
}
|
|
|
|
/**
|
|
* Resolves the user IDs of the required 'next actor' based on the workflow stage.
|
|
* Updated: Now awareness of sequential flows (e.g., LOI/LOA) to prevent parallel notifications.
|
|
*/
|
|
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>();
|
|
|
|
const stageRoleMap: Record<string, string[]> = {
|
|
// --- Common/Shared Stages ---
|
|
'DD': [ROLES.ASM],
|
|
'ASM': [ROLES.ASM],
|
|
'ASM Review': [ROLES.ASM],
|
|
'RBM': [ROLES.RBM],
|
|
'RBM + DD-ZM Review': [ROLES.RBM, ROLES.DD_ZM],
|
|
'ZM Review': [ROLES.DD_ZM],
|
|
'DD ZM Review': [ROLES.DD_ZM],
|
|
'ZBH': [ROLES.ZBH],
|
|
'ZBH Review': [ROLES.ZBH],
|
|
'DD Lead': [ROLES.DD_LEAD],
|
|
'DD Lead Review': [ROLES.DD_LEAD],
|
|
'DD Head': [ROLES.DD_HEAD],
|
|
'DD Head Review': [ROLES.DD_HEAD],
|
|
'DD Head Approval': [ROLES.DD_HEAD],
|
|
'NBH': [ROLES.NBH],
|
|
'NBH Approval': [ROLES.NBH],
|
|
'NBH Evaluation': [ROLES.NBH],
|
|
'NBH Final Approval': [ROLES.NBH],
|
|
'Legal': [ROLES.LEGAL_ADMIN],
|
|
'Legal Review': [ROLES.LEGAL_ADMIN],
|
|
'Legal Clearance': [ROLES.LEGAL_ADMIN],
|
|
'Legal Verification': [ROLES.LEGAL_ADMIN],
|
|
'Finance': [ROLES.FINANCE],
|
|
'CCO Approval': [ROLES.CCO],
|
|
'CEO Final Approval': [ROLES.CEO],
|
|
|
|
// --- Onboarding Specific (Sequential Flows) ---
|
|
'Level 1 Interview': [ROLES.DD_ZM, ROLES.RBM],
|
|
'Level 1 Interview Pending': [ROLES.DD_ZM, ROLES.RBM],
|
|
'Interview Level 1': [ROLES.DD_ZM, ROLES.RBM],
|
|
'Level 2 Interview': [ROLES.ZBH, ROLES.DD_LEAD],
|
|
'Level 2 Interview Pending': [ROLES.ZBH, ROLES.DD_LEAD],
|
|
'Interview Level 2': [ROLES.ZBH, ROLES.DD_LEAD],
|
|
'Level 3 Interview': [ROLES.NBH, ROLES.DD_HEAD],
|
|
'Level 3 Interview Pending': [ROLES.NBH, ROLES.DD_HEAD],
|
|
'Interview Level 3': [ROLES.NBH, ROLES.DD_HEAD],
|
|
|
|
// LOI and LOA follow (DD Head -> NBH) sequence
|
|
'LOI Approval': [ROLES.DD_HEAD, ROLES.NBH],
|
|
'LOI In Progress': [ROLES.DD_HEAD, ROLES.NBH],
|
|
'LOA Approval': [ROLES.DD_HEAD, ROLES.NBH],
|
|
'LOA Pending': [ROLES.DD_HEAD, ROLES.NBH],
|
|
|
|
'FDD Verification': [ROLES.FDD],
|
|
'FDD_VERIFICATION': [ROLES.FDD],
|
|
'Architecture Team Assigned': [ROLES.ARCHITECTURE],
|
|
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
|
|
|
// --- Relocation/Constitutional Specific ---
|
|
'NBH Clearance with EOR': [ROLES.NBH],
|
|
'Submitted': [ROLES.ASM],
|
|
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
|
|
|
// --- Resignation Specific ---
|
|
'DD Admin': [ROLES.DD_ADMIN],
|
|
'Spares Clearance': [ROLES.SPARES_MANAGER],
|
|
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
|
'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER],
|
|
'F&F Initiated': [ROLES.DD_ADMIN],
|
|
// SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM
|
|
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
|
|
|
|
// --- Termination Specific ---
|
|
'Show Cause Notice': [ROLES.LEGAL_ADMIN],
|
|
'Personal Hearing': [ROLES.NBH, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD],
|
|
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
|
|
};
|
|
|
|
const configRoles = stageRoleMap[newStage] || [];
|
|
let expectedRoles = configRoles;
|
|
|
|
// Sequential Logic for LOI/LOA (DD Head -> NBH)
|
|
const sequentialStages = ['LOI Approval', 'LOI In Progress', 'LOA Approval', 'LOA Pending'];
|
|
if (sequentialStages.includes(newStage) && configRoles.includes(ROLES.DD_HEAD) && configRoles.includes(ROLES.NBH)) {
|
|
// Fetch existing approvals for this stage
|
|
const approvals = await (db as any).StageApprovalAction.findAll({
|
|
where: { applicationId: requestId, stageCode: newStage, decision: 'Approved' },
|
|
attributes: ['actorRole']
|
|
});
|
|
const approvedRoles = new Set(approvals.map((a: any) => a.actorRole));
|
|
|
|
if (!approvedRoles.has(ROLES.DD_HEAD)) {
|
|
// DD Head hasn't approved yet, so ONLY notify DD Head
|
|
expectedRoles = [ROLES.DD_HEAD];
|
|
} else if (!approvedRoles.has(ROLES.NBH)) {
|
|
// DD Head has approved, but NBH hasn't, so ONLY notify NBH
|
|
expectedRoles = [ROLES.NBH];
|
|
}
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Channels per scenario:
|
|
* - Next actor (action required) → in-app + email + WhatsApp (SRS §6.14.3)
|
|
* - ASM on send-back → in-app + email + WhatsApp (SRS §6.14.3 — pending user actions)
|
|
* - Dealer on terminal event → in-app + email + WhatsApp (SRS §2052 — rejection/completion)
|
|
* - Dealer on interim stage → in-app only
|
|
* - Key observers on terminal → in-app only
|
|
*/
|
|
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', 'mobileNumber'] }]
|
|
});
|
|
|
|
const nextActorIds = await resolveNextActors(requestId, requestType, targetStage);
|
|
|
|
const isSendBack = /\b(send|sent)\s*back\b/i.test(metadata.action);
|
|
const isRejected = /\b(reject)/i.test(targetStage) || /\b(reject)/i.test(metadata.action);
|
|
const isRevoked = /\b(revok)/i.test(targetStage) || /\b(revok)/i.test(metadata.action);
|
|
const isWithdrawn = /\b(withdraw)/i.test(targetStage) || /\b(withdraw)/i.test(metadata.action);
|
|
const isCompleted = /\b(complete|settled|terminated|fnf)/i.test(targetStage);
|
|
const isTerminalEvent = isRejected || isRevoked || isWithdrawn || isCompleted;
|
|
|
|
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;
|
|
const isActingUser = u.fullName === metadata.actionUserFullName;
|
|
|
|
// Roles that should receive observer alerts on terminal events
|
|
const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || '');
|
|
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
|
|
|
|
// Phone for WhatsApp — directly on include'd user object
|
|
const phone: string | null = (u as any).mobileNumber || null;
|
|
|
|
// Skip the person who just acted (they already know)
|
|
if (isActingUser && !isNextActor) continue;
|
|
|
|
if (isNextActor) {
|
|
// ── Next Approver: Action Required — WhatsApp + Email + In-App ──
|
|
// SRS §6.14.3: critical workflow actions (assignment, scheduling) trigger via email & WhatsApp
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `Action Required: ${metadata.code}`,
|
|
message: `${metadata.code} has reached "${targetStage}" and requires your review.`,
|
|
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
|
placeholders: {
|
|
dealerName: metadata.dealerName,
|
|
requestId: metadata.code,
|
|
link: metadata.link,
|
|
targetStage,
|
|
phone: phone || ''
|
|
}
|
|
}).catch(e => console.error('[notifyStakeholders] next-actor:', e));
|
|
|
|
} else if (isSendBack && isASM) {
|
|
// ── Send Back → notify ASM via WhatsApp + Email + In-App ──
|
|
// SRS §6.14.3: pending user actions trigger email & WhatsApp
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `Case Returned for Clarification: ${metadata.code}`,
|
|
message: `${metadata.code} has been sent back. Please review remarks and resubmit.`,
|
|
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
|
placeholders: {
|
|
dealerName: metadata.dealerName,
|
|
requestId: metadata.code,
|
|
link: metadata.link,
|
|
targetStage,
|
|
remarks: metadata.remarks || 'No remarks provided',
|
|
phone: phone || ''
|
|
}
|
|
}).catch(e => console.error('[notifyStakeholders] send-back-asm:', e));
|
|
|
|
} else if (isDealer) {
|
|
// ── Dealer: in-app always; email + WhatsApp only on terminal events ──
|
|
const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email'];
|
|
if (phone) terminalChannels.push('whatsapp');
|
|
|
|
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
|
|
const placeholders: any = {
|
|
requestId: metadata.code,
|
|
link: metadata.link,
|
|
targetStage,
|
|
dealerName: metadata.dealerName,
|
|
phone: phone || ''
|
|
};
|
|
|
|
// Override for Termination Final Closure
|
|
if (targetStage === TERMINATION_STAGES.TERMINATED) {
|
|
templateCode = 'TERMINATION_FINAL_CLOSURE_DEALER';
|
|
placeholders.terminationDate = new Date().toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
|
}
|
|
|
|
// Override for Constitutional Change Completion
|
|
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
|
|
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED';
|
|
placeholders.proposedConstitution = metadata.remarks || 'Approved Structure'; // Remarks often contain the final structure or approval note
|
|
}
|
|
|
|
// Override for Relocation Completion
|
|
if (targetStage === RELOCATION_STAGES.COMPLETED && requestType === REQUEST_TYPES.RELOCATION) {
|
|
templateCode = 'RELOCATION_APPROVED';
|
|
placeholders.newLocation = metadata.remarks || 'Approved Location'; // Remarks usually contain the site address
|
|
}
|
|
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: isTerminalEvent
|
|
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
|
|
: `Application Update: ${metadata.code}`,
|
|
message: `Your request is now at "${targetStage}". ${metadata.action}`,
|
|
channels: isTerminalEvent ? terminalChannels : ['system'],
|
|
templateCode,
|
|
placeholders
|
|
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
|
|
|
|
} else if (isTerminalEvent && isKeyObserverRole) {
|
|
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events ──
|
|
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
|
|
const placeholders: any = {
|
|
requestId: metadata.code,
|
|
link: metadata.link,
|
|
targetStage,
|
|
recipientName: u.fullName || 'Team'
|
|
};
|
|
|
|
// Override for Internal Notification of Legal Letter
|
|
if (targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
|
|
templateCode = 'TERMINATION_LETTER_ISSUED';
|
|
}
|
|
|
|
await NotificationService.notify(u.id, u.email, {
|
|
title: `Case Closed: ${metadata.code}`,
|
|
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
|
|
channels: ['system', 'email'], // Internal teams get email too on closure
|
|
templateCode,
|
|
placeholders
|
|
}).catch(e => console.error('[notifyStakeholders] observer:', e));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[notifyStakeholdersOnTransition] error:', error);
|
|
}
|
|
}
|