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 { 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 { 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 { 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 { try { const participants = await RequestParticipant.findAll({ where: { requestId, requestType }, include: [{ model: User, as: 'user' }] }); const actorIds = new Set(); const stageRoleMap: Record = { // --- 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 { 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); } }