From bfef307725487ed36e8cbb217f4ac1bddf246417 Mon Sep 17 00:00:00 2001 From: laxman h Date: Tue, 21 Apr 2026 19:07:59 +0530 Subject: [PATCH] sla feature addd and templates also included in the sed file user deacticated changed to in active --- docs/TEST_CASES.md | 13 ++ sla-governance-postman-collection.json | 216 ++++++++++++++++++ src/common/queues/sla.queue.ts | 16 +- .../utils/workflow-email-notifications.ts | 36 ++- src/constants/allowed-email-template-codes.ts | 3 + .../relocation/RelocationRequest.ts | 35 +++ src/emailtemplates/sla_breach.html | 8 + src/emailtemplates/sla_escalation.html | 9 + src/emailtemplates/sla_reminder.html | 8 + src/modules/admin/admin.controller.ts | 7 +- .../assessment/assessment.controller.ts | 41 +++- src/modules/auth/auth.controller.ts | 2 +- src/modules/master/master.controller.ts | 100 +++++++- .../self-service/relocation.controller.ts | 15 +- src/modules/sla/sla.controller.ts | 35 +++ src/modules/sla/sla.routes.ts | 1 + src/scripts/patch-user-status.ts | 41 ++++ src/scripts/seed-interview-templates.ts | 90 ++++++++ src/scripts/seed-master-emails.ts | 21 ++ src/services/ResignationWorkflowService.ts | 2 +- src/services/SLAService.ts | 8 +- src/services/TerminationWorkflowService.ts | 2 +- trigger-termination.js | 2 +- 23 files changed, 675 insertions(+), 36 deletions(-) create mode 100644 sla-governance-postman-collection.json create mode 100644 src/emailtemplates/sla_breach.html create mode 100644 src/emailtemplates/sla_escalation.html create mode 100644 src/emailtemplates/sla_reminder.html create mode 100644 src/scripts/patch-user-status.ts create mode 100644 src/scripts/seed-interview-templates.ts diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md index 2962c1d..bc7869d 100644 --- a/docs/TEST_CASES.md +++ b/docs/TEST_CASES.md @@ -88,6 +88,19 @@ | C-04 | NBH sees "Verify" button | Button **must be visible** | ✅ | | | C-05 | Dealer role sees "Verify" button | Button **must NOT be visible** | ✅ | | +### 5. Sequential Approval Visibility (LOI/LOA Edge Case) +- **ID:** TC-ONB-005 +- **Description:** Verify that NBH does not receive approval notifications or see action buttons for LOI/LOA until DD Head has completed their review. +- **Pre-conditions:** Application is in `LOI_APPROVAL` stage. +- **Steps:** + 1. Log in as **NBH** stakeholder. + 2. Access the application in `LOI Approval` stage. + 3. Verify that "Approve LOI" button is **NOT** visible. + 4. Log in as **DD Head** and Approve the LOI. + 5. Verify NBH receives a system notification/email **ONLY AFTER** DD Head's action. + 6. Log in as **NBH** and verify "Approve LOI" button is now visible. +- **Expected Result:** Proper sequential handover; NBH is not disturbed by early notifications or blank action screens. + --- ## 5. Relocation Module diff --git a/sla-governance-postman-collection.json b/sla-governance-postman-collection.json new file mode 100644 index 0000000..0b41bbd --- /dev/null +++ b/sla-governance-postman-collection.json @@ -0,0 +1,216 @@ +{ + "info": { + "_postman_id": "sla-governance-re", + "name": "RE Dealer Onboarding - SLA Governance", + "description": "Collection for testing and debugging Royal Enfield Dealer Onboarding SLA engine and Escalation matrix.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Authentication", + "item": [ + { + "name": "Login (Admin)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if(jsonData.token) {", + " pm.environment.set(\"token\", jsonData.token);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@royalenfield.com\",\n \"password\": \"Admin@123\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/auth/login", + "host": [ + "{{base_url}}" + ], + "path": [ + "auth", + "login" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "SLA Governance", + "item": [ + { + "name": "Initialize Default SLA Matrix", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/master/sla-configs/initialize", + "host": [ + "{{base_url}}" + ], + "path": [ + "master", + "sla-configs", + "initialize" + ] + }, + "description": "Seeds/Resets the system with the 48+ SLA configurations across all modules as per doc requirements." + }, + "response": [] + }, + { + "name": "Get All SLA Configs", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/sla/configs", + "host": [ + "{{base_url}}" + ], + "path": [ + "sla", + "configs" + ] + } + }, + "response": [] + }, + { + "name": "Save SLA Config (Update Logic)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"activityName\": \"Level 1 Interview\",\n \"ownerRole\": \"RBM, DD-ZM\",\n \"tatHours\": 2,\n \"tatUnit\": \"days\",\n \"isActive\": true,\n \"reminders\": [\n { \"timeValue\": 24, \"timeUnit\": \"hours\" },\n { \"timeValue\": 4, \"timeUnit\": \"hours\" }\n ],\n \"escalationConfigs\": [\n { \"level\": 1, \"timeValue\": 4, \"timeUnit\": \"hours\", \"notifyRole\": \"ZBH\" },\n { \"level\": 2, \"timeValue\": 12, \"timeUnit\": \"hours\", \"notifyRole\": \"DD Lead\" },\n { \"level\": 3, \"timeValue\": 24, \"timeUnit\": \"hours\", \"notifyRole\": \"NBH\" }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/master/sla-configs", + "host": [ + "{{base_url}}" + ], + "path": [ + "master", + "sla-configs" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Monitoring & Debugging", + "item": [ + { + "name": "View SLA Queue & Database Internal Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/sla/debug/queue", + "host": [ + "{{base_url}}" + ], + "path": [ + "sla", + "debug", + "queue" + ] + }, + "description": "Debug endpoint to see active SLA tracks in DB and jobs in Redis." + }, + "response": [] + }, + { + "name": "Get Tracking for App", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/sla/tracking/:applicationId", + "host": [ + "{{base_url}}" + ], + "path": [ + "sla", + "tracking", + ":applicationId" + ], + "variable": [ + { + "key": "applicationId", + "value": "ENTER_APP_ID_HERE" + } + ] + } + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "base_url", + "value": "http://localhost:5000/api" + }, + { + "key": "token", + "value": "your_token_here" + } + ] +} diff --git a/src/common/queues/sla.queue.ts b/src/common/queues/sla.queue.ts index 750dbbe..0f2598e 100644 --- a/src/common/queues/sla.queue.ts +++ b/src/common/queues/sla.queue.ts @@ -9,12 +9,20 @@ export const slaQueue = new Queue('slaQueue', { * Schedule the recurring SLA check (Every Hour) */ export const scheduleSLACheck = async () => { - // We use a unique job ID to ensure only one instance of the repeatable job exists + const isFastMode = process.env.DEBUG_SLA_FAST_MODE === 'true'; + const pattern = isFastMode ? '* * * * *' : '0 * * * *'; + + // Clean up old repeatable jobs to ensure fresh schedule + const jobs = await slaQueue.getRepeatableJobs(); + for (const job of jobs) { + await slaQueue.removeRepeatableByKey(job.key); + } + await slaQueue.add('checkSLABreaches', {}, { repeat: { - pattern: '0 * * * *', // Every hour at :00 + pattern: pattern, }, - jobId: 'hourly-sla-check' + jobId: 'sla-check-job' }); - console.log('[SLA Queue] Repeatable job scheduled: Hourly check'); + console.log(`[SLA Queue] Repeatable job scheduled: ${isFastMode ? 'Every minute (FAST MODE)' : 'Hourly'}`); }; diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index b5aafb8..1c8eacb 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -167,6 +167,7 @@ export async function notifyRelocationSubmittedEmails( /** * Resolves the user IDs of the required 'next actor' based on the workflow stage. + * Updated: Now awareness of sequential flows (e.g., LOI/LOA) to prevent parallel notifications. */ export async function resolveNextActors(requestId: string, requestType: string, newStage: string): Promise { try { @@ -205,7 +206,7 @@ export async function resolveNextActors(requestId: string, requestType: string, 'CCO Approval': [ROLES.CCO], 'CEO Final Approval': [ROLES.CEO], - // --- Onboarding Specific --- + // --- Onboarding Specific (Sequential Flows) --- 'Level 1 Interview': [ROLES.DD_ZM, ROLES.RBM], 'Level 1 Interview Pending': [ROLES.DD_ZM, ROLES.RBM], 'Interview Level 1': [ROLES.DD_ZM, ROLES.RBM], @@ -215,10 +216,13 @@ export async function resolveNextActors(requestId: string, requestType: string, '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], + + // LOI and LOA follow (DD Head -> NBH) sequence + 'LOI Approval': [ROLES.DD_HEAD, ROLES.NBH], + 'LOI In Progress': [ROLES.DD_HEAD, ROLES.NBH], + 'LOA Approval': [ROLES.DD_HEAD, ROLES.NBH], + 'LOA Pending': [ROLES.DD_HEAD, ROLES.NBH], + 'FDD Verification': [ROLES.FDD], 'FDD_VERIFICATION': [ROLES.FDD], 'Architecture Team Assigned': [ROLES.ARCHITECTURE], @@ -242,7 +246,27 @@ export async function resolveNextActors(requestId: string, requestType: string, 'Legal - Termination Letter': [ROLES.LEGAL_ADMIN] }; - const expectedRoles = stageRoleMap[newStage] || []; + const configRoles = stageRoleMap[newStage] || []; + let expectedRoles = configRoles; + + // Sequential Logic for LOI/LOA (DD Head -> NBH) + const sequentialStages = ['LOI Approval', 'LOI In Progress', 'LOA Approval', 'LOA Pending']; + if (sequentialStages.includes(newStage) && configRoles.includes(ROLES.DD_HEAD) && configRoles.includes(ROLES.NBH)) { + // Fetch existing approvals for this stage + const approvals = await (db as any).StageApprovalAction.findAll({ + where: { applicationId: requestId, stageCode: newStage, decision: 'Approved' }, + attributes: ['actorRole'] + }); + const approvedRoles = new Set(approvals.map((a: any) => a.actorRole)); + + if (!approvedRoles.has(ROLES.DD_HEAD)) { + // DD Head hasn't approved yet, so ONLY notify DD Head + expectedRoles = [ROLES.DD_HEAD]; + } else if (!approvedRoles.has(ROLES.NBH)) { + // DD Head has approved, but NBH hasn't, so ONLY notify NBH + expectedRoles = [ROLES.NBH]; + } + } for (const p of participants) { const user = (p as any).user; diff --git a/src/constants/allowed-email-template-codes.ts b/src/constants/allowed-email-template-codes.ts index cf28874..fe7d67a 100644 --- a/src/constants/allowed-email-template-codes.ts +++ b/src/constants/allowed-email-template-codes.ts @@ -23,6 +23,9 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [ 'RESIGNATION_SUBMITTED', 'RESIGNATION_UPDATE', 'SLA_BREACH_WARNING', + 'SLA_REMINDER', + 'SLA_BREACH', + 'SLA_ESCALATION', 'TERMINATION_SCN_ISSUED', 'TERMINATION_UPDATE', 'USER_ASSIGNED', diff --git a/src/database/models/offboarding/relocation/RelocationRequest.ts b/src/database/models/offboarding/relocation/RelocationRequest.ts index c3c7e91..810a3d2 100644 --- a/src/database/models/offboarding/relocation/RelocationRequest.ts +++ b/src/database/models/offboarding/relocation/RelocationRequest.ts @@ -18,6 +18,13 @@ export interface RelocationRequestAttributes { progressPercentage: number; documents: any[]; timeline: any[]; + distance: string | null; + propertyType: string | null; + expectedRelocationDate: Date | null; + currentLatitude: number | null; + currentLongitude: number | null; + newLatitude: number | null; + newLongitude: number | null; } export interface RelocationRequestInstance extends Model, RelocationRequestAttributes { } @@ -105,6 +112,34 @@ export default (sequelize: Sequelize) => { timeline: { type: DataTypes.JSON, defaultValue: [] + }, + distance: { + type: DataTypes.STRING, + allowNull: true + }, + propertyType: { + type: DataTypes.STRING, + allowNull: true + }, + expectedRelocationDate: { + type: DataTypes.DATEONLY, + allowNull: true + }, + currentLatitude: { + type: DataTypes.DECIMAL(10, 8), + allowNull: true + }, + currentLongitude: { + type: DataTypes.DECIMAL(11, 8), + allowNull: true + }, + newLatitude: { + type: DataTypes.DECIMAL(10, 8), + allowNull: true + }, + newLongitude: { + type: DataTypes.DECIMAL(11, 8), + allowNull: true } }, { tableName: 'relocation_requests', diff --git a/src/emailtemplates/sla_breach.html b/src/emailtemplates/sla_breach.html new file mode 100644 index 0000000..6ab3d64 --- /dev/null +++ b/src/emailtemplates/sla_breach.html @@ -0,0 +1,8 @@ +{{> email_header}} +

