sla feature addd and templates also included in the sed file user deacticated changed to in active

This commit is contained in:
laxman h 2026-04-21 19:07:59 +05:30
parent 0d6821b7a9
commit bfef307725
23 changed files with 675 additions and 36 deletions

View File

@ -88,6 +88,19 @@
| C-04 | NBH sees "Verify" button | 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** | ✅ | | | 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 ## 5. Relocation Module

View File

@ -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"
}
]
}

View File

@ -9,12 +9,20 @@ export const slaQueue = new Queue('slaQueue', {
* Schedule the recurring SLA check (Every Hour) * Schedule the recurring SLA check (Every Hour)
*/ */
export const scheduleSLACheck = async () => { 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', {}, { await slaQueue.add('checkSLABreaches', {}, {
repeat: { 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'}`);
}; };

View File

@ -167,6 +167,7 @@ export async function notifyRelocationSubmittedEmails(
/** /**
* Resolves the user IDs of the required 'next actor' based on the workflow stage. * Resolves the user IDs of the required 'next actor' based on the workflow stage.
* Updated: Now awareness of sequential flows (e.g., LOI/LOA) to prevent parallel notifications.
*/ */
export async function resolveNextActors(requestId: string, requestType: string, newStage: string): Promise<string[]> { export async function resolveNextActors(requestId: string, requestType: string, newStage: string): Promise<string[]> {
try { try {
@ -205,7 +206,7 @@ export async function resolveNextActors(requestId: string, requestType: string,
'CCO Approval': [ROLES.CCO], 'CCO Approval': [ROLES.CCO],
'CEO Final Approval': [ROLES.CEO], 'CEO Final Approval': [ROLES.CEO],
// --- Onboarding Specific --- // --- Onboarding Specific (Sequential Flows) ---
'Level 1 Interview': [ROLES.DD_ZM, ROLES.RBM], 'Level 1 Interview': [ROLES.DD_ZM, ROLES.RBM],
'Level 1 Interview Pending': [ROLES.DD_ZM, ROLES.RBM], 'Level 1 Interview Pending': [ROLES.DD_ZM, ROLES.RBM],
'Interview Level 1': [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': [ROLES.NBH, ROLES.DD_HEAD],
'Level 3 Interview Pending': [ROLES.NBH, ROLES.DD_HEAD], 'Level 3 Interview Pending': [ROLES.NBH, ROLES.DD_HEAD],
'Interview Level 3': [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], // LOI and LOA follow (DD Head -> NBH) sequence
'LOA Approval': [ROLES.NBH, ROLES.DD_HEAD], 'LOI Approval': [ROLES.DD_HEAD, ROLES.NBH],
'LOA Pending': [ROLES.NBH, ROLES.DD_HEAD], '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],
'FDD_VERIFICATION': [ROLES.FDD], 'FDD_VERIFICATION': [ROLES.FDD],
'Architecture Team Assigned': [ROLES.ARCHITECTURE], 'Architecture Team Assigned': [ROLES.ARCHITECTURE],
@ -242,7 +246,27 @@ export async function resolveNextActors(requestId: string, requestType: string,
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN] '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) { for (const p of participants) {
const user = (p as any).user; const user = (p as any).user;

View File

@ -23,6 +23,9 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'RESIGNATION_SUBMITTED', 'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE', 'RESIGNATION_UPDATE',
'SLA_BREACH_WARNING', 'SLA_BREACH_WARNING',
'SLA_REMINDER',
'SLA_BREACH',
'SLA_ESCALATION',
'TERMINATION_SCN_ISSUED', 'TERMINATION_SCN_ISSUED',
'TERMINATION_UPDATE', 'TERMINATION_UPDATE',
'USER_ASSIGNED', 'USER_ASSIGNED',

View File

@ -18,6 +18,13 @@ export interface RelocationRequestAttributes {
progressPercentage: number; progressPercentage: number;
documents: any[]; documents: any[];
timeline: 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>, RelocationRequestAttributes { } export interface RelocationRequestInstance extends Model<RelocationRequestAttributes>, RelocationRequestAttributes { }
@ -105,6 +112,34 @@ export default (sequelize: Sequelize) => {
timeline: { timeline: {
type: DataTypes.JSON, type: DataTypes.JSON,
defaultValue: [] 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', tableName: 'relocation_requests',

View File

@ -0,0 +1,8 @@
{{> email_header}}
<h2 style="color: #d32f2f;">SLA BREACHED</h2>
<p>The following application has exceeded its allotted Turnaround Time (TAT).</p>
<p><strong>Application ID:</strong> {{applicationId}}<br>
<strong>SLA Stage:</strong> {{stageName}}</p>
<p>Action is required immediately to minimize further delay.</p>
{{> primary_cta}}
{{> email_footer}}

View File

@ -0,0 +1,9 @@
{{> email_header}}
<h2 style="color: #d32f2f;">SLA ESCALATION [Level {{level}}]</h2>
<p>The following application remains incomplete after an SLA breach for <strong>{{stageName}}</strong> and has been escalated to you.</p>
<p><strong>Application ID:</strong> {{applicationId}}<br>
<strong>Escalation Level:</strong> Level {{level}}<br>
<strong>Delay:</strong> {{timeValue}} {{timeUnit}} past breach</p>
<p>Please review and intervene to resolve the pending activity.</p>
{{> primary_cta}}
{{> email_footer}}

View File

@ -0,0 +1,8 @@
{{> email_header}}
<h2>SLA Reminder</h2>
<p>This is a reminder that the following application is approaching its SLA deadline.</p>
<p><strong>Application ID:</strong> {{applicationId}}<br>
<strong>SLA Stage:</strong> {{stageName}}</p>
<p>Please ensure this stage is completed promptly to avoid a breach.</p>
{{> primary_cta}}
{{> email_footer}}

View File

@ -175,9 +175,14 @@ export const getPermissions = async (req: Request, res: Response) => {
export const getAllUsers = async (req: Request, res: Response) => { export const getAllUsers = async (req: Request, res: Response) => {
try { 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 = {}; const whereClause: any = {};
// 0. External filter
if (isExternal !== undefined) {
whereClause.isExternal = isExternal === 'true';
}
// 1. Search filter // 1. Search filter
if (search) { if (search) {
whereClause[Op.or] = [ whereClause[Op.or] = [

View File

@ -125,7 +125,12 @@ const processStageDecision = async (params: {
if (interviewId) { if (interviewId) {
await InterviewEvaluation.update( await InterviewEvaluation.update(
{ decision, recommendation: decision }, {
decision,
recommendation: decision,
remarks: remarks || null,
qualitativeFeedback: remarks || null
},
{ where: { interviewId, evaluatorId: userId } } { where: { interviewId, evaluatorId: userId } }
); );
} }
@ -200,7 +205,7 @@ const processStageDecision = async (params: {
const application = await db.Application.findByPk(resolvedId); const application = await db.Application.findByPk(resolvedId);
if (application) { if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
reason: `Rejected during ${stageCode} stage: ${remarks}`, reason: remarks || `Rejected during ${stageCode} stage.`,
stage: APPLICATION_STAGES.REJECTED stage: APPLICATION_STAGES.REJECTED
}); });
statusUpdated = true; statusUpdated = true;
@ -215,14 +220,12 @@ const processStageDecision = async (params: {
// Consolidated Parallel Track Logic (SRS v2.0 Section 1.1.2 Alignment) // Consolidated Parallel Track Logic (SRS v2.0 Section 1.1.2 Alignment)
if (stageCode === 'ARCHITECTURE_WORK') { if (stageCode === 'ARCHITECTURE_WORK') {
await application.update({ architectureStatus: 'COMPLETED' }); await application.update({ architectureStatus: 'COMPLETED' });
// Architecture is non-blocking for LOA transition
targetStatus = undefined; targetStatus = undefined;
targetStage = 'Architecture Work'; targetStage = 'Architecture Work';
targetProgress = application.progressPercentage || 80; targetProgress = application.progressPercentage || 80;
statusUpdated = true; // Mark as handled to avoid redundant transition check below statusUpdated = true;
} else if (stageCode === 'STATUTORY_WORK') { } else if (stageCode === 'STATUTORY_WORK') {
await application.update({ statutoryStatus: 'COMPLETED' }); await application.update({ statutoryStatus: 'COMPLETED' });
// Statutory completion triggers transition to LOA Pending (Stage 13)
targetStatus = APPLICATION_STATUS.LOA_PENDING; targetStatus = APPLICATION_STATUS.LOA_PENDING;
targetStage = 'Statutory Work'; targetStage = 'Statutory Work';
targetProgress = 85; targetProgress = 85;
@ -231,7 +234,6 @@ const processStageDecision = async (params: {
targetStage = 'LOA'; targetStage = 'LOA';
targetProgress = 95; targetProgress = 95;
} else if (stageCode === 'LOI_APPROVAL') { } 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; targetStatus = APPLICATION_STATUS.SECURITY_DETAILS;
targetStage = APPLICATION_STAGES.LOI; targetStage = APPLICATION_STAGES.LOI;
targetProgress = typeof nextProgress === 'number' ? nextProgress : 78; targetProgress = typeof nextProgress === 'number' ? nextProgress : 78;
@ -239,13 +241,38 @@ const processStageDecision = async (params: {
if (targetStatus) { if (targetStatus) {
await WorkflowService.transitionApplication(application, targetStatus, userId, { 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, stage: targetStage,
progressPercentage: targetProgress progressPercentage: targetProgress
}); });
statusUpdated = true; 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 { return {

View File

@ -91,7 +91,7 @@ export const login = async (req: Request, res: Response) => {
if (user.status !== 'active') { if (user.status !== 'active') {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Account is deactivated' message: 'Account is inactive'
}); });
} }

View File

@ -1322,15 +1322,49 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const defaults = [ const defaults = [
// --- ONBOARDING ---
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' }, { stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, 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, DD-ZM', tat: 2, unit: 'days' }, // Per Doc §9.4.5
{ stage: 'Level 1 Interview', role: 'RBM', tat: 7, unit: 'days' }, { stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, 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) { for (const item of defaults) {
const [config] = await db.SLAConfiguration.findOrCreate({ const [config, created] = await db.SLAConfiguration.findOrCreate({
where: { activityName: item.stage }, where: { activityName: item.stage },
defaults: { defaults: {
activityName: item.stage, activityName: item.stage,
@ -1342,21 +1376,65 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
transaction transaction
}); });
// Add a default reminder for each if (!created) {
await db.SLAReminder.findOrCreate({ // Update existing to match new standard defaults if they haven't been customized too much?
where: { slaConfigId: config.id }, // Actually, let's just make sure they have the right roles as per new document alignment
defaults: { 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, slaConfigId: config.id,
timeValue: 1, timeValue: 1,
timeUnit: 'days', timeUnit: 'days',
isEnabled: true 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(); 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) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
console.error('Init SLA error:', error); console.error('Init SLA error:', error);

View File

@ -269,7 +269,11 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
// Frontend may send 'new*' fields directly // Frontend may send 'new*' fields directly
newAddress, newCity, newState, newAddress, newCity, newState,
// IDs for traceability // IDs for traceability
newDistrictId, newStateId newDistrictId, newStateId,
distance,
propertyType,
// Fallback for coordinates if sent as non-proposed
newLatitude, newLongitude
} = req.body; } = req.body;
// Use proposed* fields if available, otherwise fall back to new* fields // 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({ const request = await RelocationRequest.create({
requestId, requestId,
outletId, outletId,
dealerId: req.user.id, dealerId: outlet.dealerId,
relocationType: finalRelocationType, relocationType: finalRelocationType,
newAddress: finalAddress, newAddress: finalAddress,
newCity: finalCity, newCity: finalCity,
@ -339,6 +343,13 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
newDistrictId: newDistrictId || null, newDistrictId: newDistrictId || null,
newStateId: newStateId || null, newStateId: newStateId || null,
reason, 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, currentStage: RELOCATION_STAGES.ASM_REVIEW,
status: 'Pending ASM Review', status: 'Pending ASM Review',
progressPercentage: 10, progressPercentage: 10,

View File

@ -70,3 +70,38 @@ export const getTracking = async (req: Request, res: Response) => {
res.status(500).json({ success: false, message: 'Error fetching SLA tracking' }); 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' });
}
};

View File

@ -8,5 +8,6 @@ router.use(authenticate as any);
router.get('/configs', slaController.getConfigs); router.get('/configs', slaController.getConfigs);
router.put('/configs/:id', slaController.updateConfig); router.put('/configs/:id', slaController.updateConfig);
router.get('/tracking/:applicationId', slaController.getTracking); router.get('/tracking/:applicationId', slaController.getTracking);
router.get('/debug/queue', slaController.getQueueStatus);
export default router; export default router;

View File

@ -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();

View File

@ -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: `
<html><body>
<h2>Dear {{applicantName}},</h2>
<p>Your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been scheduled.</p>
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{link}}</p>
<p>Please ensure you are available at the scheduled time.</p>
<p><a href="{{link}}">Join Interview / View Details</a></p>
<p>Best Regards,<br>Royal Enfield Onboarding Team</p>
</body></html>
`,
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: `
<html><body>
<h2>Hi {{panelistName}},</h2>
<p>You have been assigned as a panelist for <strong>{{type}}</strong> with <strong>{{applicantName}}</strong>.</p>
<p><strong>Application ID:</strong> {{applicationId}}</p>
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{link}}</p>
<p><a href="{{link}}">Open Assessment Dashboard</a></p>
<p>Please review the applicant's profile before the session.</p>
<p>Regards,<br>System Administrator</p>
</body></html>
`,
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: `
<html><body>
<h2>Dear Stakeholder,</h2>
<p>Application <strong>{{requestId}}</strong> (Dealer: {{dealerName}}) has reached the <strong>{{targetStage}}</strong> stage and requires your action/review.</p>
<p><a href="{{link}}">Review and Take Action</a></p>
<p>Regards,<br>Royal Enfield Workflow System</p>
</body></html>
`,
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: `
<html><body>
<h2>Dear {{dealerName}},</h2>
<p>Your request <strong>{{requestId}}</strong> has been updated to: <strong>{{targetStage}}</strong>.</p>
<p>You can track the live progress here: <a href="{{link}}">View Tracking</a></p>
<p>Best Regards,<br>Royal Enfield Team</p>
</body></html>
`,
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();

View File

@ -191,6 +191,27 @@ const seedTemplates = async () => {
subject: 'SLA breach: {{applicationId}} — {{stageName}}', subject: 'SLA breach: {{applicationId}} — {{stageName}}',
fileName: 'sla_breach_warning.html', fileName: 'sla_breach_warning.html',
placeholders: ['applicationId', 'stageName', 'currentStage'] 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']
} }
]; ];

View File

@ -111,7 +111,7 @@ export class ResignationWorkflowService {
if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) { if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) {
logger.info(`[ResignationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`); logger.info(`[ResignationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
await user.update({ await user.update({
status: 'deactivated', status: 'inactive',
isActive: false isActive: false
}, transaction ? { transaction } : undefined); }, transaction ? { transaction } : undefined);
} }

View File

@ -85,7 +85,13 @@ export class SLAService {
} }
private static getTatInMs(value: number, unit: string): number { 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; return value * factor;
} }

View File

@ -148,7 +148,7 @@ export class TerminationWorkflowService {
if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) { if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`); logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
await user.update({ await user.update({
status: 'deactivated', status: 'inactive',
isActive: false isActive: false
}); });
} }

View File

@ -184,7 +184,7 @@ async function run() {
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
const dealerUser = userRes.data.find(u => u.dealerId === targetDealer.id); 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.`); console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`);
} else { } else {
console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`); console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);