# 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 \| \| ` — 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.