319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
import db from '../database/models/index.js';
|
|
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
|
|
import { Op } from 'sequelize';
|
|
import { NotificationService } from './NotificationService.js';
|
|
|
|
export class SLAService {
|
|
/**
|
|
* Periodically check for SLA breaches, reminders and escalations
|
|
*/
|
|
static async checkBreaches() {
|
|
console.log('[SLA Service] Starting SLA status check...');
|
|
const now = new Date();
|
|
|
|
// 1. Handle Active Tracks (Reminders and Initial Breach)
|
|
const activeTracking = await SLATracking.findAll({
|
|
where: { isActive: true, endTime: null },
|
|
include: [{ model: Application, as: 'application' }]
|
|
});
|
|
|
|
for (const track of activeTracking) {
|
|
const config = await SLAConfiguration.findOne({
|
|
where: { activityName: track.stageName, isActive: true },
|
|
include: [
|
|
{ model: SLAReminder, as: 'reminders' },
|
|
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
|
|
]
|
|
});
|
|
|
|
if (!config) continue;
|
|
|
|
const startTime = new Date(track.startTime);
|
|
const tatMs = this.getTatInMs(config.tatHours, config.tatUnit);
|
|
const deadline = new Date(startTime.getTime() + tatMs);
|
|
|
|
// CASE A: Not Breached Yet - Check Reminders
|
|
if (!track.isBreached && now < deadline) {
|
|
await this.processReminders(track, config, now, deadline);
|
|
}
|
|
// CASE B: Just Breached - Mark it and trigger Level 1 Escalation
|
|
else if (!track.isBreached && now >= deadline) {
|
|
await this.triggerBreach(track, now);
|
|
}
|
|
// CASE C: Already Breached - Check for Escalations
|
|
else if (track.isBreached) {
|
|
await this.processEscalations(track, config, now, deadline);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start tracking SLA for a new stage
|
|
*/
|
|
static async startTrack(applicationId: string, stageName: string) {
|
|
console.log(`[SLA Service] Starting SLA track for App: ${applicationId}, Stage: ${stageName}`);
|
|
|
|
// Ensure NO other active tracks for this application exist
|
|
await SLATracking.update(
|
|
{ isActive: false, endTime: new Date() },
|
|
{ where: { applicationId, isActive: true, endTime: null } }
|
|
);
|
|
|
|
const config = await SLAConfiguration.findOne({
|
|
where: { activityName: stageName, isActive: true }
|
|
});
|
|
|
|
if (config) {
|
|
await SLATracking.create({
|
|
applicationId,
|
|
stageName,
|
|
startTime: new Date(),
|
|
isActive: true
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop tracking SLA for a stage
|
|
*/
|
|
static async stopTrack(applicationId: string, stageName: string) {
|
|
console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`);
|
|
await SLATracking.update(
|
|
{ isActive: false, endTime: new Date() },
|
|
{ where: { applicationId, stageName, isActive: true } }
|
|
);
|
|
}
|
|
|
|
private static getTatInMs(value: number, unit: string): number {
|
|
const factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
return value * factor;
|
|
}
|
|
|
|
private static async processReminders(track: any, config: any, now: Date, deadline: Date) {
|
|
const msRemaining = deadline.getTime() - now.getTime();
|
|
|
|
for (const reminder of config.reminders || []) {
|
|
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
|
|
|
|
if (msRemaining <= reminderMs) {
|
|
const metadata = track.metadata || {};
|
|
const reminderKey = `reminder_sent_${reminder.id}`;
|
|
|
|
if (!metadata[reminderKey]) {
|
|
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
|
|
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
|
|
|
|
await this.notifyStakeholder(track, 'SLA_REMINDER', {
|
|
title: `SLA Reminder: ${track.stageName}`,
|
|
message: `The application ${track.application?.applicationId} is approaching its SLA deadline for ${track.stageName}.`
|
|
});
|
|
|
|
// §9.4.1 — Auto-log in Work Notes
|
|
await this.logWorkNote(track.applicationId, `[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`);
|
|
|
|
metadata[reminderKey] = true;
|
|
await track.update({ metadata });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async triggerBreach(track: any, now: Date) {
|
|
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`);
|
|
await track.update({ isBreached: true });
|
|
|
|
await SLABreach.create({
|
|
trackingId: track.id,
|
|
applicationId: track.applicationId,
|
|
stageCode: track.stageName,
|
|
breachedAt: now,
|
|
severity: 'High',
|
|
status: 'Open'
|
|
});
|
|
|
|
await this.notifyStakeholder(track, 'SLA_BREACH', {
|
|
title: `SLA BREACHED: ${track.stageName}`,
|
|
message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.`
|
|
});
|
|
|
|
// §9.4.1 — Auto-log in Work Notes
|
|
await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`);
|
|
}
|
|
|
|
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
|
|
const msSinceBreach = now.getTime() - deadline.getTime();
|
|
|
|
for (const esc of config.escalationConfigs || []) {
|
|
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
|
|
|
|
if (msSinceBreach >= escMs) {
|
|
const metadata = track.metadata || {};
|
|
const escKey = `esc_sent_L${esc.level}`;
|
|
|
|
if (!metadata[escKey]) {
|
|
console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`);
|
|
|
|
const { User, Application, District, Region, Zone } = db;
|
|
let targetEmail = esc.notifyEmail;
|
|
let recipientId = null;
|
|
|
|
// Runtime Resolution: Resolve role to a specific user/email if role is provided
|
|
if (esc.notifyRole) {
|
|
const app = await Application.findByPk(track.applicationId, {
|
|
include: [{
|
|
model: District,
|
|
as: 'district',
|
|
include: [
|
|
{ model: Region, as: 'region' },
|
|
{ model: Zone, as: 'zone' }
|
|
]
|
|
}]
|
|
});
|
|
|
|
if (app?.district) {
|
|
const d = app.district;
|
|
const r = d.region;
|
|
const z = d.zone;
|
|
|
|
// Map geography-bound roles
|
|
const roleMap: Record<string, string | null> = {
|
|
'ASM': d.asmId,
|
|
'DD-ZM': d.zmId,
|
|
'RBM': r?.rbmId || null,
|
|
'ZBH': z?.zbhId || null
|
|
};
|
|
|
|
if (roleMap[esc.notifyRole]) {
|
|
recipientId = roleMap[esc.notifyRole];
|
|
}
|
|
}
|
|
|
|
// Fallback/National roles: Resolve by roleCode singleton
|
|
if (!recipientId) {
|
|
const user = await User.findOne({
|
|
where: { roleCode: esc.notifyRole, status: 'active' },
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
if (user) recipientId = user.id;
|
|
}
|
|
}
|
|
|
|
// Resolve final email and phone if we have a recipientId
|
|
let phone = null;
|
|
if (recipientId) {
|
|
const user = await User.findByPk(recipientId);
|
|
if (user) {
|
|
targetEmail = user.email;
|
|
phone = user.mobileNumber || user.phone || null;
|
|
}
|
|
}
|
|
|
|
if (targetEmail) {
|
|
await NotificationService.notify(recipientId, targetEmail, {
|
|
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
|
message: `The application ${track.application?.applicationId} remains incomplete after SLA breach for ${track.stageName}.`,
|
|
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: 'SLA_ESCALATION',
|
|
placeholders: {
|
|
applicationId: track.application?.applicationId || '',
|
|
stageName: track.stageName,
|
|
level: esc.level,
|
|
timeValue: esc.timeValue,
|
|
timeUnit: esc.timeUnit,
|
|
phone: phone || ''
|
|
}
|
|
});
|
|
}
|
|
|
|
// §9.4.1 — Auto-log in Work Notes
|
|
await this.logWorkNote(track.applicationId, `[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyEmail} for stage ${track.stageName}.`);
|
|
|
|
metadata[escKey] = true;
|
|
await track.update({ metadata });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async logWorkNote(applicationId: string, text: string) {
|
|
try {
|
|
const { Worknote, User } = db;
|
|
// Find a system user or admin to be the author
|
|
const admin = await User.findOne({ where: { role: 'Super Admin' } });
|
|
await Worknote.create({
|
|
applicationId,
|
|
userId: admin?.id || (await User.findOne())?.id,
|
|
noteText: text,
|
|
noteType: 'system',
|
|
status: 'active'
|
|
});
|
|
} catch (err) {
|
|
console.error('[SLA Service] Failed to log work note:', err);
|
|
}
|
|
}
|
|
|
|
private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) {
|
|
const { Application, User, SLAConfiguration } = db;
|
|
|
|
// 1. Get the configuration for this stage to find the Owner Role(s)
|
|
const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } });
|
|
if (!config || !config.ownerRole) return;
|
|
|
|
// 2. Resolve multiple roles (comma-separated)
|
|
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
|
|
const application = await Application.findByPk(track.applicationId, {
|
|
include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }]
|
|
});
|
|
|
|
if (!application) return;
|
|
|
|
const recipientIds = new Set<string>();
|
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
|
|
for (const role of roles) {
|
|
let foundUserId = null;
|
|
|
|
// Resolve geography-bound roles
|
|
if (application.district) {
|
|
const d = application.district;
|
|
const roleMap: Record<string, string | null> = {
|
|
'ASM': d.asmId,
|
|
'DD-ZM': d.zmId,
|
|
'RBM': d.region?.rbmId || null,
|
|
'ZBH': d.zone?.zbhId || null
|
|
};
|
|
if (roleMap[role]) foundUserId = roleMap[role];
|
|
}
|
|
|
|
if (foundUserId) {
|
|
recipientIds.add(foundUserId);
|
|
} else {
|
|
// Fallback: Resolve all active users with this role
|
|
const users = await User.findAll({ where: { roleCode: role, status: 'active' } });
|
|
users.forEach((u: any) => recipientIds.add(u.id));
|
|
}
|
|
}
|
|
|
|
// 3. Send notifications to all resolved recipients
|
|
for (const userId of recipientIds) {
|
|
const user = await User.findByPk(userId);
|
|
if (!user) continue;
|
|
|
|
const phone = user.mobileNumber || user.phone || null;
|
|
await NotificationService.notify(userId, user.email, {
|
|
title: content.title,
|
|
message: content.message,
|
|
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: template,
|
|
placeholders: {
|
|
applicationId: application.applicationId || String(application.id),
|
|
stageName: track.stageName,
|
|
link: `${portalBase}/applications/${application.id}`,
|
|
phone: phone || ''
|
|
},
|
|
metadata: { applicationId: application.id }
|
|
});
|
|
}
|
|
}
|
|
}
|