in-app notiofication enhanced an SLA feimplementation done partially
This commit is contained in:
parent
5004508e91
commit
0d6821b7a9
190
docs/TEST_CASES.md
Normal file
190
docs/TEST_CASES.md
Normal file
@ -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 `<a>` 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.*
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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,51 +177,69 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
|
||||
const actorIds = new Set<string>();
|
||||
|
||||
// We try to match the new stage to specific user roles
|
||||
const stageRoleMap: Record<string, string[]> = {
|
||||
// Onboarding Specific
|
||||
'Level 1 Interview': ['DD-ZM', 'RBM'],
|
||||
'Level 1 Interview Pending': ['DD-ZM', 'RBM'],
|
||||
'Interview Level 1': ['DD-ZM', 'RBM'],
|
||||
'Level 2 Interview': ['ZBH', 'DD Lead'],
|
||||
'Level 2 Interview Pending': ['ZBH', 'DD Lead'],
|
||||
'Interview Level 2': ['ZBH', 'DD Lead'],
|
||||
'Level 3 Interview': ['NBH', 'DD Head'],
|
||||
'Level 3 Interview Pending': ['NBH', 'DD Head'],
|
||||
'Interview Level 3': ['NBH', 'DD Head'],
|
||||
'LOI Approval': ['NBH', 'DD Head'],
|
||||
'LOI In Progress': ['NBH', 'DD Head'],
|
||||
'LOA Approval': ['NBH', 'DD Head'],
|
||||
'LOA Pending': ['NBH', 'DD Head'],
|
||||
// --- 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] || [];
|
||||
@ -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) {
|
||||
|
||||
@ -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>, 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',
|
||||
|
||||
@ -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<string[]> => {
|
||||
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<any>[] = [];
|
||||
|
||||
// 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<any>[] = [];
|
||||
|
||||
// 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, { stages: any[], roles: string[] }> = {};
|
||||
|
||||
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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<string, string> = {
|
||||
[TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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...');
|
||||
|
||||
// Find all active tracking records that are still 'In Progress' (not concluded)
|
||||
const activeTracking = await SLATracking.findAll({
|
||||
where: { isActive: true, isBreached: false, endTime: null },
|
||||
include: [
|
||||
{ model: Application, as: 'application' }
|
||||
]
|
||||
});
|
||||
|
||||
console.log('[SLA Service] Starting SLA status check...');
|
||||
const now = new Date();
|
||||
|
||||
// 1. Handle Active Tracks (Reminders and Initial Breach)
|
||||
const activeTracking = await SLATracking.findAll({
|
||||
where: { isActive: true, endTime: null },
|
||||
include: [{ model: Application, as: 'application' }]
|
||||
});
|
||||
|
||||
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<string, string | null> = {
|
||||
'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<string>();
|
||||
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<string, string | null> = {
|
||||
'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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user