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-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
|
||||||
|
|||||||
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)
|
* 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'}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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) => {
|
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] = [
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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}}',
|
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']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user