Data Model: SLA and Escalation
Field shapes below match docs/06-database-schema.md "Domain: SLA" / "Domain: Escalation"
exactly, with two additive refinements called out explicitly (both purely additive — nothing in
doc 06's shape is removed or narrowed).
SLAPolicy
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
name |
String |
|
productId |
String? |
wildcard when null — FK to Product.id |
categoryId |
String? |
wildcard when null — FK to Category.id |
problemTypeId |
String? |
wildcard when null — free-text reference, no ProblemType table exists in this codebase (problem taxonomy lives on Problem directly, per 004/005); stored and matched as opaque text |
priority |
String? |
wildcard when null — free-text, matches Ticket.priority |
firstResponseMinutes |
Int |
required — every policy must define a first-response target |
investigationMinutes |
Int? |
stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) |
resolutionMinutes |
Int |
required |
customerResponseMinutes |
Int? |
stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) |
businessCalendarId |
String? |
FK to BusinessCalendar.id; null means "24/7, no exclusions" (an explicit policy choice, not a missing-calendar error) |
active |
Boolean @default(true) |
inactive policies are excluded from resolution |
createdAt / updatedAt |
DateTime |
updatedAt used as the resolution tie-break (research.md) |
Validation (Zod, at the schema layer): firstResponseMinutes > 0, resolutionMinutes > 0,
investigationMinutes/customerResponseMinutes positive when present; productId/categoryId/
businessCalendarId must reference an existing row when provided (repository-level existence
check, same convention as every prior feature's FK-shaped free-form input).
Resolution (findApplicablePolicy(ticket)): among active policies where each set scope field
equals the ticket's corresponding value and each unset field is a wildcard, return the one with
the fewest wildcards; tie-break by latest updatedAt. No match → no SLARun is created (FR-005).
SLARun
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
ticketId |
String @unique |
one run per ticket — no reopen-cycle support (spec.md Assumptions) |
policyId |
String |
FK to SLAPolicy.id, the policy resolved at creation time |
firstResponseDueAt |
DateTime? |
computed via the calendar walk from assignedAt; null when the policy has no firstResponseMinutes... (always present per policy validation, so effectively always set) |
resolutionDueAt |
DateTime? |
computed the same way from resolutionMinutes |
status |
String |
running | paused | warning | breached | completed — matches doc 06 exactly |
pausedAt |
DateTime? |
set when status transitions to paused; cleared on resume |
resumedAt |
DateTime? |
last resume timestamp, informational (audit convenience, mirrors AssignmentHistory's always-append style) |
breachedAt |
DateTime? |
set once, the first time resolutionDueAt is detected passed while running |
completedAt |
DateTime? |
set when the ticket reaches a resolved/closed status; a completed run is never later marked breached (FR-011) |
firstResponseBreachedAt |
DateTime? |
additive refinement, not in doc 06's literal listing — records the first-response breach separately from status/breachedAt, which this feature reserves for the resolution timer; doubles as the idempotency guard for the breach-detection job (research.md) |
Status transitions (enforced in the service layer, not a DB constraint — same convention as
Ticket.status's 12-state machine in 003): running → paused (on ticket entering
WAITING_FOR_CUSTOMER) → running (on leaving it, due dates shifted forward by the pause
duration) → breached (resolution due date passed while running) → completed (ticket resolved/
closed, from any of running/paused/breached). warning is reserved by doc 06's enum for a
future near-breach signal; no code path in this feature sets it (documented, not implemented —
same discipline as the 8 inert EscalationRule.triggerType values).
BusinessCalendar
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
name |
String |
|
timezone |
String |
IANA zone name (e.g. "America/New_York"), validated against Intl.supportedValuesOf('timeZone') at the schema layer |
workingHours |
Json |
shape: { mon?: {start: "HH:mm", end: "HH:mm"}, tue?: ..., wed?: ..., thu?: ..., fri?: ..., sat?: ..., sun?: ... } — a missing key means zero working hours that weekday (research.md) |
holidays |
Holiday[] |
|
Holiday
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
calendarId |
String |
FK to BusinessCalendar.id |
date |
DateTime |
compared by calendar date only (year/month/day in the calendar's own timezone), not by exact instant |
description |
String? |
|
EscalationPolicy
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
name |
String |
|
productId |
String? |
wildcard (global) when null |
active |
Boolean @default(true) |
|
rules |
EscalationRule[] |
|
EscalationRule
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
policyId |
String |
FK to EscalationPolicy.id |
triggerType |
String |
one of doc 05 §6's 10 values; schema accepts all 10, only resolution_breach/first_response_breach are ever evaluated (research.md) |
condition |
Json |
stored, not evaluated, by this feature (research.md) |
targetNodeId |
String |
FK to HierarchyNode.id, validated to exist at creation time (FR-017's rejection rule applies identically here) |
notify |
Json |
who/how to notify — stored and returned only; no delivery mechanism exists (spec.md Assumptions, platform/notifications untouched) |
active |
Boolean @default(true) |
|
EscalationEvent
| Field |
Type |
Notes |
id |
String @id @default(cuid()) |
|
ticketId |
String |
FK to Ticket.id |
ruleId |
String? |
null for a manual escalation or a breach with no matching rule |
fromNodeId |
String? |
the node the ticket was assigned to immediately before this event, if any |
toNodeId |
String? |
the rule's targetNodeId (or the manually-specified node); null when no rule matched |
reason |
String |
free text — for a rule firing, a generated description (e.g. "resolution SLA breached"); for manual escalation, the caller-supplied reason |
triggeredBy |
String |
system | <agentId> | <adminId> — never a bare "customer" literal in this feature's own write paths (doc 06 lists it as a valid value for a future customer-initiated trigger type, not one this feature fires) |
createdAt |
DateTime @default(now()) |
|
Relations added to existing models
Ticket.slaRun SLARun? (inverse of SLARun.ticketId @unique)
Ticket.escalationEvents EscalationEvent[]
Product.slaPolicies SLAPolicy[], Product.escalationPolicies EscalationPolicy[]
Category.slaPolicies SLAPolicy[]
HierarchyNode.escalationRules EscalationRule[] (inverse of targetNodeId)
Out of scope for this data model (per spec.md Assumptions)
- No
investigationDueAt/customerResponseDueAt fields — doc 06's SLARun doesn't define them,
and nothing in spec.md's acceptance scenarios exercises them; investigationMinutes/
customerResponseMinutes remain stored-but-unused on SLAPolicy, same as doc 06 itself defines.
- No FK tightening of
HierarchyNode.slaPolicyId/escalationPolicyId (still free-text, per 006) —
SLA policy resolution in this feature is scope-based (product/category/problemType/priority),
not looked up through those two fields; they remain unvalidated free text, unchanged from 006.