Dealer_Onboarding_Backend/src/common/utils/workflow-email-notifications.ts

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