sla feature addd and templates also included in the sed file user deacticated changed to in active
This commit is contained in:
parent
0d6821b7a9
commit
bfef307725
@ -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
|
||||
|
||||
216
sla-governance-postman-collection.json
Normal file
216
sla-governance-postman-collection.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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'}`);
|
||||
};
|
||||
|
||||
@ -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<string[]> {
|
||||
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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>, 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',
|
||||
|
||||
8
src/emailtemplates/sla_breach.html
Normal file
8
src/emailtemplates/sla_breach.html
Normal 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}}
|
||||
9
src/emailtemplates/sla_escalation.html
Normal file
9
src/emailtemplates/sla_escalation.html
Normal 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}}
|
||||
8
src/emailtemplates/sla_reminder.html
Normal file
8
src/emailtemplates/sla_reminder.html
Normal 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}}
|
||||
@ -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] = [
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
41
src/scripts/patch-user-status.ts
Normal file
41
src/scripts/patch-user-status.ts
Normal 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();
|
||||
90
src/scripts/seed-interview-templates.ts
Normal file
90
src/scripts/seed-interview-templates.ts
Normal 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();
|
||||
@ -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']
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user