128 lines
8.0 KiB
Markdown
128 lines
8.0 KiB
Markdown
# 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.
|