Unified SLA System Architecture
Diese Seite beschreibt den Aufbau des SLA-Systems: wie Trackings entstehen, wie Fristen nach Geschäftszeiten berechnet, pausiert und bei Prioritätswechsel neu gesetzt werden, wie der SLA-Monitor eskaliert und wie Abwesenheiten die Zuweisung beeinflussen.
System-Übersicht
┌────────────────────────────────────────────────────────────────────────────┐
│ SLA-SYSTEM ARCHITEKTUR │
└────────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐
│ 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-Struktur
Das SLA-System besteht aus folgenden Bausteinen:
Services
| Service | Verantwortlichkeit | Aufgaben im Detail |
|---|---|---|
SLATrackingService |
SLA-Lifecycle Management | Trackings anlegen, pausieren und fortsetzen, Response/Resolution als erfüllt markieren, bei Prioritätswechsel zurücksetzen |
BusinessHoursCalculator |
Deadline-Berechnung (Business Hours) | Fristen in Geschäftszeiten berechnen, Arbeitstage und Feiertage berücksichtigen |
UserAbsenceService |
Abwesenheits-Lifecycle (Domain /api/absences) | CRUD + approve/reject (siehe Absences API) |
AvailabilityService |
Agent-Verfügbarkeit (Abwesenheits-aware) | Verfügbarkeit einzelner Agents und Gruppen prüfen, abwesende Agents aus der Auswahl filtern |
SLAReportService |
Historisches Reporting (einzige Compliance-Quelle) | Report über Zeitraum und entityType: met/missed/compliancePct, MTTA/MTTR, byEntityType, byPriority; ohne viewAll nur über die für den Agenten sichtbaren Tickets. Abgebrochene Trackings (CANCELLED) und per excludeFromReporting ausgenommene zählen nicht in die Quoten; der Report weist sie getrennt aus (cancelled, excluded) |
HolidayAutoImportService |
Berechnet deutsche Feiertage (inkl. beweglicher via Osterformel) und importiert sie idempotent | aufgerufen vom Worker-Job holiday_autoimport |
SLANotificationService |
SLA-Events → Notifications | Breach-Warnung/-Critical + Escalation an das Notification-System (Empfänger-Auflösung inkl. CUSTOM, Dedupe je Level/Wiederholung) |
AbsenceNotificationService |
Benachrichtigungen der Abwesenheits-Domain | — |
API-Bereiche
| Bereich | Beschreibung | Beispiel-Endpoints |
|---|---|---|
| SLA-API | SLA-Dashboard und Report (ohne viewAll nur die für den Agenten sichtbaren Tickets) | GET /api/sla/trackings, GET /api/sla/stats, GET /api/sla/report |
| SLA-Admin-API | Admin CRUD für 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 (detailliert)
┌─────────────────────────────────────────────────────────────────────────┐
│ SLA-ERSTELLUNG (DETAILLIERT) │
└─────────────────────────────────────────────────────────────────────────┘
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 für Deadline-Berechnung
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;
}
BEISPIEL (mit Visualisierung):
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)
Drei Pause-Gründe
Die Uhr kann aus drei unabhängigen Gründen stehen: der Entity-Status liegt in policy.pauseOnStatus (Default [ON_HOLD]), die Entity hängt an einem offenen Incident-Link (policy.pauseOnIncidentLink, Default true, nur TICKET), oder ein Ticket hat mindestens ein offenes Sub-Ticket (policy.pauseOnChildTicket, Default true, nur TICKET). Die Gründe können gleichzeitig gelten. Die Uhr läuft erst weiter, wenn keiner von ihnen mehr zutrifft:
shouldRemainPaused(tracking, entityStatus, tx): – entityStatus ∈ policy.pauseOnStatus → true – policy.pauseOnIncidentLink && TICKET mit offenem Incident-Link → true – policy.pauseOnChildTicket && TICKET mit offenem Sub-Ticket → true – sonst → false
- Beim Verlassen eines Pause-Status wird nur fortgesetzt, wenn wirklich kein Grund mehr greift. Bleibt die Uhr wegen des Incident-Links stehen, nennt die Pause-Historie den Incident-Link als Grund.
- Dasselbe gilt beim Entfernen eines Incident-Links: Ist die Entity zusätzlich ON_HOLD, bleibt sie pausiert.
- Kommt ein solcher Grund zu einer bereits pausierten Entity hinzu, wird kein zweites Mal pausiert — der Grund wird nur als Marker in der History dokumentiert.
- Das Elternticket pausiert, sobald ein Sub-Ticket angelegt oder untergeordnet wird, das noch nicht fertig ist. Wird das Sub-Ticket fertig (gelöst, geschlossen oder als Spam markiert), vom Elternticket gelöst oder gelöscht, prüft das System die Wiederaufnahme — ist ein weiteres Sub-Ticket offen oder steht das Elternticket in einem Pause-Status, bleibt die Uhr stehen. Ein wieder geöffnetes Sub-Ticket pausiert erneut.
- Der Grund der laufenden Pause wird angezeigt: im SLA-Dashboard und in der Seitenleiste des Ticket-Details, etwa als „Wartet auf INC-000042" oder „Wartet auf Sub-Ticket TK-000456". Der Sub-Ticket-Grund ist eine interne Angabe — er erscheint nur für Agenten mit dem Recht tickets.viewInternal.
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 (mit 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)
}
});
}
BEISPIEL:
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-Verhalten bei Reopen
Wird eine Entität wiederöffnet, wird das SLA-Verhalten je Entitätstyp in der Lifecycle-Konfiguration festgelegt: slaOnReopenFromResolved (Default CONTINUE) und slaOnReopenFromClosed (Default RESTART).
| Modus | Wirkung |
|---|---|
CONTINUE | Soft-Reset: dieselbe SLA-Periode läuft weiter, nur der Resolution-Status (resolutionMet) wird genullt — KEINE neuen Deadlines/Timer. |
RESTART | Hard-Reset: frischer SLA-Lifecycle (neue Deadlines, Eskalations-Level 0, Breach-Status gelöscht). |
Die Defaults spiegeln ITIL: Reopen aus RESOLVED wartete nur auf Kunden-Bestätigung → CONTINUE; Reopen aus CLOSED/SPAM ist faktisch ein neuer Vorgang → RESTART. Fehlt beim Reopen aus CLOSED/SPAM eine aktive Kategorie-/Default-SLA-Policy, läuft die Entität ohne SLA-Tracking weiter (per WARN-Audit dokumentiert). Governance-Details: Reopen & Lifecycle.
Response-SLA: was als Reaktion zählt
Die Response-SLA misst, wie schnell der Kunde eine echte Reaktion sieht — nicht, wie schnell intern ein Name am Vorgang steht. Deshalb ist die Semantik je Entity-Typ unterschiedlich:
| Entity | responseMet wird gesetzt durch |
|---|---|
TICKET |
die erste ÖFFENTLICHE Antwort eines Agenten: Web-Nachricht (nicht intern, nicht vom Kunden), Agenten-Antwort per E-Mail in den Thread, oder die ausgehende E-Mail, mit der ein Agent das Ticket überhaupt erst anlegt. Eine Zuweisung — an Agent, Gruppe, Mailbox oder Queue — zählt NICHT. |
INCIDENT / PROBLEM |
die Zuweisung bzw. Annahme (Acknowledge) — ITIL-Semantik, hier ist das Übernehmen die Reaktion. |
Folge im Betrieb: Ein E-Mail-Ticket, das automatisch in die Default-Gruppe einer Mailbox läuft, ist erst mit der ersten öffentlichen Agenten-Antwort beantwortet. Die Response-Stufen einer Eskalations-Policy greifen also auch hier. Wird ein Ticket wiederhergestellt, prüft das System, ob bereits eine öffentliche Agenten-Antwort vorliegt.
Priority Reset Logic
Wenn die Priority eines Entity erhöht wird (z.B. LOW → HIGH oder 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()
}
});
}
WARUM 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!
Weil ein Prioritätswechsel die volle Frist neu startet, zeigt die Ticket-Timeline den Wechsel mit alter und neuer Resolution-Deadline. Im Audit-Log stehen beide Deadlines für alle Entitätstypen. Bei Incidents ergibt sich die Priorität aus der Matrix. Auch die Policy-UI weist darauf hin, dass ein Prioritätswechsel die Zielzeiten ab dem Wechselzeitpunkt neu setzt. Pausieren, Fortsetzen, Abbrechen und Eskalationen des SLA erscheinen ebenfalls in der Timeline von Ticket, Incident und Problem; der Text wird in der Sprache des Betrachters angezeigt.
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
}
Wiederholende Erinnerungen nach der letzten Stufe
Jede Eskalationsstufe wird genau einmal ausgelöst (currentEscalationLevel steigt je Stufe um eins). Für dauerhaft verletzte SLAs gibt es an der Eskalations-Policy zusätzlich eine Wiederholung (repeatConfig), die nach der letzten Stufe greift:
REPEAT-BEDINGUNGEN (alle müssen gelten):
repeatConfig.enabled Wiederholung eingeschaltet tracking.breachAt != null bereits verletzt tracking.resolutionMet == null noch offen currentEscalationLevel >= letztes Level Leiter abgearbeitet repeatsDone < repeatConfig.maxRepeats Kontingent übrig repeatsDone < ⌊businessMinutesAfterBreach / intervalMin⌋
Zähler: repeatsDone = Anzahl der History-Einträge mit triggerType REPEAT.Wirkung: NOTIFY an repeatConfig.notifyTargets, Payload wie die höchste Stufe; History-Eintrag { triggerType: "REPEAT", repetition: n }; lastEscalationAt wird gesetzt; der Dedupe-Key trägt :r<n>, sodass jede Wiederholung einzeln zugestellt wird.
Manager-Eskalation & automatischer Ticket-Zugriff
Beim NOTIFY-Target MANAGER läuft die Benachrichtigung die Manager-Eskalationskette nach oben. Sieht ein Manager das Ticket ohnehin, wird er benachrichtigt. Sieht er es NICHT, greift — nur für TICKET und nur wenn das General-Setting autoGrantManagerAccess aktiv ist (Default AUS, opt-in) — der automatische Zugriff:
- Der nicht-sichtbare Manager wird als stummer FOLLOWER-Participant aufs Ticket gesetzt, damit der Deep-Link in der Benachrichtigung aufgeht.
- Bleibt das Ticket trotz Follower unsichtbar (z.B. restricted Mailbox/Gruppe), wird der Follower wieder entfernt und der nächste Manager in der Kette geprüft.
- Ist das Setting AUS, bleibt es beim reinen Filter-Verhalten (nur ohnehin sichtbare Manager werden benachrichtigt). Änderungen am Setting wirken nach spätestens 60 Sekunden. Tritt bei der Prüfung ein Fehler auf, wird kein Zugriff gewährt.
User Absence System
Abwesenheiten sind eine eigene Domain (/api/absences) mit eigener Doku. Hier steht nur, wie sie auf SLA und Zuweisung wirken: Abwesende Agents werden bei jeder Zuweisung übergangen, bereits zugewiesene Vorgänge bleiben unverändert.
🌴 Vollständige Abwesenheits-API (Typen, Status, Genehmigung, Substitute): Absences API →.
SLA-/Assignment-Integration
- UserAbsence: type (AbsenceType), startDate/endDate, allDay (+startTime/endTime), status (AbsenceStatus, Default APPROVED — Agents tragen sich meist selbst ein), substituteId?, approvedById/approvedAt. Anlegen über /api/absences.
- AvailabilityService: isAgentAvailable(agentId), filterAvailableAgents(ids), getAvailableAgentsInGroup(groupId), getAgentAvailabilitySummary(). Abwesende Agents werden bei der Zuweisung herausgefiltert; ein gesetzter substitute wird zur Laufzeit als Empfänger umgeleitet (kein nachträgliches Umhängen bestehender Tickets).
- UserAbsenceService / AbsenceNotificationService: Lifecycle + Benachrichtigungen der Abwesenheits-Domain.
Event-Driven Architecture
Domain-Events für SLA
| Event-Type | Trigger | Handler |
|---|---|---|
SLA_WARNING |
Warnschwelle erreicht (Standard 80 %) | SLANotificationService → Benachrichtigung über mehrere Kanäle |
SLA_BREACH |
SLA verletzt (100 %+) | SLANotificationService → Benachrichtigung über mehrere Kanäle (isCritical, isEnforced) |
SLA_ESCALATION |
Eskalationsstufe ausgelöst | SLANotificationService → Eskalations-Empfänger benachrichtigen (isCritical) |
ENTITY_STATUS_CHANGED |
Statuswechsel an Ticket/Incident | SLATrackingService → Tracking pausieren/fortsetzen |
ENTITY_PRIORITY_CHANGED |
Priorität angehoben | SLATrackingService → Tracking mit neuen Targets zurücksetzen |
ABSENCE_APPROVED |
Abwesenheit genehmigt/aktiv | AvailabilityService → Agent gilt als nicht verfügbar und wird bei neuen Zuweisungen übergangen |
Event-Flow Visualisierung
┌─────────────────────────────────────────────────────────────────────┐
│ 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-Strategie
Zwischengespeichert wird, was in der Deadline-Berechnung häufig abgefragt wird: Feiertagslisten und Business-Hours-Konfiguration. Policies und Trackings werden bei jeder Berechnung aktuell gelesen.
| Cache | Ebene / TTL | Zweck |
|---|---|---|
sla:holidays:{country}:{region} |
Redis, 1 Stunde | Feiertagsliste je Land/Region — instanzübergreifend geteilt, wird bei jeder Feiertags-Änderung invalidiert |
| Lokaler Feiertags-Cache | In-Memory, 5 Minuten je Key | Fast-Path vor Redis (Deadline-Berechnung fragt pro Iterationstag) |
| BusinessHours-Config-Cache | In-Memory, 5 Minuten | Schedule/Timezone je businessHoursId, spart wiederholte DB-Lookups |
Kaskade: lokaler Cache → Redis → Datenbank.
Database-Optimierungen
-- 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: SLA-Monitor verwendet Redis-Locks, damit er nicht doppelt läuft. Bricht ein Worker mitten im Lauf ab, gibt der Scheduler hängengebliebene Jobs beim nächsten Start wieder frei, und der Monitor läuft weiter.
- Background Job Interval: 2 Minuten (Built-in-Template, in der CronJobs-Verwaltung anpassbar)
- Batch Processing: Der Monitor liest fällige Trackings seitenweise zu je 500 und arbeitet so den gesamten fälligen Bestand ab (höchstens 20 Seiten pro Lauf)
- Event Queue: Domain-Events über eine Redis-Queue (asynchron)
- Multi-Instance Support: Der Job-Worker skaliert horizontal (Abstimmung über Redis-Locks)
Security & Compliance
Audit-Logging
Alle SLA-Operationen werden im Enterprise Audit System geloggt:
| 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 beim Reopen (CONTINUE): resolutionMet zurückgesetzt |
SLA_TRACKING_RECREATED / SLA_TRACKING_MISSING_POLICY |
INFO / WARNING | Hard-Reset beim Reopen (RESTART) bzw. Reopen ohne passende Policy → Entity läuft ohne Tracking weiter |
SLA_CANCELLED |
INFO | Tracking storniert (Entity gelöscht/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: Alle SLA-Events werden in einer SHA-256-Hash-Kette gesichert, sodass nachträgliche Änderungen erkennbar sind. Das unterstützt Audits nach ISO 27001, SOC 2 und DSGVO. Siehe Enterprise Audit System.
Integration mit 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() |
| Zuweisungs-Logik | ✅ availabilityService | isAgentAvailable(), filterAvailableAgents(), getAvailableAgentsInGroup() |
NotificationOrchestrator |
✅ slaNotificationService | Route SLA events to multi-channel notifications |
Response-Embedding
Entity-GET-Responses enthalten immer SLA-Informationen:
// 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
Konfiguration
Der SLA-Monitor läuft als Cronjob im Job-Worker (Built-in-Template „SLA Monitor“, Intervall ca. 2 min, runOnStartup) und wird in der CronJobs-Verwaltung eingestellt. Die Schwellwerte für WARNING und CRITICAL sind ein Setting (sla-settings), siehe SLA Management API → Schwellwerte konfigurieren.
- Holiday-Cache in Redis unter Prefix
sla:holidays:{country}:{region}(+ lokaler In-Memory-Cache)
Generische Worker-/Redis-Konfiguration (REDIS_URL etc.) siehe Deployment-Seiten.
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 für B2B: Business Hours für realistische SLA-Targets verwenden (nicht 24/7 für Standard-Support)
- 24/7 für Premium: Premium-Support sollte 24/7-SLAs verwenden (businessHoursId = null)
- Category-Specific Policies: Separate Policies für kritische Kategorien anlegen (z.B. "Production Issues")
- Multi-Level Escalation: Mindestens 3 Stufen (80 % Warning, Breach → Reassign, Zeit nach Breach → Prio anheben); für Dauer-Breaches zusätzlich eine Wiederholung (repeatConfig) statt einer endlosen Level-Leiter
- Pause bei ON_HOLD: pauseOnStatus = ["ON_HOLD"] verwenden, damit Wartezeiten auf den Kunden nicht zählen; pauseOnIncidentLink und pauseOnChildTicket aktiviert lassen, damit Tickets keine Frist verbrauchen, solange die Ursache in einem Incident oder die Zuarbeit in einem Sub-Ticket bearbeitet wird
- Kategorie-Policies überschneidungsfrei: Eine Kategorie darf nur einer aktiven Policy je entityType gehören — das Backend lehnt Überschneidungen mit 409 ab
- Priority Reset: SLA bei Priority-Change resetten (neue Targets = neuer Start)
- Absence Management: Vertretung (substituteId) pflegen — abwesende Agents fallen aus der Zuweisungs-Auswahl, bestehende Vorgänge werden NICHT automatisch umgehängt
- Compliance messen: Quoten immer aus /sla/report (historisch) ziehen, nicht aus /sla/stats (Momentaufnahme); Ausreißer über excludeFromReporting aus den Quoten nehmen
- Audit-Retention: Aufbewahrungsfrist der SLA-Events an den eigenen Audit- und Nachweispflichten ausrichten
- Kanäle zentral steuern: Zustellwege gehören in die Notification-Typ-Konfiguration und die Nutzer-Präferenzen, nicht in die Eskalations-Policy
Verwandte Dokumentation
- SLA Management API - API-Referenz mit Beispielen
- Tickets API - SLA-Integration bei Tickets
- Incidents API - SLA-Integration bei Incidents
- Problems API - SLA-Integration bei Problems
- CronJobs API - SLA-Monitor Background-Job
- Enterprise Audit System - Audit-Logging für SLA-Events
- Docker Compose Details - Job-Worker Container mit SLA-Monitor