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 = { '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(); 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 = { '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 } }); } } }