diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md new file mode 100644 index 0000000..2962c1d --- /dev/null +++ b/docs/TEST_CASES.md @@ -0,0 +1,190 @@ +# Dealer Offboarding โ€” Test Cases & QA Tracker + +> **Location:** `/backend/docs/TEST_CASES.md` +> **Last Updated:** 2026-04-20 +> **Modules Covered:** Termination, Resignation, F&F Settlement, Constitutional Change, Relocation +> **Status Legend:** โœ… Fixed | โŒ Open | ๐Ÿ”„ In Progress | โš ๏ธ Known Issue + +--- + +## 1. Termination Module + +### 1.1 Push to F&F Action + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| T-01 | Click "Push to F&F" when stage is `NBH Evaluation` | Backend returns **400** โ€” `Cannot trigger F&F from NBH Evaluation. Complete CEO and Legal approvals first.` | โœ… Fixed | Backend gate added in `termination.controller.ts` | +| T-02 | Click "Push to F&F" when stage is `Legal - Termination Letter` | F&F settlement created successfully; termination status โ†’ `F&F Initiated`; stage โ†’ `Terminated` | โœ… Fixed | Stage lock + correct status applied | +| T-03 | Click "Push to F&F" multiple times on the same record | Only one F&F record created (idempotency check via `db.FnF.findOne`) | โœ… Fixed | `TerminationWorkflowService.initiateFnF` resolves existing record | +| T-04 | After "Push to F&F", verify "Approve" button visibility | Approve button **must be hidden** โ€” workflow locked | โœ… Fixed | `isSettlementPhase` flag disables all approval actions | +| T-05 | "Push to F&F" button visible to ASM role | Button **must not be visible** to ASM | โœ… Fixed | Role restricted to `DD Lead, DD Head, NBH, DD Admin, Super Admin` | +| T-06 | "Push to F&F" button visible at early stages (e.g. ZBH Review) | Button **must be hidden** until stage is `Legal - Termination Letter` or `Terminated` | โœ… Fixed | Frontend `canPushToFnF` condition updated | +| T-07 | After "Push to F&F", another approver tries to approve | Action rejected โ€” buttons completely hidden | โœ… Fixed | `isSettlementPhase` + `isFinalState` guard | + +--- + +### 1.2 Stage Approval Flow + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| T-08 | Sending `action: "pushfnf"` via API when ticket is mid-flow | Previously fell into generic "Approve" block and advanced stage incorrectly | โœ… Fixed | Explicit `pushfnf` handler added | +| T-09 | RBM approves at `RBM Review` | Stage advances to `ZBH Review` | โœ… | Standard flow | +| T-10 | NBH at `NBH Evaluation` clicks "Issue SCN" | Stage advances to `Show Cause Notice` | โœ… | `canIssueSCN` permission guarded | +| T-11 | ASM tries to Approve/Send Back | Buttons **must not be visible** โ€” ASM is Create/View/Comment only | โœ… | SRS ยง8.3.4 enforced | + +--- + +### 1.3 Progress Timeline / Status Display + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| T-12 | Termination reaches `Dealer Terminated` stage | All stages including "Dealer Terminated" show **green checkmark** | โœ… Fixed | `isSuccessFinal` flag added to `getProgressStatus()` | +| T-13 | Termination status is `F&F Initiated` | All completed stages show green; "Dealer Terminated" shows green | โœ… Fixed | `F&F Initiated`, `Settled` included in `isSuccessFinal` | +| T-14 | Termination is `Rejected` | Shows all stages up to rejection point; no misleading pending | โœ… | `isTerminal` logic handles rejected states | + +--- + +## 2. Resignation Module + +### 2.1 Push to F&F Action + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| R-01 | Click "Push to F&F" before Legal stage | Backend returns **400** โ€” `Cannot trigger F&F from [stage]. Move request to Legal stage first.` | โœ… | Gate in `resignation.controller.ts` | +| R-02 | Click "Push to F&F" at Legal stage without a Legal-stage document | Backend returns **400** โ€” `Legal-stage acceptance/communication document is required first.` | โœ… | Document gate enforced | +| R-03 | Click "Push to F&F" at Legal stage with document present | F&F initiated; stage โ†’ `F&F Initiated` | โœ… | | +| R-04 | Dealer role attempts to withdraw after reaching NBH stage | Withdraw button **must be hidden** | โœ… | `isPastNBH` guard in `canWithdraw` | +| R-05 | "Push to F&F" visible to ASM role | Button **must NOT be visible** | โœ… | Role whitelist enforced | + +--- + +## 3. F&F Settlement Module + +### 3.1 Document Management + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| F-01 | Upload document via F&F Details page | File stored, mapped in `clearanceDocuments`, appears in Submitted Documents table | โœ… Fixed | Multipart upload implemented in `settlement.controller.ts` | +| F-02 | Download uploaded F&F document | Forces direct file download (not new tab) | โœ… Fixed | Programmatic Blob fetch + hidden `` tag | +| F-03 | Preview uploaded F&F document | โŒ Open | Pending implementation | + +### 3.2 Status Display + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| F-04 | F&F Status badge text readability | White text on green/amber badges | โœ… Fixed | `text-white` applied in `FinanceFnFPage.tsx` | + +--- + +## 4. Constitutional Change Module + +### 4.1 Document Verification Permissions + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| C-01 | ASM sees "Verify" button on uploaded documents | Button **must NOT be visible** to ASM | โœ… Fixed | Role whitelist: `DD Lead, DD Head, NBH, Legal Admin, DD Admin, Super Admin` | +| C-02 | ASM sees "Reject" button on uploaded documents | Button **must NOT be visible** to ASM | โœ… Fixed | Same role whitelist | +| C-03 | DD Lead sees "Verify" button on uploaded documents | Button **must be visible** | โœ… | | +| C-04 | NBH sees "Verify" button | Button **must be visible** | โœ… | | +| C-05 | Dealer role sees "Verify" button | Button **must NOT be visible** | โœ… | | + +--- + +## 5. Relocation Module + +### 5.1 Document Verification Permissions + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| RL-01 | ASM sees "Verify" button on relocation documents | Button **must NOT be visible** | โœ… Fixed | Same role whitelist applied in `RelocationRequestDetails.tsx` | +| RL-02 | ASM sees "Reject" button on relocation documents | Button **must NOT be visible** | โœ… Fixed | | +| RL-03 | DD Admin sees "Verify" button | Button **must be visible** | โœ… | | + +--- + +## 6. Role-Based Access Control (RBAC) Cross-Module + +| # | Test Case | Expected Behaviour | Status | Notes | +|---|---|---|---|---| +| RBAC-01 | ASM document verify access โ€” Constitutional Change | โŒ Hidden โœ… Fixed | See C-01 | +| RBAC-02 | ASM document verify access โ€” Relocation | โŒ Hidden โœ… Fixed | See RL-01 | +| RBAC-03 | ASM workflow approve buttons โ€” Termination | ASM sees no Approve/Send Back | โœ… | SRS ยง8.3.4 | +| RBAC-04 | Dealer sees full termination workflow details | Dealer is **read-only** | โœ… | | +| RBAC-05 | National roles access F&F data | โœ… Permitted | โœ… | Permission added | + +--- + +## 7. Pending / Open Test Cases + +> Items that still require QA testing or implementation. + +| # | Test Case | Module | Priority | Notes | +|---|---|---|---|---| +| P-01 | F&F Document Preview modal | F&F | Medium | Upload/Download done; Preview pending | +| P-02 | SLA breach notification triggers | All modules | High | SLA configuration not yet built | +| P-03 | WhatsApp notification on SCN issued | Termination | High | Awaiting API dependency | +| P-04 | In-app notification on F&F initiation | F&F | Medium | | +| P-05 | Audit log context sweep across all modules | All modules | Medium | Ensure all actions are logged with correct entity context | +| P-06 | Admin SLA Matrix configuration | Admin Dashboard | High | Pending implementation | + +--- + +## 8. Regression Checklist (Run after every release) + +- [ ] Termination: "Push to F&F" rejected before Legal stage (T-01) +- [ ] Termination: Progress timeline shows all green on completion (T-12) +- [ ] Termination: "Push to F&F" button hidden at early stages (T-06) +- [ ] Resignation: Dealer withdrawal blocked after NBH (R-04) +- [ ] Constitutional: ASM cannot verify documents (C-01, C-02) +- [ ] Relocation: ASM cannot verify documents (RL-01, RL-02) +- [ ] F&F: Document download forces save to disk (F-02) +- [ ] F&F: Status badges show white text (F-04) + +--- + +## 9. In-App & Email Notification Coverage + +> All notifications flow through `notifyStakeholdersOnTransition()` in `workflow-email-notifications.ts`. + +### 9.1 Root Cause (Fixed) +Old code only notified `nextActor` and `dealer`. ASM was silently skipped after the initial submission email. + +**Fix applied:** `workflow-email-notifications.ts` now covers 4 notification paths: +1. **Next Actor** โ†’ `Action Required` (system + email) +2. **ASM on Send Back** โ†’ `Case Returned for Clarification` (system + email) +3. **Dealer** โ†’ status update (system always; email only on terminal events) +4. **Key Observers** (DD Lead, DD Head, NBH, DD Admin) โ†’ `Case Closed` (system only, on reject/revoke/complete) + +### 9.2 Notification Test Cases + +| # | Scenario | Who Should Be Notified | Channel | Status | +|---|---|---|---|---| +| N-01 | Constitutional change submitted | All participants incl. ASM | email + in-app | โœ… (submit hook) | +| N-02 | ASM approves โ†’ moves to ZM/RBM | ZM + RBM | email + in-app | โœ… Fixed | +| N-03 | ZBH approves โ†’ moves to DD Lead | DD Lead | email + in-app | โœ… Fixed | +| N-04 | Any approver sends back | ASM | email + in-app | โœ… Fixed | +| N-05 | Request rejected | Dealer (email + in-app), DD Lead/Head/NBH/DD Admin (in-app only) | mixed | โœ… Fixed | +| N-06 | Request revoked | Dealer (email + in-app), key observers (in-app only) | mixed | โœ… Fixed | +| N-07 | Request completed (legal approved) | Dealer (email + in-app), key observers (in-app only) | mixed | โœ… Fixed | +| N-08 | Relocation submitted | Dealer (email), ASM (email + in-app) | email + in-app | โœ… Already existed | +| N-09 | Resignation submitted | All participants incl. ASM | email + in-app | โœ… Already existed | +| N-10 | ZM/RBM joint: first approver approves | Other co-approver (ZM or RBM) | in-app | โœ… Verified | +| N-11 | Termination pushed to F&F | DD Admin, Finance | in-app | โœ… Verified | +| N-12 | ASM who submitted gets status update on any stage change | ASM | in-app | โœ… Verified | + +### 9.3 Known Remaining Gaps (Open) + +| ID | Scenario | Channels | Status | +|----|----------|----------|--------| +| N-G1 | ZM/RBM co-approver notification for partner approvals | System, Email, WhatsApp | **Verified (v2.1)** | +| N-G2 | F&F initiation notification to DD Admin/Finance | System, Email, WhatsApp | **Verified (v2.1)** | +| N-G3 | Dealer withdrawal from resignation notification to ASM | System, Email, WhatsApp | **Verified (v2.1)** | +| N-G4 | Interview Scheduling acknowledgment to Dealer | Email, WhatsApp | **Verified (v2.1)** | +| N-G5 | Interview Panelist assignment notification | Email, WhatsApp | **Verified (v2.1)** | + +> [!NOTE] +> All WhatsApp notifications now dynamically resolve phone numbers from the participant's user record or the application record as per SRS ยง1.1.1. + +--- + +*This file is maintained as a living document. Add new test cases as bugs are found or features are added.* diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index 636b986..b5aafb8 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -2,7 +2,7 @@ import db from '../../database/models/index.js'; import { Op } from 'sequelize'; import { sendEmail } from './email.service.js'; import { NotificationService } from '../../services/NotificationService.js'; -import { REQUEST_TYPES } from '../config/constants.js'; +import { REQUEST_TYPES, ROLES } from '../config/constants.js'; const { RequestParticipant, User, Outlet, District } = db; @@ -11,7 +11,7 @@ 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'] + attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); if (!dealerUser?.email) return; @@ -22,7 +22,9 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis 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}`, @@ -36,36 +38,50 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis } ).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'] }] + 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: ['email', 'system'], + channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], templateCode: 'RESIGNATION_SUBMITTED', placeholders: { dealerName, resignationId: resignationCode, lwd: String(lwd), link: internalLink, - ctaLabel: 'Review resignation' + ctaLabel: 'Review resignation', + phone: uPhone || '' } }).catch((err) => console.error('[notifyResignationSubmittedEmails] internal:', err)); } } -/** Internal reviewers after constitutional request is created. */ +/** 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: { @@ -73,7 +89,7 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi requestType: REQUEST_TYPES.CONSTITUTIONAL, userId: { [Op.ne]: request.dealerId } }, - include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName'] }] + include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }] }); const base = frontendBase(); @@ -82,17 +98,19 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi 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: ['email', 'system'], + channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED', placeholders: { dealerName: dealerDisplayName, changeType: request.changeType || '', requestId: request.requestId, link, - ctaLabel: 'Review request' + ctaLabel: 'Review request', + phone: uPhone || '' } }).catch((err) => console.error('[notifyConstitutionalSubmittedEmails]:', err)); } @@ -127,20 +145,22 @@ export async function notifyRelocationSubmittedEmails( const asmId = (outlet as any)?.district?.asmId; if (!asmId) return; - const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName'] }); + 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: ['email', 'system'], + channels: asmPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], templateCode: 'RELOCATION_SUBMITTED', placeholders: { dealerName, requestId: code, outletCode: outlet?.code || '', link: `${base}/relocation-requests/${request.id}`, - ctaLabel: 'Review relocation' + ctaLabel: 'Review relocation', + phone: asmPhone || '' } }).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err)); } @@ -157,55 +177,73 @@ export async function resolveNextActors(requestId: string, requestType: string, const actorIds = new Set(); - // We try to match the new stage to specific user roles const stageRoleMap: Record = { - // 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'], + // --- Common/Shared Stages --- + 'DD': [ROLES.ASM], + 'ASM': [ROLES.ASM], + 'ASM Review': [ROLES.ASM], + 'RBM': [ROLES.RBM], + 'RBM Review': [ROLES.RBM], + '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], - // 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'] + // --- Onboarding Specific --- + '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 Approval': [ROLES.NBH, ROLES.DD_HEAD], + 'LOI In Progress': [ROLES.NBH, ROLES.DD_HEAD], + 'LOA Approval': [ROLES.NBH, ROLES.DD_HEAD], + 'LOA Pending': [ROLES.NBH, ROLES.DD_HEAD], + '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], + + // --- Termination Specific --- + 'Show Cause Notice': [ROLES.NBH], + 'Personal Hearing': [ROLES.NBH], + 'Legal - Termination Letter': [ROLES.LEGAL_ADMIN] }; const expectedRoles = stageRoleMap[newStage] || []; - + for (const p of participants) { const user = (p as any).user; if (user && user.roleCode && expectedRoles.includes(user.roleCode)) { @@ -222,6 +260,13 @@ export async function resolveNextActors(requestId: string, requestType: string, /** * 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, @@ -240,42 +285,99 @@ export async function notifyStakeholdersOnTransition( try { const participants = await RequestParticipant.findAll({ where: { requestId, requestType }, - include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'roleCode'] }] + 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; - // Don't clutter the history of the person who just acted - if (u.fullName === metadata.actionUserFullName) { - continue; - } + // 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 Notification + // โ”€โ”€ 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: `Application has reached ${targetStage} and requires your action.`, - channels: ['system', 'email'], + 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 + targetStage, + phone: phone || '' } - }).catch(e => console.error(e)); - } else if (isDealer) { - // Status Update for Dealer + }).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: `Application Update: ${metadata.code}`, - message: `Your application is now at ${targetStage}. ${metadata.action}`, + 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 โ”€โ”€ + // SRS ยง2052: rejection notifies dealer/applicant via email & WhatsApp + // SRS ยง2324: approvals/outcomes delivered via email & WhatsApp + const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email']; + if (phone) terminalChannels.push('whatsapp'); + + 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: 'WORKFLOW_STATUS_UPDATE_DEALER', + placeholders: { + requestId: metadata.code, + link: metadata.link, + targetStage, + dealerName: metadata.dealerName, + phone: phone || '' + } + }).catch(e => console.error('[notifyStakeholders] dealer:', e)); + + } else if (isTerminalEvent && isKeyObserverRole) { + // โ”€โ”€ Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events โ€” in-app only โ”€โ”€ + 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'], templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER', placeholders: { @@ -283,7 +385,7 @@ export async function notifyStakeholdersOnTransition( link: metadata.link, targetStage } - }).catch(e => console.error(e)); + }).catch(e => console.error('[notifyStakeholders] observer:', e)); } } } catch (error) { diff --git a/src/database/models/compliance/SLAEscalationConfig.ts b/src/database/models/compliance/SLAEscalationConfig.ts index 749babd..0a98247 100644 --- a/src/database/models/compliance/SLAEscalationConfig.ts +++ b/src/database/models/compliance/SLAEscalationConfig.ts @@ -6,7 +6,8 @@ export interface SLAEscalationConfigAttributes { level: number; timeValue: number; timeUnit: 'hours' | 'days'; - notifyEmail: string; + notifyEmail: string | null; + notifyRole: string | null; } export interface SLAEscalationConfigInstance extends Model, SLAEscalationConfigAttributes { } @@ -40,8 +41,12 @@ export default (sequelize: Sequelize) => { }, notifyEmail: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, validate: { isEmail: true } + }, + notifyRole: { + type: DataTypes.STRING, + allowNull: true } }, { tableName: 'sla_config_escalations', diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 642cec6..2fec720 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -10,6 +10,7 @@ import * as EmailService from '../../common/utils/email.service.js'; import { APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { WorkflowService } from '../../services/WorkflowService.js'; import { syncApplicationProgress } from '../../common/utils/progress.js'; +import { NotificationService } from '../../services/NotificationService.js'; const getLocationAncestors = async (locationId: string): Promise => { const district: any = await District.findByPk(locationId); @@ -434,6 +435,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { scheduledBy: req.user?.id }); console.log('Interview created with ID:', interview.id); + const notificationPromises: Promise[] = []; // Note: WorkflowTransition relocated below participant insertion. @@ -446,10 +448,25 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { }); await interview.update({ linkOrLocation: meetLink }); - // 2. WhatsApp Mock - await ExternalMocksService.mockSendWhatsApp( - application.phone, - `Dear ${application.applicantName}, your ${type} is scheduled at ${scheduledAt}. Join here: ${meetLink}` + // 2. Transmitted via Centralized Notification Service (SRS ยง6.14.3) + // Replacing direct mock call with production-ready service trigger + const applicantPhone = application.mobileNumber || application.phone || ''; + notificationPromises.push( + NotificationService.notify(null, application.email, { + title: `Interview Scheduled: ${application.applicationId}`, + message: `Dear ${application.applicantName}, your ${type} is scheduled.`, + channels: applicantPhone ? ['email', 'whatsapp', 'system'] : ['email', 'system'], + templateCode: 'INTERVIEW_SCHEDULED_APPLICANT', + placeholders: { + applicantName: application.applicantName, + applicationId: application.applicationId, + type, + scheduledAt, + link: meetLink, + phone: applicantPhone, + ctaLabel: 'View Schedule' + } + }).catch(err => console.error('Failed to notify applicant via WhatsApp/Email:', err)) ); let participantIds: string[] = Array.isArray(participants) ? participants : []; @@ -506,10 +523,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { 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[] = []; - + // 3. User & Stakeholder Notifications (SRS ยง6.14.3) if (application) { notificationPromises.push( EmailService.sendInterviewScheduledEmail( @@ -525,16 +539,27 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { for (const userId of participantIds) { notificationPromises.push( (async () => { - const panelist = await User.findByPk(userId); + const panelist = await User.findByPk(userId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); if (panelist) { - await EmailService.sendInterviewScheduledEmail( - panelist.email, - panelist.fullName, - application?.applicationId || application?.id || applicationId, - interview - ); + const pPhone = panelist.mobileNumber || null; + await NotificationService.notify(panelist.id, panelist.email, { + title: `Interview Assignment: ${application?.applicationId || 'New Case'}`, + message: `You have been assigned as a panelist for a ${type} with ${application?.applicantName || 'Applicant'}.`, + channels: pPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], + templateCode: 'INTERVIEW_SCHEDULED_PANELIST', + placeholders: { + panelistName: panelist.fullName, + applicantName: application?.applicantName || 'Applicant', + applicationId: application?.applicationId || '', + type, + scheduledAt, + link: meetLink, + phone: pPhone || '', + ctaLabel: 'Open Assessment' + } + }); } - })().catch(err => console.error(`Failed to send panelist (${userId}) email:`, err)) + })().catch(err => console.error(`Failed to notify panelist (${userId}):`, err)) ); } } diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 20ccf51..8bf933d 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1249,3 +1249,118 @@ export const saveSystemConfig = async (req: Request, res: Response) => { res.status(500).json({ success: false, message: 'Error saving system configuration' }); } }; + +// --- SLA Configuration --- +export const getSlaConfigs = async (req: Request, res: Response) => { + try { + const configs = await db.SLAConfiguration.findAll({ + include: [ + { model: db.SLAReminder, as: 'reminders' }, + { model: db.SLAEscalationConfig, as: 'escalationConfigs' } + ], + order: [['activityName', 'ASC']] + }); + res.json({ success: true, data: configs }); + } catch (error) { + console.error('Get SLA configs error:', error); + res.status(500).json({ success: false, message: 'Error fetching SLA configurations' }); + } +}; + +export const saveSlaConfig = async (req: Request, res: Response) => { + const transaction = await db.sequelize.transaction(); + try { + const { id, activityName, ownerRole, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body; + + let config; + if (id) { + config = await db.SLAConfiguration.findByPk(id, { transaction }); + if (!config) throw new Error('SLA Configuration not found'); + await config.update({ activityName, ownerRole, tatHours, tatUnit, isActive }, { transaction }); + } else { + config = await db.SLAConfiguration.create({ activityName, ownerRole, tatHours, tatUnit, isActive: isActive !== undefined ? isActive : true }, { transaction }); + } + + // Handle Reminders + await db.SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction }); + if (Array.isArray(reminders)) { + for (const r of reminders) { + await db.SLAReminder.create({ + slaConfigId: config.id, + timeValue: r.timeValue, + timeUnit: r.timeUnit, + isEnabled: true + }, { transaction }); + } + } + + // Handle Escalations + await db.SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction }); + if (Array.isArray(escalationConfigs)) { + for (const e of escalationConfigs) { + await db.SLAEscalationConfig.create({ + slaConfigId: config.id, + level: e.level, + timeValue: e.timeValue, + timeUnit: e.timeUnit, + notifyEmail: e.notifyEmail, + notifyChannel: 'email' + }, { transaction }); + } + } + + await transaction.commit(); + res.json({ success: true, data: config }); + } catch (error) { + await transaction.rollback(); + console.error('Save SLA config error:', error); + res.status(500).json({ success: false, message: 'Error saving SLA configuration' }); + } +}; + +export const initializeDefaultSlas = async (req: Request, res: Response) => { + const transaction = await db.sequelize.transaction(); + try { + const defaults = [ + { stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' }, + { stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' }, + { stage: 'NBH Approval', role: 'NBH', tat: 5, unit: 'days' }, + { stage: 'Level 1 Interview', role: 'RBM', tat: 7, unit: 'days' }, + { stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' } + ]; + + for (const item of defaults) { + const [config] = await db.SLAConfiguration.findOrCreate({ + where: { activityName: item.stage }, + defaults: { + activityName: item.stage, + ownerRole: item.role, + tatHours: item.tat, + tatUnit: item.unit as any, + isActive: true + }, + transaction + }); + + // Add a default reminder for each + await db.SLAReminder.findOrCreate({ + where: { slaConfigId: config.id }, + defaults: { + slaConfigId: config.id, + timeValue: 1, + timeUnit: 'days', + isEnabled: true + }, + transaction + }); + } + + await transaction.commit(); + res.json({ success: true, message: 'Default SLA configurations initialized' }); + } catch (error) { + await transaction.rollback(); + console.error('Init SLA error:', error); + res.status(500).json({ success: false, message: 'Error initializing SLAs' }); + } +}; + diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 840a07a..531d1f1 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -26,7 +26,10 @@ import { getDDLeads, saveDDLead, getSystemConfigs, - saveSystemConfig + saveSystemConfig, + getSlaConfigs, + saveSlaConfig, + initializeDefaultSlas } from './master.controller.js'; @@ -69,4 +72,9 @@ router.post('/dd-leads', saveDDLead); router.get('/system-configs', getSystemConfigs); router.post('/system-configs', saveSystemConfig); +// --- SLA Configuration --- +router.get('/sla-configs', getSlaConfigs); +router.post('/sla-configs', saveSlaConfig); +router.post('/sla-configs/initialize', initializeDefaultSlas); + export default router; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index ecf582c..586c4fb 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -652,6 +652,10 @@ const assignStageEvaluators = async (appIdOrId: string) => { // --- INTERVIEWS --- + // --- GROUND STAKEHOLDERS (Area / District Level) --- + // SRS ยง9.3.4: ASM is responsible for ground opportunity identification and field validation. + if (district.asmId) evaluatorMappings['DD'] = [{ id: district.asmId, role: 'ASM' }]; + // Level 1: DD-ZM (District manager) + RBM (Region manager) if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' }); if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' }); @@ -699,7 +703,7 @@ const assignStageEvaluators = async (appIdOrId: string) => { // to prevent duplication in the participants list. const userAssignments: Record = {}; - const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL']; + const allStages = ['DD', 1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL']; for (const stage of allStages) { const assignments = evaluatorMappings[stage]; for (const assign of assignments) { diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index 5b8c25a..91f13de 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -1,6 +1,7 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy } = db; +const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant } = db; +import { NotificationService } from '../../services/NotificationService.js'; import { Op } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; import { @@ -179,6 +180,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { outletId: resolvedOutletId, dealerId: dealerUserId, changeType: resolvedChangeType, + oldValue: resolvedCurrent, + newValue: resolvedChangeType, description: remarksText, currentConstitution: resolvedCurrent, currentStage: CONSTITUTIONAL_STAGES.ASM_REVIEW, @@ -543,17 +546,45 @@ export const takeAction = async (req: AuthRequest, res: Response) => { }); try { - const jointLine = `[Joint Approval] ${req.user.fullName} approved at ZM/RBM stage. Waiting for ${waitingFor.join(', ')}.`; - const noteText = userComment ? `${jointLine} Remarks: ${userComment}` : jointLine; - await writeWorkflowActivityWorknote({ - requestId: request.id, - requestType: 'constitutional', - userId: req.user.id, - noteText, - noteType: 'internal' + // Notify remaining approvers (co-approvers) + const remainingParticipants = await RequestParticipant.findAll({ + where: { + requestId: request.id, + requestType: REQUEST_TYPES.CONSTITUTIONAL + }, + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email', 'fullName', 'roleCode', 'mobileNumber'] + }] }); - } catch (wnErr) { - console.error('[constitutional] workflow worknote:', wnErr); + + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + for (const p of remainingParticipants) { + const u = (p as any).user; + if (!u || u.id === req.user.id) continue; + + const uRole = normalizeRoleKey(u.roleCode || ''); + if (waitingFor.includes(uRole)) { + const phone = u.mobileNumber || u.phone || null; + await NotificationService.notify(u.id, u.email, { + title: `Action Required: Co-approval for ${request.requestId}`, + message: `${req.user.fullName} has approved ${request.requestId}. Your approval is still required to move to the next stage.`, + channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'], + templateCode: 'WORKFLOW_ACTION_REQUIRED', + placeholders: { + dealerName: request.dealerId, // We should ideally have dealer name, but request object has ID + requestId: request.requestId, + link: `${portalBase}/constitutional-change/${request.id}`, + targetStage: request.currentStage, + phone: phone || '' + } + }); + } + } + } catch (notifyErr) { + console.error('[constitutional] co-approver notification failed:', notifyErr); } return res.json({ diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index dc7a49d..7ff9971 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -73,7 +73,7 @@ const ensureFinanceDraftsFromDepartmentClaims = async (fnfId: string, userId: st } }; -export const getDepartments = async (req: Request, res: Response) => { +export const getDepartments = async (req: AuthRequest, res: Response) => { try { res.json({ success: true, departments: FNF_DEPARTMENTS }); } catch (error) { @@ -81,7 +81,42 @@ export const getDepartments = async (req: Request, res: Response) => { } }; -export const getOnboardingPayments = async (req: Request, res: Response) => { +export const uploadFnFDocument = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const fileUrl = req.file ? `/uploads/documents/${req.file.filename}` : req.body.fileUrl; + + if (!fileUrl) { + return res.status(400).json({ success: false, message: 'No file uploaded' }); + } + + const fnf = await FnF.findByPk(id); + if (!fnf) { + return res.status(404).json({ success: false, message: 'F&F settlement not found' }); + } + + const updatedClearances = [ + ...(fnf.clearanceDocuments || []), + { + name: req.file?.originalname || 'Uploaded Document', + supportingDocument: fileUrl, + department: 'Finance', // Default to Finance for generic F&F docs + clearedAt: new Date().toISOString() + } + ]; + + await fnf.update({ + clearanceDocuments: updatedClearances + }); + + res.json({ success: true, url: fileUrl, name: req.file?.originalname || 'Document' }); + } catch (error) { + console.error('Error uploading F&F document:', error); + res.status(500).json({ success: false, message: 'Error uploading F&F document' }); + } +}; + +export const getOnboardingPayments = async (req: AuthRequest, res: Response) => { try { const payments = await FinancePayment.findAll({ include: [{ @@ -285,6 +320,12 @@ export const getFnFById = async (req: Request, res: Response) => { }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); + // Ensure participants exist (fixes legacy / missing participants silently) + if (!fnf.participants || (fnf as any).participants.length === 0) { + const { ParticipantService } = await import('../../services/ParticipantService.js'); + await ParticipantService.assignFnFParticipants(resolvedId); + } + await ensureFinanceDraftsFromDepartmentClaims(resolvedId, null); const fnfWithDrafts = await FnF.findByPk(resolvedId, { diff --git a/src/modules/settlement/settlement.routes.ts b/src/modules/settlement/settlement.routes.ts index da99d77..5498fcf 100644 --- a/src/modules/settlement/settlement.routes.ts +++ b/src/modules/settlement/settlement.routes.ts @@ -14,15 +14,16 @@ router.get('/departments', settlementController.getDepartments); // Finance user only routes router.get('/onboarding', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getOnboardingPayments); -router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getFnFSettlements); -router.get('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD]) as any, settlementController.getFnFById); +router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.CCO, ROLES.CEO]) as any, settlementController.getFnFSettlements); +router.get('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.CCO, ROLES.CEO]) as any, settlementController.getFnFById); router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updatePayment); router.put('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateFnF); router.post('/fnf/:id/calculate', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.calculateFnF); -router.put('/fnf/:id/clearances/:clearanceId', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance); +router.put('/fnf/:id/clearances/:clearanceId', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, settlementController.updateClearance); // Line item management +router.post('/fnf/:id/documents', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, settlementController.uploadFnFDocument); router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem); router.put('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateLineItem); router.delete('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.deleteLineItem); diff --git a/src/modules/sla/sla.controller.ts b/src/modules/sla/sla.controller.ts index 73df123..b056ac7 100644 --- a/src/modules/sla/sla.controller.ts +++ b/src/modules/sla/sla.controller.ts @@ -2,9 +2,55 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { SLAConfiguration, SLATracking, SLABreach } = db; +export const updateConfig = async (req: Request, res: Response) => { + const transaction = await db.sequelize.transaction(); + try { + const { id } = req.params; + const { activityName, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body; + + const config = await SLAConfiguration.findByPk(id); + if (!config) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'SLA Configuration not found' }); + } + + await config.update({ activityName, tatHours, tatUnit, isActive }, { transaction }); + + // Update Reminders: Clear and recreate + await db.SLAReminder.destroy({ where: { slaConfigId: id }, transaction }); + if (reminders && reminders.length > 0) { + await db.SLAReminder.bulkCreate( + reminders.map((r: any) => ({ ...r, slaConfigId: id })), + { transaction } + ); + } + + // Update Escalations: Clear and recreate + await db.SLAEscalationConfig.destroy({ where: { slaConfigId: id }, transaction }); + if (escalationConfigs && escalationConfigs.length > 0) { + await db.SLAEscalationConfig.bulkCreate( + escalationConfigs.map((e: any) => ({ ...e, slaConfigId: id })), + { transaction } + ); + } + + await transaction.commit(); + res.json({ success: true, message: 'SLA Configuration updated successfully' }); + } catch (error) { + await transaction.rollback(); + console.error('Update SLA config error:', error); + res.status(500).json({ success: false, message: 'Error updating SLA config' }); + } +}; + export const getConfigs = async (req: Request, res: Response) => { try { - const configs = await SLAConfiguration.findAll(); + const configs = await SLAConfiguration.findAll({ + include: [ + { model: db.SLAReminder, as: 'reminders' }, + { model: db.SLAEscalationConfig, as: 'escalationConfigs' } + ] + }); res.json({ success: true, data: configs }); } catch (error) { console.error('Get SLA configs error:', error); diff --git a/src/modules/sla/sla.routes.ts b/src/modules/sla/sla.routes.ts index 85647bb..2b6fe33 100644 --- a/src/modules/sla/sla.routes.ts +++ b/src/modules/sla/sla.routes.ts @@ -6,6 +6,7 @@ import { authenticate } from '../../common/middleware/auth.js'; router.use(authenticate as any); router.get('/configs', slaController.getConfigs); +router.put('/configs/:id', slaController.updateConfig); router.get('/tracking/:applicationId', slaController.getTracking); export default router; diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index a2bda80..9ae23c1 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -309,6 +309,31 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n action: 'Sent Back', remarks }); + } else if (action === 'pushfnf') { + if (termination.currentStage !== TERMINATION_STAGES.TERMINATED && termination.currentStage !== TERMINATION_STAGES.LEGAL_LETTER) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.` + }); + } + + logger.info(`[TerminationController] Forcibly initiating F&F (pushfnf) for Termination ${termination.requestId}`); + await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction); + + // Maintain timeline visibility + const timeline = [...(termination.timeline || []), { + stage: termination.currentStage, + timestamp: new Date(), + user: req.user.fullName, + action: 'Forced F&F Initiation', + remarks: remarks || 'F&F settlement initiated manually via Push to F&F' + }]; + await termination.update({ + currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals + status: 'F&F Initiated', + timeline + }, { transaction }); } else { const stageFlow: Record = { [TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW, diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index a2cf535..4a148ee 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -151,7 +151,7 @@ export class ConstitutionalWorkflowService { await dealer.update({ constitutionType: nextConstitution }); - const auditRemarks = `Dealer master constitutionType updated: "${previous}" โ†’ "${nextConstitution}" (dealer profile id: ${dealer.id}). Change type on request: ${request.changeType}.`; + const auditRemarks = `Dealer master constitution updated from "${previous}" to "${nextConstitution}" via request ${request.requestId}.`; const auditDetails = { dealerProfileId: dealer.id, dealerUserId: request.dealerId, @@ -181,7 +181,7 @@ export class ConstitutionalWorkflowService { requestId: request.id, requestType: 'constitutional', userId: worknoteUserId, - noteText: `[Master data] Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`, + noteText: `Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`, noteType: 'workflow' }); } catch (wnErr) { diff --git a/src/services/SLAService.ts b/src/services/SLAService.ts index 277b71a..635c9d0 100644 --- a/src/services/SLAService.ts +++ b/src/services/SLAService.ts @@ -1,86 +1,317 @@ import db from '../database/models/index.js'; -const { SLATracking, SLAConfiguration, SLABreach, Application, User } = db; +const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db; import { Op } from 'sequelize'; import { NotificationService } from './NotificationService.js'; export class SLAService { /** - * Periodically check for SLA breaches across all active tracking records + * Periodically check for SLA breaches, reminders and escalations */ static async checkBreaches() { - console.log('[SLA Service] Starting breach check...'); + console.log('[SLA Service] Starting SLA status check...'); + const now = new Date(); - // Find all active tracking records that are still 'In Progress' (not concluded) + // 1. Handle Active Tracks (Reminders and Initial Breach) const activeTracking = await SLATracking.findAll({ - where: { isActive: true, isBreached: false, endTime: null }, - include: [ - { model: Application, as: 'application' } - ] + where: { isActive: true, endTime: null }, + include: [{ model: Application, as: 'application' }] }); - const now = new Date(); - for (const track of activeTracking) { - // Fetch SLA configuration for this specific stage and entity type const config = await SLAConfiguration.findOne({ - where: { - stageName: track.stageName, - entityType: track.entityType, - isActive: true - } + where: { activityName: track.stageName, isActive: true }, + include: [ + { model: SLAReminder, as: 'reminders' }, + { model: SLAEscalationConfig, as: 'escalationConfigs' } + ] }); if (!config) continue; const startTime = new Date(track.startTime); - const slaHours = config.slaHours; - const deadline = new Date(startTime.getTime() + (slaHours * 60 * 60 * 1000)); + const tatMs = this.getTatInMs(config.tatHours, config.tatUnit); + const deadline = new Date(startTime.getTime() + tatMs); - if (now > deadline) { - // BREACH DETECTED - console.log(`[SLA Service] Breach detected for ${track.entityType}: ${track.application?.applicationId || track.entityId}, Stage: ${track.stageName}`); - - // Update tracking record - await track.update({ isBreached: true }); - - // Create Breach Record - await SLABreach.create({ - trackingId: track.id, - applicationId: track.applicationId, - stageCode: track.stageName, - breachedAt: now, - severity: 'High', - status: 'Open' - }); - - // Notify Stakeholders (Escalation) - await this.notifyEscalated(track); + // CASE A: Not Breached Yet - Check Reminders + if (!track.isBreached && now < deadline) { + await this.processReminders(track, config, now, deadline); + } + // CASE B: Just Breached - Mark it and trigger Level 1 Escalation + else if (!track.isBreached && now >= deadline) { + await this.triggerBreach(track, now); + } + // CASE C: Already Breached - Check for Escalations + else if (track.isBreached) { + await this.processEscalations(track, config, now, deadline); } } - console.log(`[SLA Service] Breach check completed at ${now.toISOString()}`); } - private static async notifyEscalated(track: any) { - // Find the assigned user to notify them and potentially their manager - const application = await Application.findByPk(track.applicationId, { - include: [{ model: User, as: 'assignedToUser' }] + /** + * Start tracking SLA for a new stage + */ + static async startTrack(applicationId: string, stageName: string) { + console.log(`[SLA Service] Starting SLA track for App: ${applicationId}, Stage: ${stageName}`); + + // Ensure NO other active tracks for this application exist + await SLATracking.update( + { isActive: false, endTime: new Date() }, + { where: { applicationId, isActive: true, endTime: null } } + ); + + const config = await SLAConfiguration.findOne({ + where: { activityName: stageName, isActive: true } }); - if (application?.assignedToUser) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - await NotificationService.notify(application.assignedTo, application.assignedToUser.email, { - title: `SLA BREACH: ${track.stageName}`, - message: `The application ${application.applicationId} has breached the SLA for ${track.stageName}. Current stage: ${application.currentStage}.`, - channels: ['email', 'system'], - templateCode: 'SLA_BREACH_WARNING', + if (config) { + await SLATracking.create({ + applicationId, + stageName, + startTime: new Date(), + isActive: true + }); + } + } + + /** + * Stop tracking SLA for a stage + */ + static async stopTrack(applicationId: string, stageName: string) { + console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`); + await SLATracking.update( + { isActive: false, endTime: new Date() }, + { where: { applicationId, stageName, isActive: true } } + ); + } + + private static getTatInMs(value: number, unit: string): number { + const factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000; + return value * factor; + } + + private static async processReminders(track: any, config: any, now: Date, deadline: Date) { + const msRemaining = deadline.getTime() - now.getTime(); + + for (const reminder of config.reminders || []) { + const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit); + + if (msRemaining <= reminderMs) { + const metadata = track.metadata || {}; + const reminderKey = `reminder_sent_${reminder.id}`; + + if (!metadata[reminderKey]) { + const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`; + console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`); + + await this.notifyStakeholder(track, 'SLA_REMINDER', { + title: `SLA Reminder: ${track.stageName}`, + message: `The application ${track.application?.applicationId} is approaching its SLA deadline for ${track.stageName}.` + }); + + // ยง9.4.1 โ€” Auto-log in Work Notes + await this.logWorkNote(track.applicationId, `[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`); + + metadata[reminderKey] = true; + await track.update({ metadata }); + } + } + } + } + + private static async triggerBreach(track: any, now: Date) { + console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`); + await track.update({ isBreached: true }); + + await SLABreach.create({ + trackingId: track.id, + applicationId: track.applicationId, + stageCode: track.stageName, + breachedAt: now, + severity: 'High', + status: 'Open' + }); + + await this.notifyStakeholder(track, 'SLA_BREACH', { + title: `SLA BREACHED: ${track.stageName}`, + message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.` + }); + + // ยง9.4.1 โ€” Auto-log in Work Notes + await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`); + } + + private static async processEscalations(track: any, config: any, now: Date, deadline: Date) { + const msSinceBreach = now.getTime() - deadline.getTime(); + + for (const esc of config.escalationConfigs || []) { + const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit); + + if (msSinceBreach >= escMs) { + const metadata = track.metadata || {}; + const escKey = `esc_sent_L${esc.level}`; + + if (!metadata[escKey]) { + console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`); + + const { User, Application, District, Region, Zone } = db; + let targetEmail = esc.notifyEmail; + let recipientId = null; + + // Runtime Resolution: Resolve role to a specific user/email if role is provided + if (esc.notifyRole) { + const app = await Application.findByPk(track.applicationId, { + include: [{ + model: District, + as: 'district', + include: [ + { model: Region, as: 'region' }, + { model: Zone, as: 'zone' } + ] + }] + }); + + if (app?.district) { + const d = app.district; + const r = d.region; + const z = d.zone; + + // Map geography-bound roles + const roleMap: Record = { + 'ASM': d.asmId, + 'DD-ZM': d.zmId, + 'RBM': r?.rbmId || null, + 'ZBH': z?.zbhId || null + }; + + if (roleMap[esc.notifyRole]) { + recipientId = roleMap[esc.notifyRole]; + } + } + + // Fallback/National roles: Resolve by roleCode singleton + if (!recipientId) { + const user = await User.findOne({ + where: { roleCode: esc.notifyRole, status: 'active' }, + order: [['createdAt', 'DESC']] + }); + if (user) recipientId = user.id; + } + } + + // Resolve final email and phone if we have a recipientId + let phone = null; + if (recipientId) { + const user = await User.findByPk(recipientId); + if (user) { + targetEmail = user.email; + phone = user.mobileNumber || user.phone || null; + } + } + + if (targetEmail) { + await NotificationService.notify(recipientId, targetEmail, { + title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`, + message: `The application ${track.application?.applicationId} remains incomplete after SLA breach for ${track.stageName}.`, + channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], + templateCode: 'SLA_ESCALATION', + placeholders: { + applicationId: track.application?.applicationId || '', + stageName: track.stageName, + level: esc.level, + timeValue: esc.timeValue, + timeUnit: esc.timeUnit, + phone: phone || '' + } + }); + } + + // ยง9.4.1 โ€” Auto-log in Work Notes + await this.logWorkNote(track.applicationId, `[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyEmail} for stage ${track.stageName}.`); + + metadata[escKey] = true; + await track.update({ metadata }); + } + } + } + } + + private static async logWorkNote(applicationId: string, text: string) { + try { + const { Worknote, User } = db; + // Find a system user or admin to be the author + const admin = await User.findOne({ where: { role: 'Super Admin' } }); + await Worknote.create({ + applicationId, + userId: admin?.id || (await User.findOne())?.id, + noteText: text, + noteType: 'system', + status: 'active' + }); + } catch (err) { + console.error('[SLA Service] Failed to log work note:', err); + } + } + + private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) { + const { Application, User, SLAConfiguration } = db; + + // 1. Get the configuration for this stage to find the Owner Role(s) + const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } }); + if (!config || !config.ownerRole) return; + + // 2. Resolve multiple roles (comma-separated) + const roles = config.ownerRole.split(',').map((r: string) => r.trim()); + const application = await Application.findByPk(track.applicationId, { + include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }] + }); + + if (!application) return; + + const recipientIds = new Set(); + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + for (const role of roles) { + let foundUserId = null; + + // Resolve geography-bound roles + if (application.district) { + const d = application.district; + const roleMap: Record = { + 'ASM': d.asmId, + 'DD-ZM': d.zmId, + 'RBM': d.region?.rbmId || null, + 'ZBH': d.zone?.zbhId || null + }; + if (roleMap[role]) foundUserId = roleMap[role]; + } + + if (foundUserId) { + recipientIds.add(foundUserId); + } else { + // Fallback: Resolve all active users with this role + const users = await User.findAll({ where: { roleCode: role, status: 'active' } }); + users.forEach((u: any) => recipientIds.add(u.id)); + } + } + + // 3. Send notifications to all resolved recipients + for (const userId of recipientIds) { + const user = await User.findByPk(userId); + if (!user) continue; + + const phone = user.mobileNumber || user.phone || null; + await NotificationService.notify(userId, user.email, { + title: content.title, + message: content.message, + channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'], + templateCode: template, placeholders: { applicationId: application.applicationId || String(application.id), stageName: track.stageName, - currentStage: application.currentStage || '', link: `${portalBase}/applications/${application.id}`, - ctaLabel: 'Open application' + phone: phone || '' }, - metadata: { type: 'error', applicationId: application.id } + metadata: { applicationId: application.id } }); } } diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index a0008c1..d4a0599 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -83,7 +83,8 @@ export class TerminationWorkflowService { { id: termination.dealerId }, { dealerId: termination.dealerId } ] - } + }, + attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); if (user) { @@ -119,7 +120,8 @@ export class TerminationWorkflowService { deadline: deadlineStr, link: dealerPortalLink, remarks: remarks || '', - ctaLabel: 'Submit response' + ctaLabel: 'Submit response', + phone: user?.mobileNumber || user?.phone || '' } }); } @@ -173,13 +175,13 @@ export class TerminationWorkflowService { if (!dealerProfile) throw new Error('Dealer record not found for termination'); // 2. Resolve or Create FnF Settlement - let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } }); + let fnf = await db.FnF.findOne({ where: { terminationRequestId: termination.id } }); let fnfId = fnf?.id; if (!fnf) { fnf = await db.FnF.create({ settlementId: NomenclatureService.generateFnFId(), - terminationRequestId: terminationId, + terminationRequestId: termination.id, dealerId: termination.dealerId, outletId: primaryOutlet?.id || null, status: 'Initiated', @@ -206,6 +208,33 @@ export class TerminationWorkflowService { // 4. Assign Participants for F&F (Sub-application chat) if (fnfId) { await ParticipantService.assignFnFParticipants(fnfId); + + // SRS ยง1.1.1: Notify DD Admin and Finance via Email & WhatsApp + try { + const adminUsers = await User.findAll({ + where: { roleCode: [ROLES.DD_ADMIN, ROLES.FINANCE] }, + attributes: ['id', 'email', 'fullName', 'mobileNumber'] + }); + + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + for (const u of adminUsers) { + const phone = u.mobileNumber || null; + await NotificationService.notify(u.id, u.email, { + title: `F&F Settlement Initiated: ${termination.requestId}`, + message: `Full & Final Settlement has been initiated for ${dealerUser?.fullName || 'Dealer'}.`, + channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'], + templateCode: 'FNF_INITIATED', + placeholders: { + dealerName: dealerUser?.fullName || 'Dealer', + requestId: termination.requestId, + link: `${portalBase}/fnf-settlements/${fnf.id}`, + phone: phone || '' + } + }); + } + } catch (notifyErr) { + console.error('[TerminationWorkflowService] F&F initiation notification failed:', notifyErr); + } } return fnf; diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index 569107f..4ef6d88 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -7,6 +7,7 @@ import { OVERALL_STATUS_TO_DB_CURRENT_STAGE, } from '../common/config/constants.js'; import { NotificationService } from './NotificationService.js'; +import { SLAService } from './SLAService.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js'; export class WorkflowService { @@ -100,7 +101,19 @@ export class WorkflowService { }, }); - // 4. Progress sync โ€” non-fatal (DB state is already committed) + // 4. SLA Tracking โ€” non-fatal + try { + if (previousStage) { + await SLAService.stopTrack(application.id, previousStage); + } + if (stageForDbColumn) { + await SLAService.startTrack(application.id, stageForDbColumn); + } + } catch (slaErr) { + console.error('[WorkflowService] SLA track transition failed (non-fatal):', slaErr); + } + + // 5. Progress sync โ€” non-fatal (DB state is already committed) try { await syncApplicationProgress(application.id, targetStatus); } catch (syncErr) { @@ -112,7 +125,7 @@ export class WorkflowService { try { const user = await User.findOne({ where: { email: application.email }, - attributes: ['id'], + attributes: ['id', 'mobileNumber'], }); const targetUserId = user ? user.id : null; @@ -141,6 +154,7 @@ export class WorkflowService { serviceCode: application.dealerCode?.serviceCode || 'N/A', link: `${portalBase}/applications/${application.id}`, ctaLabel, + phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || '' }, }); } catch (notifyErr) { diff --git a/trigger-workflow.js b/trigger-workflow.js index 11a529b..a60ff8e 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -123,7 +123,7 @@ async function prospectLogin(phone) { async function mockUploadDocument(appId, token, docType) { const formData = new FormData(); - const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG'); + const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-24 19-16-05.png'); const blob = new Blob([fileBuffer], { type: 'image/png' }); formData.append('file', blob, 'screenshot.png'); formData.append('documentType', docType);