Unified SLA System Architecture
This page describes how the SLA system works: how trackings are created, how deadlines are calculated in business hours, paused and reset on a priority change, how the SLA monitor escalates and how absences affect assignment.
System Overview
┌────────────────────────────────────────────────────────────────────────────┐
│ SLA SYSTEM ARCHITECTURE │
└────────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐
│ Frontend │ User creates Ticket/Incident/Problem
│ (React 19+) │ → POST /api/tickets
└────────┬─────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Backend (Node.js + Express) │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ TicketMutationService │ │
│ │ • createTicket() │ │
│ │ • Calls: slaTrackingService.createTracking() │ │
│ └───────────────────────────┬────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ SLATrackingService │ │
│ │ • findMatchingPolicy(entityType, categoryId) │ │
│ │ - Category-specific policy (if categoryId) │ │
│ │ - Default policy for entity type │ │
│ │ • extractTargets(policy, priority) │ │
│ │ - targets.HIGH = { responseMin: 60, resolutionMin: 480 } │ │
│ │ • Calculate deadlines via BusinessHoursCalculator │ │
│ │ • INSERT INTO SLATracking │ │
│ └───────────────────────────┬────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ BusinessHoursCalculator │ │
│ │ • calculateDeadline(startTime, businessMinutes, businessHoursId) │ │
│ │ • If 24/7 mode: addMinutes(startTime, businessMinutes) │ │
│ │ • If business hours: │ │
│ │ - Iterate days, skip weekends/holidays │ │
│ │ - Accumulate business minutes until target reached │ │
│ │ • Returns deadline (Date) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ │
│ SLATracking: │
│ • id, entityType, entityId │
│ • slaPolicyId, targetResponseMin, targetResolutionMin │
│ • responseDeadline, resolutionDeadline │
│ • responseMet, resolutionMet, responseAt, resolvedAt, breachAt │
│ • isPaused, pausedAt, pausedTotalSec, pauseHistory │
│ • currentEscalationLevel, lastEscalationAt, escalationHistory │
│ • calculatedStatus, calculatedPercentUsed (Monitor-gepflegt) │
│ • excludeFromReporting (aus Compliance-Quoten raus) │
│ │
│ SLAPolicy: │
│ • id, name, entityType, targets (JSONB) │
│ • businessHoursId, escalationPolicyId │
│ • categoryIds[], pauseOnStatus[] │
│ • pauseOnIncidentLink, pauseOnChildTicket │
│ • isDefault, isActive │
│ │
│ BusinessHours: │
│ • id, name, schedule (JSONB), timezone │
│ • excludeHolidays, holidayCountry, holidayRegion, isDefault │
│ │
│ Holiday: │
│ • id, name, date, isRecurring, country, region │
│ │
│ EscalationPolicy: │
│ • id, name, levels (JSONB), repeatConfig (JSONB), isActive │
│ │
│ UserAbsence: │
│ • userId, type, startDate, endDate, substituteId │
│ • autoReassign, status, approvedBy, approvedAt │
└────────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Job-Worker (Background Jobs) │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ SLAMonitorAction (built-in cron, interval 2 min + runOnStartup) │ │
│ │ • Fetch active SLA trackings (resolutionMet = null) │ │
│ │ • Calculate current status (OK/WARNING/BREACH/CRITICAL) │ │
│ │ • Evaluate escalation policy levels │ │
│ │ • Execute escalation actions (NOTIFY/REASSIGN/ESCALATE_PRIORITY) │ │
│ │ • Repeat reminders after the last level (repeatConfig) │ │
│ │ • Record escalation in database │ │
│ │ • Publish SLA events to notification system │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Notification System │
│ • SLANotificationService → DomainEventBus │
│ • SLANotificationService listens to SLA events │
│ • Routes to NotificationOrchestrator │
│ • Multi-channel: WEB, EMAIL, TEAMS, WEBEX │
└────────────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Audit System │
│ • All SLA events logged via getAuditService() │
│ • SHA-256 hash chain (tamper-evident) │
│ • Actions: SLA_TRACKING_CREATED, SLA_WARNING/SLA_BREACH, SLA_RESOLUTION_MISSED │
└────────────────────────────────────────────────────────────────────────────┘
Domain Structure
The SLA system consists of the following components:
Services
| Service | Responsibility | Tasks in detail |
|---|---|---|
SLATrackingService |
SLA lifecycle management | create, pause and resume trackings, mark response/resolution as met, reset on a priority change |
BusinessHoursCalculator |
Deadline calculation (business hours) | calculate deadlines in business hours, taking working days and holidays into account |
UserAbsenceService |
Absence lifecycle (domain /api/absences) | CRUD + approve/reject (see Absences API) |
AvailabilityService |
Agent availability (absence-aware) | check the availability of agents and groups, filter absent agents out of the selection |
SLAReportService |
Historical reporting (single compliance source) | report over a time range and entityType: met/missed/compliancePct, MTTA/MTTR, byEntityType, byPriority; without viewAll only over the tickets visible to the agent. Cancelled trackings (CANCELLED) and those excluded via excludeFromReporting do not count toward the rates; the report lists them separately (cancelled, excluded) |
HolidayAutoImportService |
Calculates German public holidays (incl. movable ones via the Easter formula) and imports them idempotently | called by the worker job holiday_autoimport |
SLANotificationService |
SLA events → notifications | Breach warning/critical + escalation to the notification system (recipient resolution incl. CUSTOM, dedupe per level/repeat) |
AbsenceNotificationService |
Absence domain notifications | — |
API Areas
| Area | Description | Example Endpoints |
|---|---|---|
| SLA API | SLA dashboard and report (without viewAll only the tickets visible to the agent) | GET /api/sla/trackings, GET /api/sla/stats, GET /api/sla/report |
| SLA admin API | Admin CRUD for policies, business hours, holidays | POST /api/sla/admin/policies, PATCH /api/sla/admin/business-hours/:id |
| Absences API | User absence management | POST /api/absences, POST /api/absences/:id/approve |
SLA Creation Flow (detailed)
┌─────────────────────────────────────────────────────────────────────────┐
│ SLA CREATION (DETAILED) │
└─────────────────────────────────────────────────────────────────────────┘
1. User creates Ticket/Incident/Problem:
POST /api/tickets
{
"title": "Database connection timeout",
"priority": "HIGH",
"categoryId": "database-issues",
"description": "..."
}
2. TicketMutationService.createTicket():
// Insert ticket into database
const ticket = await prisma.ticket.create({ ... });
// Create SLA tracking
await slaTrackingService.createTracking({
entityType: 'TICKET',
entityId: ticket.id,
priority: ticket.priority,
categoryId: ticket.categoryId,
createdAt: ticket.createdAt
});
3. SLATrackingService.createTracking():
Step 3.1: Find Matching SLA Policy
// Try category-specific policy first
let policy = await prisma.slaPolicy.findFirst({
where: {
entityType: 'TICKET',
categoryIds: { has: categoryId }, // Array contains categoryId
isActive: true
}
});
// Fallback to default policy
if (!policy) {
policy = await prisma.slaPolicy.findFirst({
where: {
entityType: 'TICKET',
isDefault: true,
isActive: true
}
});
}
// Throw error if no policy found
if (!policy) {
throw new Error('No SLA policy found for entity type TICKET');
}
Step 3.2: Extract Targets for Priority
const targets = policy.targets[priority] || policy.targets.default;
// Example: targets = { responseMin: 60, resolutionMin: 480 }
if (!targets) {
throw new Error(`No SLA targets for priority ${priority}`);
}
Step 3.3: Calculate Deadlines
const responseDeadline = await businessHoursCalculator.calculateDeadline(
createdAt,
targets.responseMin,
policy.businessHoursId
);
const resolutionDeadline = await businessHoursCalculator.calculateDeadline(
createdAt,
targets.resolutionMin,
policy.businessHoursId
);
Step 3.4: Create SLATracking Record
const tracking = await prisma.sLATracking.create({
data: {
entityType: 'TICKET',
entityId: ticket.id,
slaPolicyId: policy.id,
targetResponseMin: targets.responseMin,
targetResolutionMin: targets.resolutionMin,
responseDeadline,
resolutionDeadline,
responseMet: null, // pending
resolutionMet: null, // pending
responseAt: null,
resolvedAt: null,
breachAt: null,
currentEscalationLevel: 0,
lastEscalationAt: null,
escalationHistory: [],
isPaused: false,
pausedAt: null,
pausedTotalSec: 0,
pauseHistory: []
}
});
Step 3.5: Audit Log
await auditService.log({
domain: 'SLA',
action: 'SLA_TRACKING_CREATED',
severity: 'INFO',
entityType: 'TICKET',
entityId: ticket.id,
actorId: currentUser.id,
metadata: {
policyId: policy.id,
policyName: policy.name,
targetResponseMin: targets.responseMin,
targetResolutionMin: targets.resolutionMin,
responseDeadline: responseDeadline.toISOString(),
resolutionDeadline: resolutionDeadline.toISOString()
}
});
4. Return Response:
{
"id": "ticket-uuid",
"title": "Database connection timeout",
"priority": "HIGH",
"sla": {
"id": "tracking-uuid",
"policyName": "Standard Support SLA",
"status": "OK",
"responseDeadline": "2026-01-28T11:00:00Z",
"resolutionDeadline": "2026-01-28T17:00:00Z"
}
}
Business Hours Calculator (Implementation)
Algorithm for Deadline Calculation
BUSINESS HOURS CALCULATOR LOGIC:
async calculateDeadline(
startTime: Date,
businessMinutes: number,
businessHoursId: string | null
): Promise<Date> {
// 24/7 Mode (no business hours restriction)
if (!businessHoursId) {
return addMinutes(startTime, businessMinutes);
}
// Business Hours Mode
const businessHours = await this.getBusinessHours(businessHoursId);
// Config cached in-memory for 5 min (schedule/timezone per id)
let currentTime = startTime;
let remainingMinutes = businessMinutes;
// Iterate until all business minutes accumulated
while (remainingMinutes > 0) {
const dayOfWeek = currentTime.getDay(); // 0 = Sunday, 1 = Monday, ...
const dayName = ['sunday', 'monday', 'tuesday', ...][dayOfWeek];
const daySchedule = businessHours.schedule[dayName];
// Skip non-working days
if (!daySchedule || !daySchedule.start || !daySchedule.end) {
currentTime = startOfNextDay(currentTime);
continue;
}
// Check if holiday
if (businessHours.excludeHolidays) {
const isHoliday = await this.isHoliday(
currentTime,
businessHours.holidayCountry,
businessHours.holidayRegion
);
// Cached in Redis: key "sla:holidays:{country}:{region}:{year}", TTL 24h
if (isHoliday) {
currentTime = startOfNextDay(currentTime);
continue;
}
}
// Parse working hours (in businessHours.timezone)
const workStartTime = parseTime(daySchedule.start, businessHours.timezone);
const workEndTime = parseTime(daySchedule.end, businessHours.timezone);
// If before work start, jump to work start
if (currentTime < workStartTime) {
currentTime = workStartTime;
}
// If after work end, jump to next day
if (currentTime >= workEndTime) {
currentTime = startOfNextDay(currentTime);
continue;
}
// Calculate working minutes available today
const minutesUntilEndOfDay = differenceInMinutes(workEndTime, currentTime);
const minutesToAdd = Math.min(remainingMinutes, minutesUntilEndOfDay);
// Add minutes to current time
currentTime = addMinutes(currentTime, minutesToAdd);
remainingMinutes -= minutesToAdd;
// If still minutes remaining, move to next day
if (remainingMinutes > 0) {
currentTime = startOfNextDay(currentTime);
}
}
return currentTime;
}
EXAMPLE (with visualization):
Input:
• startTime: Friday 14:00
• businessMinutes: 480 (8 hours)
• businessHours: Monday-Friday 09:00-17:00 (8h/day)
Calculation:
┌─────────────────────────────────────────────────┐
│ Friday 14:00 → 17:00 = 180 min (3h) ✅ │
│ Remaining: 480 - 180 = 300 min │
├─────────────────────────────────────────────────┤
│ Saturday = SKIP (not in schedule) │
│ Sunday = SKIP (not in schedule) │
├─────────────────────────────────────────────────┤
│ Monday 09:00 → 14:00 = 300 min (5h) ✅ │
│ Remaining: 0 min │
└─────────────────────────────────────────────────┘
Result: Monday 14:00 (3 calendar days later!)
Pause/Resume Logic (Implementation)
Three pause reasons
The clock can stand still for three independent reasons: the entity status is in policy.pauseOnStatus (default [ON_HOLD]), the entity hangs on an open incident link (policy.pauseOnIncidentLink, default true, TICKET only), or a ticket has at least one open sub-ticket (policy.pauseOnChildTicket, default true, TICKET only). The reasons can apply at once. The clock resumes only once none of them applies any more:
shouldRemainPaused(tracking, entityStatus, tx): – entityStatus ∈ policy.pauseOnStatus → true – policy.pauseOnIncidentLink && TICKET with open incident link → true – policy.pauseOnChildTicket && TICKET with open sub-ticket → true – otherwise → false
- When leaving a pause status, the clock resumes only if no reason applies any more. If it stays paused because of the incident link, the pause history names the incident link as the reason.
- The same applies when an incident link is removed: if the entity is also ON_HOLD, it stays paused.
- If such a reason is added to an already paused entity, no second pause happens — the reason is only documented as a marker in the history.
- The parent ticket pauses as soon as a sub-ticket is created or subordinated that is not yet done. When the sub-ticket is done (resolved, closed or marked as spam), detached from the parent or deleted, the system checks whether the clock resumes — if another sub-ticket is open or the parent ticket sits in a pause status, the clock stays still. A reopened sub-ticket pauses it again.
- The reason of the running pause is shown: in the SLA dashboard and in the sidebar of the ticket detail, for example as "Waiting on INC-000042" or "Waiting on sub-ticket TK-000456". The sub-ticket reason is internal information — it appears only for agents with the tickets.viewInternal permission.
Pause SLA
PAUSE LOGIC:
// Triggered when status changes to ON_HOLD
async pauseTracking(trackingId: string): Promise<void> {
const tracking = await prisma.sLATracking.findUnique({
where: { id: trackingId }
});
if (!tracking || tracking.isPaused) {
return; // Already paused or not found
}
const now = new Date();
// Update tracking record
await prisma.sLATracking.update({
where: { id: trackingId },
data: {
isPaused: true,
pausedAt: now,
pauseHistory: {
push: {
action: 'PAUSE',
timestamp: now.toISOString(),
reason: 'Status changed to ON_HOLD'
}
}
}
});
// Audit log
await auditService.log({
domain: 'SLA',
action: 'SLA_TRACKING_PAUSED',
severity: 'INFO',
entityType: tracking.entityType,
entityId: tracking.entityId,
metadata: { pausedAt: now.toISOString() }
});
}
Resume SLA (with Deadline Shift)
RESUME LOGIC:
// Triggered when status changes from ON_HOLD to IN_PROGRESS
async resumeTracking(trackingId: string): Promise<void> {
const tracking = await prisma.sLATracking.findUnique({
where: { id: trackingId },
include: { slaPolicy: { include: { businessHours: true } } }
});
if (!tracking || !tracking.isPaused) {
return; // Not paused
}
const now = new Date();
const pausedAt = tracking.pausedAt!;
// Calculate pause duration
const pauseDurationSec = differenceInSeconds(now, pausedAt);
// Shift deadlines
let newResponseDeadline = tracking.responseDeadline;
let newResolutionDeadline = tracking.resolutionDeadline;
if (tracking.slaPolicy.businessHoursId) {
// Business Hours Mode: Shift by business minutes only
const businessMinutes = await businessHoursCalculator.calculateBusinessMinutes(
pausedAt,
now,
tracking.slaPolicy.businessHoursId
);
newResponseDeadline = await businessHoursCalculator.calculateDeadline(
tracking.responseDeadline,
businessMinutes,
tracking.slaPolicy.businessHoursId
);
newResolutionDeadline = await businessHoursCalculator.calculateDeadline(
tracking.resolutionDeadline,
businessMinutes,
tracking.slaPolicy.businessHoursId
);
} else {
// 24/7 Mode: Shift by calendar minutes
newResponseDeadline = addSeconds(tracking.responseDeadline, pauseDurationSec);
newResolutionDeadline = addSeconds(tracking.resolutionDeadline, pauseDurationSec);
}
// Update tracking record
await prisma.sLATracking.update({
where: { id: trackingId },
data: {
isPaused: false,
pausedAt: null,
pausedTotalSec: tracking.pausedTotalSec + pauseDurationSec,
responseDeadline: newResponseDeadline,
resolutionDeadline: newResolutionDeadline,
pauseHistory: {
push: {
action: 'RESUME',
timestamp: now.toISOString(),
pauseDurationSec,
oldDeadline: tracking.resolutionDeadline.toISOString(),
newDeadline: newResolutionDeadline.toISOString()
}
}
}
});
// Audit log
await auditService.log({
domain: 'SLA',
action: 'SLA_TRACKING_RESUMED',
severity: 'INFO',
entityType: tracking.entityType,
entityId: tracking.entityId,
metadata: {
pauseDurationSec,
deadlineShiftSec: differenceInSeconds(newResolutionDeadline, tracking.resolutionDeadline)
}
});
}
EXAMPLE:
Initial Deadline: Monday 14:00
Paused: Monday 11:00
Resumed: Tuesday 10:00 (24 hours later)
24/7 Mode:
→ Shift by 24 hours (1440 minutes)
→ New Deadline: Tuesday 14:00
Business Hours Mode (09:00-17:00):
→ Pause duration = 24 calendar hours
→ Business minutes = 7 hours (Monday 11:00-17:00 + Tuesday 09:00-10:00)
→ Shift by 7 business hours
→ New Deadline: Tuesday 14:00 + 7h = Wednesday 13:00
→ Business Hours Mode is FAIRER for the customer!
SLA Behavior on Reopen
When an entity is reopened, the SLA behavior is set per entity type in the lifecycle configuration: slaOnReopenFromResolved (default CONTINUE) and slaOnReopenFromClosed (default RESTART).
| Mode | Effect |
|---|---|
CONTINUE | Soft reset: the same SLA period continues, only the resolution status (resolutionMet) is cleared — NO new deadlines/timers. |
RESTART | Hard reset: a fresh SLA lifecycle (new deadlines, escalation level 0, breach state cleared). |
The defaults reflect ITIL: reopen from RESOLVED merely awaited customer confirmation → CONTINUE; reopen from CLOSED/SPAM is effectively a new engagement → RESTART. If a reopen from CLOSED/SPAM finds no active category/default SLA policy, the entity continues without SLA tracking (recorded via a WARN audit). Governance details: Reopen & Lifecycle.
Response SLA: what counts as a reaction
The response SLA measures how fast the customer sees a real reaction — not how fast a name is attached internally. Hence the semantics differ per entity type:
| Entity | responseMet is set by |
|---|---|
TICKET |
the first PUBLIC agent reply: web message (not internal, not from the customer), an agent email reply into the thread, or the outbound email with which an agent creates the ticket in the first place. An assignment — to agent, group, mailbox or queue — does NOT count. |
INCIDENT / PROBLEM |
assignment/acknowledgement — ITIL semantics, where taking ownership is the reaction. |
In practice: an email ticket routed automatically into a mailbox default group counts as answered only with the first public agent reply, so the response levels of an escalation policy apply there too. When a ticket is restored, the system checks whether a public agent reply already exists.
Priority Reset Logic
When an entity's priority is escalated (e.g., LOW → HIGH or HIGH → CRITICAL):
PRIORITY RESET LOGIC:
// Triggered when priority changes
async resetTracking(trackingId: string, newPriority: string): Promise<void> {
const tracking = await prisma.sLATracking.findUnique({
where: { id: trackingId },
include: { slaPolicy: true }
});
if (!tracking) {
throw new Error('SLA tracking not found');
}
// Extract new targets for new priority
const newTargets = tracking.slaPolicy.targets[newPriority]
|| tracking.slaPolicy.targets.default;
if (!newTargets) {
throw new Error(`No SLA targets for priority ${newPriority}`);
}
const now = new Date();
// Calculate NEW deadlines from NOW (fresh start)
const newResponseDeadline = await businessHoursCalculator.calculateDeadline(
now,
newTargets.responseMin,
tracking.slaPolicy.businessHoursId
);
const newResolutionDeadline = await businessHoursCalculator.calculateDeadline(
now,
newTargets.resolutionMin,
tracking.slaPolicy.businessHoursId
);
// Reset escalation level
const resetEscalationLevel = 0;
// Update tracking record
await prisma.sLATracking.update({
where: { id: trackingId },
data: {
targetResponseMin: newTargets.responseMin,
targetResolutionMin: newTargets.resolutionMin,
responseDeadline: newResponseDeadline,
resolutionDeadline: newResolutionDeadline,
currentEscalationLevel: resetEscalationLevel,
breachAt: null, // Clear breach if any
escalationHistory: {
push: {
action: 'RESET',
timestamp: now.toISOString(),
reason: `Priority changed to ${newPriority}`,
oldTargets: {
responseMin: tracking.targetResponseMin,
resolutionMin: tracking.targetResolutionMin
},
newTargets: {
responseMin: newTargets.responseMin,
resolutionMin: newTargets.resolutionMin
},
oldDeadline: tracking.resolutionDeadline.toISOString(),
newDeadline: newResolutionDeadline.toISOString()
}
}
}
});
// Audit log
await auditService.log({
domain: 'SLA',
action: 'SLA_RECALCULATED_FOR_PRIORITY',
severity: 'WARNING',
entityType: tracking.entityType,
entityId: tracking.entityId,
metadata: {
newPriority,
oldTargetResolutionMin: tracking.targetResolutionMin,
newTargetResolutionMin: newTargets.resolutionMin,
oldDeadline: tracking.resolutionDeadline.toISOString(),
newDeadline: newResolutionDeadline.toISOString()
}
});
}
WHY RESET?
Szenario:
• Ticket created: Monday 09:00, Priority LOW
• Target: 2880 minutes (48 hours)
• Deadline: Wednesday 09:00
• Elapsed: 24 hours (50% used, status WARNING)
Priority escalated to CRITICAL:
• New Target: 120 minutes (2 hours)
• NEW Deadline: Monday 11:00 (from NOW, not inherited)
• Escalation Level: 0 (reset)
→ Prevents inherited "almost breached" status from the old priority!
→ Gives the team a fair chance with stricter targets!
Because a priority change restarts the full deadline, the ticket timeline shows the change with the old and new resolution deadline. The audit log records both deadlines for all entity types. For incidents the priority is derived from the matrix. The policy UI also points out that a priority change resets target times from the moment of the change. Pausing, resuming, cancelling and escalations of the SLA also appear in the timeline of tickets, incidents and problems; the text is shown in the viewer's language.
Escalation System (Detailed Flow)
┌─────────────────────────────────────────────────────────────────────────┐
│ SLA ESCALATION WORKFLOW │
└─────────────────────────────────────────────────────────────────────────┘
1. SLA Monitor (Background Job) runs every 2 minutes (built-in template):
┌──────────────────────────────────────────────────────────────────────┐
│ job-worker: SLAMonitorAction │
└──────────────────────────────────────────────────────────────────────┘
Step 1.1: Fetch Active SLA Trackings
const trackings = await loadActiveTrackings(); // resolutionMet = null
Returns all SLAs not yet resolved (active)
Step 1.2: Calculate Current Status for Each
For each tracking:
// Calculate elapsed time (excluding pause time)
const elapsed = differenceInMinutes(NOW, tracking.createdAt);
const elapsedNet = elapsed - (tracking.pausedTotalSec / 60);
// For business hours policies
if (tracking.slaPolicy.businessHoursId) {
elapsedNet = await businessHoursCalculator.calculateBusinessMinutes(
tracking.createdAt,
NOW,
tracking.slaPolicy.businessHoursId
) - (tracking.pausedTotalSec / 60);
}
// Calculate percent used
const percentUsed = (elapsedNet / tracking.targetResolutionMin) * 100;
// Determine status
let status = 'OK';
if (percentUsed >= 100) {
const minutesAfterBreach = elapsedNet - tracking.targetResolutionMin;
status = minutesAfterBreach >= 60 ? 'CRITICAL' : 'BREACH';
} else if (percentUsed >= 80) {
status = 'WARNING';
}
Step 1.3: Evaluate Escalation Policy Levels
const escalationPolicy = tracking.slaPolicy.escalationPolicy;
if (!escalationPolicy || !escalationPolicy.isActive) {
continue; // No escalation policy
}
for (const level of escalationPolicy.levels) {
// Skip if already escalated to this level
if (tracking.currentEscalationLevel >= level.level) {
continue;
}
// Check if trigger condition met
let triggered = false;
switch (level.triggerType) {
case 'PERCENTAGE':
triggered = percentUsed >= level.triggerValue;
break;
case 'BREACH':
triggered = percentUsed >= 100;
break;
case 'TIME_AFTER_BREACH':
if (tracking.breachAt) {
const minutesAfterBreach = differenceInMinutes(NOW, tracking.breachAt);
triggered = minutesAfterBreach >= level.triggerValue;
}
break;
}
if (!triggered) {
continue;
}
// TRIGGER ESCALATION!
await executeEscalation(tracking, level, status, percentUsed);
}
Step 1.4: Execute Escalation Actions
async function executeEscalation(tracking, level, status, percentUsed) {
const actionResults = [];
for (const action of level.actions) {
try {
switch (action.type) {
case 'NOTIFY':
await executeNotifyAction(tracking, action, status);
actionResults.push({ type: 'NOTIFY', success: true });
break;
case 'REASSIGN':
await executeReassignAction(tracking, action);
actionResults.push({ type: 'REASSIGN', success: true });
break;
case 'ESCALATE_PRIORITY':
await executeEscalatePriorityAction(tracking);
actionResults.push({ type: 'ESCALATE_PRIORITY', success: true });
break;
}
} catch (error) {
actionResults.push({ type: action.type, success: false, error: error.message });
}
}
// Record escalation in database
await recordEscalation(tracking.id, {
level: level.level,
trigger: level.triggerType,
percentUsed,
status,
actions: actionResults,
timestamp: NOW
});
// Audit log (status-based action: SLA_WARNING | SLA_BREACH | SLA_CRITICAL)
await auditService.log({
domain: 'SLA',
action: status === 'CRITICAL' ? 'SLA_CRITICAL' : (status === 'BREACH' ? 'SLA_BREACH' : 'SLA_WARNING'),
severity: status === 'CRITICAL' ? 'CRITICAL' : 'WARNING',
entityType: tracking.entityType,
entityId: tracking.entityId,
metadata: {
escalationLevel: level.level,
triggerType: level.triggerType,
percentUsed,
status,
actions: actionResults
}
});
}
2. Execute NOTIFY Action:
async function executeNotifyAction(tracking, action, status) {
// Determine recipients based on notifyTargets
const recipients = [];
if (action.notifyTargets.includes('ASSIGNEE')) {
recipients.push(tracking.entity.assignedToId);
}
if (action.notifyTargets.includes('GROUP_LEAD')) {
recipients.push(...await getGroupLeads(tracking.entity.assignedGroupId));
}
if (action.notifyTargets.includes('MANAGER')) {
// Walks up the manager chain; see "Manager escalation" below
recipients.push(...await getEscalationChain(tracking.entity.assignedToId));
}
if (action.notifyTargets.includes('CUSTOM')) {
// Explicit user picker in the policy builder
recipients.push(...action.customUserIds);
}
// Publish SLA event → SLANotificationService
{
type: status === 'CRITICAL' || status === 'BREACH' ? 'SLA_BREACH' : 'SLA_WARNING',
trackingId: tracking.id,
entityType: tracking.entityType,
entityId: tracking.entityId,
recipientIds: recipients,
// NO channels here — the notification type config + user
// preferences decide the delivery route (in-app/email/…)
metadata: { percentUsed, deadline: tracking.resolutionDeadline.toISOString() }
}
// Recipients are filtered against entity visibility at runtime;
// dedupe key includes level (and repetition for repeats)
}
3. Execute REASSIGN Action:
async function executeReassignAction(tracking, action) {
// Target may be a group, a user, or both
await assignmentService.reassign({
entityType: tracking.entityType,
entityId: tracking.entityId,
groupId: action.reassignToGroupId, // optional
userId: action.reassignToUserId, // optional (must be an active agent)
reason: 'SLA_ESCALATION'
});
// Runs through AssignmentService → Activity, Audit and workload
// handling identical to a manual reassignment. If both are set,
// the group is assigned first, then the user.
// Afterwards fresh entity details are loaded for the notification.
}
4. Execute ESCALATE_PRIORITY Action:
async function executeEscalatePriorityAction(tracking) {
// Determine new priority (one level up)
const priorityMap = {
'LOW': 'MEDIUM',
'MEDIUM': 'HIGH',
'HIGH': 'CRITICAL',
'CRITICAL': 'CRITICAL' // Already at top
};
const currentPriority = tracking.entity.priority;
const newPriority = priorityMap[currentPriority];
if (newPriority === currentPriority) {
return; // Already at top priority
}
// Update entity priority
await escalatePriority({
entityType: tracking.entityType,
entityId: tracking.entityId,
newPriority
});
// This triggers SLA reset internally:
// → New targets for new priority
// → New deadlines calculated from NOW
// → Escalation level reset to 0
// → Fresh start with stricter targets
}
Repeating reminders after the last level
Each escalation level fires exactly once (currentEscalationLevel increases by one per level). For permanently breached SLAs the escalation policy additionally offers a repeat (repeatConfig) that applies after the last level:
REPEAT CONDITIONS (all must hold):
repeatConfig.enabled repeat switched on tracking.breachAt != null already breached tracking.resolutionMet == null still open currentEscalationLevel >= last level ladder exhausted repeatsDone < repeatConfig.maxRepeats quota left repeatsDone < ⌊businessMinutesAfterBreach / intervalMin⌋
Counter: repeatsDone = number of history entries with triggerType REPEAT.Effect: NOTIFY to repeatConfig.notifyTargets, payload like the highest level; history entry { triggerType: "REPEAT", repetition: n }; lastEscalationAt is bumped; the dedupe key carries :r<n>, so each repeat is delivered individually.
Manager Escalation & Automatic Ticket Access
For the NOTIFY target MANAGER the notification walks up the manager escalation chain. If a manager can already view the ticket, they are notified. If NOT, automatic access kicks in — only for TICKET and only when the general setting autoGrantManagerAccess is enabled (default OFF, opt-in):
- The non-visible manager is added as a silent FOLLOWER participant on the ticket so the deep-link in the notification works.
- If the ticket stays invisible despite the follower (e.g. restricted mailbox/group), the follower is removed again and the next manager up the chain is tried.
- If the setting is OFF, behavior stays pure filtering (only managers who can already see it are notified). Changes to the setting take effect within 60 seconds. If the check fails, no access is granted.
User Absence System
Absences are a separate domain (/api/absences) with their own docs. This section only covers their effect on SLA and assignment: absent agents are skipped at every assignment, while items already assigned stay as they are.
🌴 Full absence API (types, status, approval, substitute): Absences API →.
SLA / Assignment Integration
- UserAbsence: type (AbsenceType), startDate/endDate, allDay (+startTime/endTime), status (AbsenceStatus, default APPROVED — agents usually self-enter), substituteId?, approvedById/approvedAt. Created via /api/absences.
- AvailabilityService: isAgentAvailable(agentId), filterAvailableAgents(ids), getAvailableAgentsInGroup(groupId), getAgentAvailabilitySummary(). Absent agents are filtered out during assignment; a configured substitute is redirected as the recipient at runtime (no after-the-fact reassignment of existing tickets).
- UserAbsenceService / AbsenceNotificationService: lifecycle + notifications of the absence domain.
Event-Driven Architecture
Domain Events for SLA
| Event Type | Trigger | Handler |
|---|---|---|
SLA_WARNING |
warning threshold reached (default 80%) | SLANotificationService → multi-channel notification |
SLA_BREACH |
SLA breached (100%+) | SLANotificationService → multi-channel notification (isCritical, isEnforced) |
SLA_ESCALATION |
escalation level triggered | SLANotificationService → notify escalation targets (isCritical) |
ENTITY_STATUS_CHANGED |
ticket/incident status change | SLATrackingService → pause/resume tracking |
ENTITY_PRIORITY_CHANGED |
priority escalated | SLATrackingService → reset tracking with new targets |
ABSENCE_APPROVED |
Absence approved/active | AvailabilityService → agent counts as unavailable and is skipped for new assignments |
Event Flow Visualization
┌─────────────────────────────────────────────────────────────────────┐
│ EVENT-DRIVEN FLOW │
└─────────────────────────────────────────────────────────────────────┘
┌──────────────────┐
│ SLA Monitor │ Detects: SLA at 85% used
│ (Job-Worker) │
└────────┬─────────┘
│
│ Publishes event
▼
┌──────────────────────────────────────┐
│ DomainEventBus │
│ Event: SLA_WARNING │
│ Data: { │
│ trackingId, entityId, percentUsed │
│ } │
└────────┬─────────────────────────────┘
│
│ Routes to subscribers
├────────────────────────┬────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Notification │ │ Webhook │ │ Audit │
│ Adapter │ │ Dispatcher │ │ Service │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Multi-Channel │ │ External System │ │ Audit Event │
│ Notification: │ │ via Webhook │ │ (SHA-256) │
│ • WEB │ │ │ │ │
│ • EMAIL │ │ │ │ │
│ • TEAMS │ │ │ │ │
│ • WEBEX │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────┘
Performance & Scalability
Caching Strategy
Caching covers what the deadline calculation queries often: holiday lists and business-hours configuration. Policies and trackings are read fresh for every calculation.
| Cache | Layer / TTL | Purpose |
|---|---|---|
sla:holidays:{country}:{region} |
Redis, 1 hour | Holiday list per country/region — shared across instances, invalidated on every holiday change |
| Local holiday cache | In-memory, 5 min per key | Fast path in front of Redis (deadline calculation asks per iterated day) |
| BusinessHours config cache | In-memory, 5 min | Schedule/timezone per businessHoursId, avoids repeated DB lookups |
Cascade: local cache → Redis → database.
Database Optimizations
-- Performance-kritische Indices
CREATE INDEX idx_sla_tracking_entity ON "SLATracking" (entityType, entityId);
CREATE INDEX idx_sla_tracking_deadline ON "SLATracking" (resolutionDeadline)
WHERE resolutionMet IS NULL;
CREATE INDEX idx_sla_tracking_paused ON "SLATracking" (isPaused)
WHERE isPaused = true;
CREATE INDEX idx_sla_tracking_breach ON "SLATracking" (breachAt)
WHERE breachAt IS NOT NULL;
CREATE INDEX idx_sla_tracking_policy ON "SLATracking" (slaPolicyId);
-- SLA Policy Indices
CREATE INDEX idx_sla_policy_entity ON "SLAPolicy" (entityType, isActive);
CREATE INDEX idx_sla_policy_default ON "SLAPolicy" (entityType, isDefault)
WHERE isDefault = true;
CREATE INDEX idx_sla_policy_category ON "SLAPolicy" USING GIN (categoryIds);
-- Business Hours Index
CREATE INDEX idx_business_hours_default ON "BusinessHours" (isDefault)
WHERE isDefault = true;
-- Holiday Indices
CREATE INDEX idx_holiday_date ON "Holiday" (date);
CREATE INDEX idx_holiday_country ON "Holiday" (country, region, date);
-- User Absence Indices
CREATE INDEX idx_absence_user ON "UserAbsence" (userId, status);
CREATE INDEX idx_absence_dates ON "UserAbsence" (startDate, endDate)
WHERE status = 'APPROVED';
Scalability Considerations
- Distributed Locks: The SLA monitor uses Redis locks so it does not run twice. If a worker dies mid-run, the scheduler releases stuck jobs on its next startup and the monitor keeps running.
- Background Job Interval: 2 minutes (built-in template, adjustable in CronJobs administration)
- Batch Processing: The monitor reads due trackings in pages of 500 and works through the whole due backlog this way (at most 20 pages per run)
- Event Queue: Domain events via a Redis-backed queue (asynchronous)
- Multi-Instance Support: The job worker scales horizontally (synchronized via Redis locks)
Security & Compliance
Audit Logging
All SLA operations are logged in the Enterprise Audit System:
| Action | Severity | Metadata |
|---|---|---|
SLA_TRACKING_CREATED |
INFO | policyId, policyName, targetMin, deadlines |
SLA_TRACKING_PAUSED |
INFO | pausedAt, reason |
SLA_TRACKING_RESUMED |
INFO | pauseDurationSec, deadlineShiftSec |
SLA_RECALCULATED_FOR_PRIORITY |
WARNING | newPriority, oldTargets, newTargets, deadlineChange |
SLA_RESOLUTION_RESET |
INFO | Soft reset on reopen (CONTINUE): resolutionMet cleared |
SLA_TRACKING_RECREATED / SLA_TRACKING_MISSING_POLICY |
INFO / WARNING | Hard reset on reopen (RESTART), or reopen without a matching policy → entity continues without tracking |
SLA_CANCELLED |
INFO | Tracking cancelled (entity deleted/irrelevant) |
SLA_RESPONSE_MET |
INFO | responseAt, deadline, timeToResponse |
SLA_RESPONSE_MISSED |
WARNING | responseAt, deadline, breachMinutes |
SLA_RESOLUTION_MET |
INFO | resolvedAt, deadline, timeToResolution |
SLA_RESOLUTION_MISSED |
CRITICAL | resolvedAt, deadline, breachMinutes |
SLA_WARNING / SLA_BREACH / SLA_CRITICAL |
WARNING/CRITICAL | escalationLevel, triggerType, percentUsed, actions |
🔒 Compliance: All SLA events are secured in a SHA-256 hash chain, so later modifications are detectable. This supports ISO 27001, SOC 2 and GDPR audits. See Enterprise Audit System.
Integration with ITSM Core
Entity Service Integration
| Entity Service | Imports SLA Service | Integration Points |
|---|---|---|
TicketMutationService |
✅ slaTrackingService | createTicket(), updateStatus(), updatePriority(), closeTicket(), addComment() |
IncidentMutationService |
✅ slaTrackingService | createIncident(), updateStatus(), updatePriority(), closeIncident() |
ProblemMutationService |
✅ slaTrackingService | createProblem(), updateStatus(), updatePriority(), closeProblem() |
| Assignment logic | ✅ availabilityService | isAgentAvailable(), filterAvailableAgents(), getAvailableAgentsInGroup() |
NotificationOrchestrator |
✅ slaNotificationService | Route SLA events to multi-channel notifications |
Response Embedding
Entity GET responses always include SLA information:
// Ticket GET Response
GET /api/tickets/:id
{
"id": "ticket-uuid",
"title": "Database connection timeout",
"priority": "HIGH",
"status": "IN_PROGRESS",
"createdAt": "2026-01-28T09:00:00Z",
// Embedded SLA info
"sla": {
"id": "tracking-uuid",
"policyName": "Standard Support SLA",
"status": "WARNING", // OK, WARNING, BREACH, CRITICAL
"percentUsed": 85.5,
"timeRemaining": "1h 23m",
"responseDeadline": "2026-01-28T10:00:00Z",
"resolutionDeadline": "2026-01-28T17:00:00Z",
"responseMet": true,
"resolutionMet": null, // pending
"isPaused": false,
"currentEscalationLevel": 1
}
}
Deployment & Configuration
Configuration
The SLA monitor runs as a cron job in the job worker (built-in template "SLA Monitor", interval approx. 2 min, runOnStartup) and is configured in CronJobs management. The WARNING and CRITICAL thresholds are a setting (sla-settings), see SLA Management API → Configuring the thresholds.
- Holiday cache in Redis under prefix
sla:holidays:{country}:{region}(+ local in-memory cache)
Generic worker/Redis configuration (REDIS_URL etc.): see the deployment pages.
Initial Setup
# 1. Create default SLA policies via Admin UI or API
POST /api/sla/admin/policies
{
"name": "Standard Support SLA",
"entityType": "TICKET",
"targets": { ... },
"isDefault": true
}
# 2. Configure business hours
POST /api/sla/admin/business-hours
{
"name": "German Business Hours",
"schedule": { ... },
"timezone": "Europe/Berlin",
"isDefault": true
}
# 3. Import holidays
POST /api/sla/admin/holidays/bulk
{
"holidays": [
{ "name": "New Year", "date": "2026-01-01", "isRecurring": true }
]
}
# 4. Create escalation policy
POST /api/sla/admin/escalation-policies
{
"name": "Standard Escalation",
"levels": [ ... ]
}
# 5. SLA monitor: no setup needed — the built-in job template
# "SLA Monitor" ships with the system (interval trigger, 2 min,
# runOnStartup, batchSize 500 = page size; the run pages through the
# whole due backlog). Enable/adjust it in the CronJobs administration
# instead of creating your own job.
Best Practices
- Business Hours for B2B: Use business hours for realistic SLA targets (not 24/7 for standard support)
- 24/7 for Premium: Premium support should use 24/7 SLAs (businessHoursId = null)
- Category-Specific Policies: Create separate policies for critical categories (e.g. "Production Issues")
- Multi-Level Escalation: At least 3 levels (80% warning, breach → reassign, time after breach → raise priority); for permanent breaches add a repeat (repeatConfig) instead of an endless ladder
- Pause on ON_HOLD: Use pauseOnStatus = ["ON_HOLD"] so time spent waiting on the customer does not count; keep pauseOnIncidentLink and pauseOnChildTicket enabled so tickets do not consume their deadline while the cause is handled in an incident or the groundwork in a sub-ticket
- Non-overlapping category policies: A category may belong to only one active policy per entityType — the backend rejects overlaps with 409
- Priority Reset: Reset SLA on priority change (new targets = fresh start)
- Absence Management: Maintain substitutes (substituteId) — absent agents drop out of assignment selection; existing items are NOT reassigned automatically
- Measuring compliance: Always take rates from /sla/report (historical), not /sla/stats (snapshot); exclude outliers from the rates via excludeFromReporting
- Audit Retention: Align the retention period of SLA events with your own audit and record-keeping requirements
- Control channels centrally: Delivery routes belong in the notification type configuration and user preferences, not in the escalation policy
Related Documentation
- SLA Management API - API reference with examples
- Tickets API - SLA integration with tickets
- Incidents API - SLA integration with incidents
- Problems API - SLA integration with problems
- CronJobs API - SLA monitor background job
- Enterprise Audit System - Audit logging for SLA events
- Docker Compose Details - Job-worker container with SLA monitor