SLA BREACHED

+

The following application has exceeded its allotted Turnaround Time (TAT).

+

Application ID: {{applicationId}}
+SLA Stage: {{stageName}}

+

Action is required immediately to minimize further delay.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/sla_escalation.html b/src/emailtemplates/sla_escalation.html new file mode 100644 index 0000000..440ce4d --- /dev/null +++ b/src/emailtemplates/sla_escalation.html @@ -0,0 +1,9 @@ +{{> email_header}} +

SLA ESCALATION [Level {{level}}]

+

The following application remains incomplete after an SLA breach for {{stageName}} and has been escalated to you.

+

Application ID: {{applicationId}}
+Escalation Level: Level {{level}}
+Delay: {{timeValue}} {{timeUnit}} past breach

+

Please review and intervene to resolve the pending activity.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/sla_reminder.html b/src/emailtemplates/sla_reminder.html new file mode 100644 index 0000000..7ed3305 --- /dev/null +++ b/src/emailtemplates/sla_reminder.html @@ -0,0 +1,8 @@ +{{> email_header}} +

SLA Reminder

+

This is a reminder that the following application is approaching its SLA deadline.

+

Application ID: {{applicationId}}
+SLA Stage: {{stageName}}

+

Please ensure this stage is completed promptly to avoid a breach.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 6821d13..f54f973 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -175,9 +175,14 @@ export const getPermissions = async (req: Request, res: Response) => { export const getAllUsers = async (req: Request, res: Response) => { try { - const { roleCode, locationId, search, page = 1, limit = 100 } = req.query as any; + const { roleCode, locationId, search, page = 1, limit = 100, isExternal } = req.query as any; const whereClause: any = {}; + // 0. External filter + if (isExternal !== undefined) { + whereClause.isExternal = isExternal === 'true'; + } + // 1. Search filter if (search) { whereClause[Op.or] = [ diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 2fec720..37efb33 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -125,7 +125,12 @@ const processStageDecision = async (params: { if (interviewId) { await InterviewEvaluation.update( - { decision, recommendation: decision }, + { + decision, + recommendation: decision, + remarks: remarks || null, + qualitativeFeedback: remarks || null + }, { where: { interviewId, evaluatorId: userId } } ); } @@ -200,7 +205,7 @@ const processStageDecision = async (params: { const application = await db.Application.findByPk(resolvedId); if (application) { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, { - reason: `Rejected during ${stageCode} stage: ${remarks}`, + reason: remarks || `Rejected during ${stageCode} stage.`, stage: APPLICATION_STAGES.REJECTED }); statusUpdated = true; @@ -215,14 +220,12 @@ const processStageDecision = async (params: { // Consolidated Parallel Track Logic (SRS v2.0 Section 1.1.2 Alignment) if (stageCode === 'ARCHITECTURE_WORK') { await application.update({ architectureStatus: 'COMPLETED' }); - // Architecture is non-blocking for LOA transition targetStatus = undefined; targetStage = 'Architecture Work'; targetProgress = application.progressPercentage || 80; - statusUpdated = true; // Mark as handled to avoid redundant transition check below + statusUpdated = true; } else if (stageCode === 'STATUTORY_WORK') { await application.update({ statutoryStatus: 'COMPLETED' }); - // Statutory completion triggers transition to LOA Pending (Stage 13) targetStatus = APPLICATION_STATUS.LOA_PENDING; targetStage = 'Statutory Work'; targetProgress = 85; @@ -231,7 +234,6 @@ const processStageDecision = async (params: { targetStage = 'LOA'; targetProgress = 95; } else if (stageCode === 'LOI_APPROVAL') { - // Always land on Security Details for admin + finance checks before LOI Issued (ignore client nextStatus). targetStatus = APPLICATION_STATUS.SECURITY_DETAILS; targetStage = APPLICATION_STAGES.LOI; targetProgress = typeof nextProgress === 'number' ? nextProgress : 78; @@ -239,13 +241,38 @@ const processStageDecision = async (params: { if (targetStatus) { await WorkflowService.transitionApplication(application, targetStatus, userId, { - reason: `Policy satisfied for ${stageCode}. Moving to next sequential step.`, + reason: remarks || `Policy satisfied for ${stageCode}. Moving to next sequential step.`, stage: targetStage, progressPercentage: targetProgress }); statusUpdated = true; } } + } else { + // --- SEQUENTIAL NOTIFICATION TRIGGER --- + // If policy is NOT yet met (e.g. DD Head approved, waiting for NBH), + // we still need to trigger notifications for the NEXT person in the sequence. + try { + const { notifyStakeholdersOnTransition } = await import('../../common/utils/workflow-email-notifications.js'); + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + await notifyStakeholdersOnTransition( + application.id, + 'application', + application.currentStage || stageCode, // Notify for the CURRENT stage (to trigger resolveNextActors logic) + { + code: application.applicationId, + dealerName: application.applicantName || 'Applicant', + dealerId: '', + actionUserFullName: 'Stakeholder', // Will be resolved by notifyStakeholders if needed + action: `Partial Approval: ${roleCode} approved ${stageCode}`, + remarks: remarks || 'Approval recorded. Waiting for next sequential stakeholder.', + link: `${portalBase}/applications/${application.id}` + } + ); + } catch (err) { + console.error('[processStageDecision] Sequential notification failed:', err); + } } return { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3487662..15883d4 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -91,7 +91,7 @@ export const login = async (req: Request, res: Response) => { if (user.status !== 'active') { return res.status(403).json({ success: false, - message: 'Account is deactivated' + message: 'Account is inactive' }); } diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 8bf933d..d280dcc 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1322,15 +1322,49 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => { const transaction = await db.sequelize.transaction(); try { const defaults = [ + // --- ONBOARDING --- { 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' } + { stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' }, // Per Doc §9.4.5 + { stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' }, + { stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' }, + { stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' }, + { stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' }, + { stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' }, + { stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' }, + + // --- RESIGNATION --- + { stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' }, + { stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' }, + { stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' }, + { stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' }, + { stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' }, + { stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' }, + + // --- TERMINATION --- + { stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' }, + { stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' }, + { stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' }, + { stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' }, + { stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' }, + { stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' }, + { stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' }, + + // --- RELOCATION --- + { stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' }, + { stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' }, + { stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' }, + { stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' }, + { stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' }, + + // --- CONSTITUTIONAL CHANGE --- + { stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' }, + { stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' }, + { stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' } ]; for (const item of defaults) { - const [config] = await db.SLAConfiguration.findOrCreate({ + const [config, created] = await db.SLAConfiguration.findOrCreate({ where: { activityName: item.stage }, defaults: { activityName: item.stage, @@ -1342,21 +1376,65 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => { transaction }); - // Add a default reminder for each - await db.SLAReminder.findOrCreate({ - where: { slaConfigId: config.id }, - defaults: { + if (!created) { + // Update existing to match new standard defaults if they haven't been customized too much? + // Actually, let's just make sure they have the right roles as per new document alignment + await config.update({ + ownerRole: item.role, + tatHours: item.tat, + tatUnit: item.unit as any + }, { transaction }); + } + + // Cleanup old reminders/escalations to avoid duplicates if re-running + await db.SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction }); + await db.SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction }); + + // 1. Default Reminders (Per Doc §9.4.5: T-24h and T-4h) + await db.SLAReminder.bulkCreate([ + { slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true }, - transaction - }); + { + slaConfigId: config.id, + timeValue: 4, + timeUnit: 'hours', + isEnabled: true + } + ], { transaction }); + + // 2. Escalation Matrix (Per Doc §9.4.5) + // L1: +4h, L2: +12h, L3: +24h + await db.SLAEscalationConfig.bulkCreate([ + { + slaConfigId: config.id, + level: 1, + timeValue: 4, + timeUnit: 'hours', + notifyRole: 'ZBH' // Example default escalation path + }, + { + slaConfigId: config.id, + level: 2, + timeValue: 12, + timeUnit: 'hours', + notifyRole: 'DD Lead' + }, + { + slaConfigId: config.id, + level: 3, + timeValue: 24, + timeUnit: 'hours', + notifyRole: 'NBH' + } + ], { transaction }); } await transaction.commit(); - res.json({ success: true, message: 'Default SLA configurations initialized' }); + res.json({ success: true, message: 'Comprehensive default SLA configurations initialized across all modules' }); } catch (error) { await transaction.rollback(); console.error('Init SLA error:', error); diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index 47bb7a5..fe00051 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -269,7 +269,11 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { // Frontend may send 'new*' fields directly newAddress, newCity, newState, // IDs for traceability - newDistrictId, newStateId + newDistrictId, newStateId, + distance, + propertyType, + // Fallback for coordinates if sent as non-proposed + newLatitude, newLongitude } = req.body; // Use proposed* fields if available, otherwise fall back to new* fields @@ -331,7 +335,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { const request = await RelocationRequest.create({ requestId, outletId, - dealerId: req.user.id, + dealerId: outlet.dealerId, relocationType: finalRelocationType, newAddress: finalAddress, newCity: finalCity, @@ -339,6 +343,13 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { newDistrictId: newDistrictId || null, newStateId: newStateId || null, reason, + distance, + propertyType: propertyType || null, + expectedRelocationDate: proposedDate || null, + currentLatitude: currentLatitude || null, + currentLongitude: currentLongitude || null, + newLatitude: proposedLatitude || newLatitude || null, + newLongitude: proposedLongitude || newLongitude || null, currentStage: RELOCATION_STAGES.ASM_REVIEW, status: 'Pending ASM Review', progressPercentage: 10, diff --git a/src/modules/sla/sla.controller.ts b/src/modules/sla/sla.controller.ts index b056ac7..cf34a46 100644 --- a/src/modules/sla/sla.controller.ts +++ b/src/modules/sla/sla.controller.ts @@ -70,3 +70,38 @@ export const getTracking = async (req: Request, res: Response) => { res.status(500).json({ success: false, message: 'Error fetching SLA tracking' }); } }; + +// --- Debug Endpoint --- +export const getQueueStatus = async (req: Request, res: Response) => { + try { + const { slaQueue } = await import('../../common/queues/sla.queue.js'); + + // 1. Get BullMQ Stats + const repeatableJobs = await slaQueue.getRepeatableJobs(); + const counts = await slaQueue.getJobCounts('active', 'waiting', 'completed', 'failed', 'delayed'); + + // 2. Get Database Active Tracks (The real "pending" work) + const activeTracks = await SLATracking.findAll({ + where: { isActive: true, endTime: null }, + attributes: ['id', 'applicationId', 'stageName', 'startTime', 'isBreached'] + }); + + res.json({ + success: true, + debug: { + isFastMode: process.env.DEBUG_SLA_FAST_MODE === 'true', + redis: { + repeatableJobs, + counts + }, + database: { + pendingTracksCount: activeTracks.length, + activeTracks + } + } + }); + } catch (error) { + console.error('Debug SLA Queue error:', error); + res.status(500).json({ success: false, message: 'Error fetching SLA debug info' }); + } +}; diff --git a/src/modules/sla/sla.routes.ts b/src/modules/sla/sla.routes.ts index 2b6fe33..9588745 100644 --- a/src/modules/sla/sla.routes.ts +++ b/src/modules/sla/sla.routes.ts @@ -8,5 +8,6 @@ router.use(authenticate as any); router.get('/configs', slaController.getConfigs); router.put('/configs/:id', slaController.updateConfig); router.get('/tracking/:applicationId', slaController.getTracking); +router.get('/debug/queue', slaController.getQueueStatus); export default router; diff --git a/src/scripts/patch-user-status.ts b/src/scripts/patch-user-status.ts new file mode 100644 index 0000000..bc1409e --- /dev/null +++ b/src/scripts/patch-user-status.ts @@ -0,0 +1,41 @@ +import db from '../database/models/index.js'; +const { User } = db; + +async function patchUserStatus() { + try { + console.log('--- Starting User Status Patch ---'); + + const [updatedCount] = await User.update( + { status: 'inactive' }, + { + where: { + status: 'deactivated' + } + } + ); + + console.log(`Success: Updated ${updatedCount} users from "deactivated" to "inactive".`); + + // Also check if any are 'Deactive' (case variant) + const [updatedCount2] = await User.update( + { status: 'inactive' }, + { + where: { + status: 'deactive' + } + } + ); + + if (updatedCount2 > 0) { + console.log(`Success: Updated ${updatedCount2} users from "deactive" to "inactive".`); + } + + console.log('--- Patch Completed Successfully ---'); + process.exit(0); + } catch (error) { + console.error('Error during patch execution:', error); + process.exit(1); + } +} + +patchUserStatus(); diff --git a/src/scripts/seed-interview-templates.ts b/src/scripts/seed-interview-templates.ts new file mode 100644 index 0000000..9709735 --- /dev/null +++ b/src/scripts/seed-interview-templates.ts @@ -0,0 +1,90 @@ +import db from '../database/models/index.js'; + +const seedInterviewTemplates = async () => { + try { + console.log('--- Seeding Missing Interview Templates ---'); + + const templates = [ + { + templateCode: 'INTERVIEW_SCHEDULED_APPLICANT', + description: 'Notification sent to the applicant when an interview is scheduled', + subject: 'Interview Scheduled: {{applicationId}}', + body: ` + +

Dear {{applicantName}},

+

Your {{type}} for Royal Enfield Dealership Application ({{applicationId}}) has been scheduled.

+

Scheduled Time: {{scheduledAt}}

+

Meeting Link/Location: {{link}}

+

Please ensure you are available at the scheduled time.

+

Join Interview / View Details

+

Best Regards,
Royal Enfield Onboarding Team

+ + `, + placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'link'] + }, + { + templateCode: 'INTERVIEW_SCHEDULED_PANELIST', + description: 'Notification sent to the panelist (employee) when assigned an interview', + subject: 'New Interview Assignment: {{applicationId}}', + body: ` + +

Hi {{panelistName}},

+

You have been assigned as a panelist for {{type}} with {{applicantName}}.

+

Application ID: {{applicationId}}

+

Scheduled Time: {{scheduledAt}}

+

Meeting Link/Location: {{link}}

+

Open Assessment Dashboard

+

Please review the applicant's profile before the session.

+

Regards,
System Administrator

+ + `, + placeholders: ['panelistName', 'applicantName', 'applicationId', 'type', 'scheduledAt', 'link'] + }, + { + templateCode: 'WORKFLOW_ACTION_REQUIRED', + description: 'Notification for stakeholders when their action is required in a workflow', + subject: 'Action Required: {{requestId}} — {{targetStage}}', + body: ` + +

Dear Stakeholder,

+

Application {{requestId}} (Dealer: {{dealerName}}) has reached the {{targetStage}} stage and requires your action/review.

+

Review and Take Action

+

Regards,
Royal Enfield Workflow System

+ + `, + placeholders: ['requestId', 'dealerName', 'targetStage', 'link'] + }, + { + templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER', + description: 'Milestone update notification for the dealer/applicant', + subject: 'Update on your Request: {{requestId}}', + body: ` + +

Dear {{dealerName}},

+

Your request {{requestId}} has been updated to: {{targetStage}}.

+

You can track the live progress here: View Tracking

+

Best Regards,
Royal Enfield Team

+ + `, + placeholders: ['requestId', 'dealerName', 'targetStage', 'link'] + } + + ]; + + for (const t of templates) { + await db.EmailTemplate.upsert({ + ...t, + isActive: true + }); + console.log(`Successfully seeded/updated: ${t.templateCode}`); + } + + console.log('--- Interview Templates Seeded Successfully ---'); + process.exit(0); + } catch (error) { + console.error('Error seeding interview templates:', error); + process.exit(1); + } +}; + +seedInterviewTemplates(); diff --git a/src/scripts/seed-master-emails.ts b/src/scripts/seed-master-emails.ts index c387dca..097f10d 100644 --- a/src/scripts/seed-master-emails.ts +++ b/src/scripts/seed-master-emails.ts @@ -191,6 +191,27 @@ const seedTemplates = async () => { subject: 'SLA breach: {{applicationId}} — {{stageName}}', fileName: 'sla_breach_warning.html', placeholders: ['applicationId', 'stageName', 'currentStage'] + }, + { + templateCode: 'SLA_REMINDER', + description: 'Reminder sent before SLA breach', + subject: 'SLA Reminder: {{applicationId}} — {{stageName}}', + fileName: 'sla_reminder.html', + placeholders: ['applicationId', 'stageName', 'link'] + }, + { + templateCode: 'SLA_BREACH', + description: 'Notification sent when SLA is breached', + subject: 'SLA BREACHED: {{applicationId}} — {{stageName}}', + fileName: 'sla_breach.html', + placeholders: ['applicationId', 'stageName', 'link'] + }, + { + templateCode: 'SLA_ESCALATION', + description: 'Notification sent for multi-level SLA escalations', + subject: 'SLA ESCALATION [L{{level}}]: {{applicationId}} — {{stageName}}', + fileName: 'sla_escalation.html', + placeholders: ['applicationId', 'stageName', 'level', 'timeValue', 'timeUnit', 'link'] } ]; diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index 8a8f632..8082d33 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -111,7 +111,7 @@ export class ResignationWorkflowService { if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) { logger.info(`[ResignationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`); await user.update({ - status: 'deactivated', + status: 'inactive', isActive: false }, transaction ? { transaction } : undefined); } diff --git a/src/services/SLAService.ts b/src/services/SLAService.ts index 635c9d0..10729ab 100644 --- a/src/services/SLAService.ts +++ b/src/services/SLAService.ts @@ -85,7 +85,13 @@ export class SLAService { } private static getTatInMs(value: number, unit: string): number { - const factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000; + let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000; + + // Debug Mode: 1 hour = 1 minute (60x speedup) + if (process.env.DEBUG_SLA_FAST_MODE === 'true') { + factor = factor / 60; + } + return value * factor; } diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index d4a0599..443acce 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -148,7 +148,7 @@ export class TerminationWorkflowService { if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) { logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`); await user.update({ - status: 'deactivated', + status: 'inactive', isActive: false }); } diff --git a/trigger-termination.js b/trigger-termination.js index 162bf8e..dddb705 100644 --- a/trigger-termination.js +++ b/trigger-termination.js @@ -184,7 +184,7 @@ async function run() { const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); const dealerUser = userRes.data.find(u => u.dealerId === targetDealer.id); - if (dealerUser && !dealerUser.isActive && dealerUser.status === 'deactivated') { + if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') { console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`); } else { console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);