Dealer_Onboarding_Backend/src/services/SLAService.ts

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 }
});
}
}
}