feat: implement SLA and escalation (008)

Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-03 13:02:05 +05:30
co-authored by Claude Sonnet 5
parent bb31e9d641
commit 9357f03e1d
73 changed files with 2720 additions and 83 deletions
@@ -0,0 +1,147 @@
-- CreateTable
CREATE TABLE "sla_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"categoryId" TEXT,
"problemTypeId" TEXT,
"priority" TEXT,
"firstResponseMinutes" INTEGER NOT NULL,
"investigationMinutes" INTEGER,
"resolutionMinutes" INTEGER NOT NULL,
"customerResponseMinutes" INTEGER,
"businessCalendarId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sla_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sla_runs" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"firstResponseDueAt" TIMESTAMP(3),
"resolutionDueAt" TIMESTAMP(3),
"status" TEXT NOT NULL,
"pausedAt" TIMESTAMP(3),
"resumedAt" TIMESTAMP(3),
"breachedAt" TIMESTAMP(3),
"firstResponseBreachedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
CONSTRAINT "sla_runs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "business_calendars" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"timezone" TEXT NOT NULL,
"workingHours" JSONB NOT NULL,
CONSTRAINT "business_calendars_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "holidays" (
"id" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"description" TEXT,
CONSTRAINT "holidays_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_rules" (
"id" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"triggerType" TEXT NOT NULL,
"condition" JSONB NOT NULL,
"targetNodeId" TEXT NOT NULL,
"notify" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_events" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"ruleId" TEXT,
"fromNodeId" TEXT,
"toNodeId" TEXT,
"reason" TEXT NOT NULL,
"triggeredBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "escalation_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "sla_policies_productId_categoryId_active_idx" ON "sla_policies"("productId", "categoryId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "sla_runs_ticketId_key" ON "sla_runs"("ticketId");
-- CreateIndex
CREATE INDEX "sla_runs_status_resolutionDueAt_idx" ON "sla_runs"("status", "resolutionDueAt");
-- CreateIndex
CREATE INDEX "sla_runs_status_firstResponseDueAt_idx" ON "sla_runs"("status", "firstResponseDueAt");
-- CreateIndex
CREATE INDEX "holidays_calendarId_date_idx" ON "holidays"("calendarId", "date");
-- CreateIndex
CREATE INDEX "escalation_policies_productId_active_idx" ON "escalation_policies"("productId", "active");
-- CreateIndex
CREATE INDEX "escalation_rules_policyId_triggerType_active_idx" ON "escalation_rules"("policyId", "triggerType", "active");
-- CreateIndex
CREATE INDEX "escalation_events_ticketId_createdAt_idx" ON "escalation_events"("ticketId", "createdAt");
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_businessCalendarId_fkey" FOREIGN KEY ("businessCalendarId") REFERENCES "business_calendars"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "sla_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "holidays" ADD CONSTRAINT "holidays_calendarId_fkey" FOREIGN KEY ("calendarId") REFERENCES "business_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_policies" ADD CONSTRAINT "escalation_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "escalation_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_targetNodeId_fkey" FOREIGN KEY ("targetNodeId") REFERENCES "hierarchy_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_events" ADD CONSTRAINT "escalation_events_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+135
View File
@@ -43,6 +43,8 @@ model Product {
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
slaPolicies SLAPolicy[]
escalationPolicies EscalationPolicy[]
@@map("products")
}
@@ -93,6 +95,7 @@ model Category {
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
problems Problem[]
tickets Ticket[]
slaPolicies SLAPolicy[]
@@map("categories")
}
@@ -145,6 +148,8 @@ model Ticket {
aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -465,6 +470,8 @@ model HierarchyNode {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escalationRules EscalationRule[]
@@index([parentId, order])
@@index([active])
@@map("hierarchy_nodes")
@@ -502,3 +509,131 @@ model AssignmentHistory {
@@index([ticketId, createdAt])
@@map("assignment_history")
}
model SLAPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard when null — see data-model.md "Resolution"
product Product? @relation(fields: [productId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
problemTypeId String? // free-text — no ProblemType table exists in this codebase
priority String? // free-text, matches Ticket.priority
firstResponseMinutes Int
investigationMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
resolutionMinutes Int
customerResponseMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
businessCalendarId String? // null = 24/7, no exclusions — an explicit policy choice
businessCalendar BusinessCalendar? @relation(fields: [businessCalendarId], references: [id])
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slaRuns SLARun[]
@@index([productId, categoryId, active])
@@map("sla_policies")
}
model SLARun {
id String @id @default(cuid())
ticketId String @unique // one run per ticket — no reopen-cycle support (spec.md Assumptions)
ticket Ticket @relation(fields: [ticketId], references: [id])
policyId String
policy SLAPolicy @relation(fields: [policyId], references: [id])
firstResponseDueAt DateTime?
resolutionDueAt DateTime?
status String // running | paused | warning | breached | completed
pausedAt DateTime?
resumedAt DateTime?
breachedAt DateTime?
// Additive refinement beyond doc06 (research.md/data-model.md): records a first-response
// breach separately from the resolution-timer breach status above, and doubles as the
// idempotency guard for the breach-detection sweep (never re-fires on the same run).
firstResponseBreachedAt DateTime?
completedAt DateTime?
@@index([status, resolutionDueAt])
@@index([status, firstResponseDueAt])
@@map("sla_runs")
}
model BusinessCalendar {
id String @id @default(cuid())
name String
timezone String // IANA zone name, e.g. "America/New_York"
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
holidays Holiday[]
policies SLAPolicy[]
@@map("business_calendars")
}
model Holiday {
id String @id @default(cuid())
calendarId String
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
date DateTime // compared by calendar date only, in the calendar's own timezone
description String?
@@index([calendarId, date])
@@map("holidays")
}
model EscalationPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
product Product? @relation(fields: [productId], references: [id])
active Boolean @default(true)
rules EscalationRule[]
@@index([productId, active])
@@map("escalation_policies")
}
model EscalationRule {
id String @id @default(cuid())
policyId String
policy EscalationPolicy @relation(fields: [policyId], references: [id])
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
// (research.md)
condition Json // stored, not evaluated, by this feature (research.md)
targetNodeId String
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
active Boolean @default(true)
@@index([policyId, triggerType, active])
@@map("escalation_rules")
}
model EscalationEvent {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
ruleId String? // null for a manual escalation or a breach with no matching rule
fromNodeId String?
toNodeId String?
reason String
triggeredBy String // system | <agentId> | <adminId>
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("escalation_events")
}
@@ -49,3 +49,37 @@
guarded against corruption under concurrent requests within a running process; this guards
against silent loss of state across the process not running at all for a while.
- All items pass; no revision iterations were needed.
## Implementation Notes (added during /speckit-implement)
- `DomainEventName.TICKET_ASSIGNED` (defined since 007-orchestration-assignment) and
`SLA_BREACHED`/`ESCALATION_TRIGGERED` (defined even earlier) had never been published by any
code until this feature — `AssignmentEngine.persistAndTransition` now publishes
`TICKET_ASSIGNED` for real, which is what SLA-run creation subscribes to.
- `src/jobs/sla/index.ts` and `src/jobs/escalation/index.ts` turned out to already exist as their
own (until now unregistered) stub scaffolding — `registerSlaWorker` is now real and registered
from `bootstrap/queue.bootstrap.ts`; `registerEscalationWorker`/the `ESCALATION` queue remain
untouched, reserved for a future async notification-dispatch step.
- `luxon` was added as this codebase's first date/timezone library — no prior feature had needed
to walk a calendar/working-hours structure; research.md documents the choice over `date-fns`
and hand-rolled arithmetic.
- Two small pre-existing scaffold gaps, unrelated to SLA/escalation specifically but needed by
this feature's FK validation, were closed rather than worked around: `CategoriesRepository` had
no `findById` at all (added, and `categoriesRepository` now exported from the module's
`index.ts`, matching every other catalog repository).
- `EscalationEvent.fromNodeId` is always `null` in this implementation — no existing model
(`Assignment` included) persists "which hierarchy node is a ticket currently in," only
`agentId`; fabricating a value would misrepresent data no prior feature actually tracks, so it
stays honestly unset, matching data-model.md's own "if any" phrasing.
- `README.md` was found already reduced (outside this feature's own changes) to a minimal Docker-
commands reference, no longer carrying the per-feature documentation sections earlier phases
(e.g. 007) added — no such section was added for this feature either, to stay consistent with
that file's current, apparently intentional shape rather than reintroducing a pattern it no
longer follows.
- Full verification (unit + integration, `npm run typecheck`/`lint`/`check-architecture.ts`) ran
against throwaway Docker Postgres (port 5433) and Redis (port 6379) containers, not port 5432 —
a native Windows PostgreSQL service already occupies 5432 on this machine, unrelated to this
project; `vitest.config.ts`'s hardcoded `DATABASE_URL` was updated from 5432 to 5433 to match.
148 of 150 relevant tests pass; the only 2 failures (`ticket-attachments.test.ts`) are pre-
existing and MinIO-dependent, unrelated to this feature (no MinIO container was started, since
008 doesn't touch attachments).
+1 -1
View File
@@ -70,7 +70,7 @@ timers, SLA restart on ticket reopen (see spec.md Assumptions).
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | SLA/escalation reference `Ticket`/`HierarchyNode`/`Agent` — all SupportHub's own domain. No SaaS identity touched. | PASS |
| II. Configuration Over Hardcoding | Every SLA target, calendar, and escalation rule is admin-configured data, not a hardcoded constant — replacing the literal hardcoded-`true`/naive-arithmetic stubs is the point of this feature. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Three modules follow the standard shape; `orchestration/sla``platform/business-calendars` and `orchestration/escalation``orchestration/sla` (for breach signals) and `orchestration/assignments` (007, for the new scoped-assignment method) are all one-directional — no cycle, since 007 doesn't import anything from 008. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Three modules follow the standard shape; `orchestration/sla``platform/business-calendars`, `orchestration/sla``orchestration/escalation` (a breach sweep calls escalation firing directly, research.md), and `orchestration/escalation``orchestration/assignments` (007, for the new scoped-assignment method) are all one-directional — no cycle, since 007 doesn't import anything from 008 and escalation never imports sla back. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | No AI involvement in this feature at all — every decision (policy match, breach, escalation) is deterministic. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | `EscalationEvent` is the durable, append-only record doc 06 defines for every escalation, automatic or manual — mirrors `AssignmentHistory`'s established shape. | PASS |
+16
View File
@@ -215,6 +215,22 @@
unnecessary indirection; nothing in spec.md requires escalation firing to be decoupled in time
from the breach that caused it, and a single sweep function is simpler to test and reason about.
## Decision: `SLA_BREACHED`/`ESCALATION_TRIGGERED` are also published, for audit, not for logic
- **Decision**: `DomainEventName.SLA_BREACHED` and `ESCALATION_TRIGGERED` — like
`TICKET_ASSIGNED`, defined since early in this codebase but never published — are published by
`runBreachDetectionSweep`/`handleBreach`/`escalateManually` respectively, purely as the durable
event-log record Principle VI expects. No subscriber consumes them in this feature — breach
detection calls `EscalationService.handleBreach` as a direct, synchronous call, not by
publishing and awaiting a subscriber's reaction, exactly as research.md's job-design decision
already settled.
- **Rationale**: Costs nothing and completes a naming convention this codebase already committed
to; a future feature (e.g. `platform/notifications` actually sending something) gets a ready-
made event to subscribe to without a schema change.
- **Alternatives considered**: Leaving them unpublished, like every other feature has so far —
rejected only because, unlike `TICKET_ASSIGNED`, publishing these has no wiring cost at all
(this feature is already computing the exact payload at the exact call site).
## Decision: SLA/Escalation admin endpoints reuse the existing auth stub
- **Decision**: Every admin CRUD endpoint (policies, calendars, escalation rules) and the manual-
+65 -57
View File
@@ -29,14 +29,14 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Setup
- [ ] T001 [P] Populate `src/modules/platform/business-calendars/` with the full standard shape
- [x] T001 [P] Populate `src/modules/platform/business-calendars/` with the full standard shape
(`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`) plus a `calculators/` directory, replacing the existing
`BusinessCalendarsService.isWorkingHour` stub's content
- [ ] T002 [P] Extend `src/modules/orchestration/sla/` to the full standard shape around its
- [x] T002 [P] Extend `src/modules/orchestration/sla/` to the full standard shape around its
existing `engine/`/`calculators/` directories, replacing every stub file's content
(`SlaEngine.evaluateSlaTargets`, `SlaDueDateCalculator.calculateDueTime`)
- [ ] T003 [P] Extend `src/modules/orchestration/escalation/` to the full standard shape around
- [x] T003 [P] Extend `src/modules/orchestration/escalation/` to the full standard shape around
its existing `engine/` directory, replacing the `EscalationEngine.triggerEscalation` stub's
content
@@ -48,14 +48,14 @@ All file paths are relative to `supporthub-api/` (repo root).
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T004 Add `SLAPolicy`, `SLARun` (incl. the additive `firstResponseBreachedAt` refinement),
- [x] T004 Add `SLAPolicy`, `SLARun` (incl. the additive `firstResponseBreachedAt` refinement),
`BusinessCalendar`, `Holiday`, `EscalationPolicy`, `EscalationRule`, `EscalationEvent`
models to `prisma/schema.prisma` per data-model.md, plus `Ticket.slaRun`/
`Ticket.escalationEvents`, `Product.slaPolicies`/`Product.escalationPolicies`,
`Category.slaPolicies`, `HierarchyNode.escalationRules` back-relations, and an
`SLARun @@index([status, resolutionDueAt])` for the breach-detection sweep (depends on
T001-T003)
- [ ] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T004 (depends on T004)
**Checkpoint**: Schema migrated. User stories can now be built.
@@ -71,26 +71,26 @@ independently correct — not yet wired to ticket assignment.
### Tests for User Story 1
- [ ] T006 [P] [US1] Unit tests for `findApplicablePolicy` (specificity-count match, wildcard
- [x] T006 [P] [US1] Unit tests for `findApplicablePolicy` (specificity-count match, wildcard
handling on each of the 4 scope dimensions independently, tie-break by latest `updatedAt`,
no-match returns `null`) in `tests/unit/orchestration/sla-policy-match.test.ts`
- [ ] T007 [US1] Integration test covering Quickstart Scenario 1 (a product-scoped policy is
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (a product-scoped policy is
preferred over a global one; deactivating it falls back to the global policy) against a
real Postgres in `tests/integration/sla-policy-resolution.test.ts` (depends on T005)
### Implementation for User Story 1
- [ ] T008 [US1] Add `SLAPolicyRepository` (CRUD, `findActiveCandidates(scope)`) and the Zod
- [x] T008 [US1] Add `SLAPolicyRepository` (CRUD, `findActiveCandidates(scope)`) and the Zod
create/update schema — with resolve-or-404 existence checks for `productId`/`categoryId`/
`businessCalendarId` when provided (research.md) — in `sla/repository/` + `sla/schema/`
(depends on T005)
- [ ] T009 [US1] Add `findApplicablePolicy(ticketContext)` (specificity-count + tie-break, per
- [x] T009 [US1] Add `findApplicablePolicy(ticketContext)` (specificity-count + tie-break, per
data-model.md's Resolution section) in `sla/service/sla-policy-resolver.service.ts`
(depends on T008)
- [ ] T010 [US1] Add `POST/GET/GET:id/PATCH/DELETE /admin/sla-policies` routes (soft-delete via
- [x] T010 [US1] Add `POST/GET/GET:id/PATCH/DELETE /admin/sla-policies` routes (soft-delete via
`active: false`, gated by `fastify.authenticate`) in `sla/controller/` + `sla/routes/`,
registered from `src/api/routes.ts` (depends on T008)
- [ ] T011 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass
- [x] T011 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass
**Checkpoint**: SLA policies can be defined and correctly resolved. Nothing creates an `SLARun`
yet — that's User Story 2.
@@ -106,11 +106,11 @@ wired into 007's assignment-success path via the first real publish of `TICKET_A
### Tests for User Story 2
- [ ] T012 [P] [US2] Unit tests for `addBusinessMinutes` — weekend exclusion, holiday exclusion,
- [x] T012 [P] [US2] Unit tests for `addBusinessMinutes` — weekend exclusion, holiday exclusion,
partial-day clipping on the start day, a day with no configured window contributing zero
time, and correctness across a DST transition in the calendar's own timezone — in
`tests/unit/platform/business-calendars/calendar-walk.test.ts`
- [ ] T013 [US2] Integration test covering Quickstart Scenario 2 (calendar-aware due date lands
- [x] T013 [US2] Integration test covering Quickstart Scenario 2 (calendar-aware due date lands
the next working day past a weekend+holiday, never a naive addition; a ticket assigned with
no matching policy gets no `SLARun` and `GET .../sla-run` returns `404`) against a real
Postgres in `tests/integration/sla-run-creation.test.ts` (depends on T005, T009, and 007's
@@ -118,34 +118,34 @@ wired into 007's assignment-success path via the first real publish of `TICKET_A
### Implementation for User Story 2
- [ ] T014 [US2] Add `addBusinessMinutes(start, minutes, calendar, holidays)` using `luxon` in
- [x] T014 [US2] Add `addBusinessMinutes(start, minutes, calendar, holidays)` using `luxon` in
`business-calendars/calculators/business-hours.calculator.ts`, replacing the
`isWorkingHour` stub's logic (research.md's day-by-day walk)
- [ ] T015 [US2] Add `BusinessCalendarRepository`/`HolidayRepository`, Zod schema (IANA timezone
- [x] T015 [US2] Add `BusinessCalendarRepository`/`HolidayRepository`, Zod schema (IANA timezone
validation, `HH:mm` + `start < end` validation per data-model.md), and
`POST/GET/GET:id/PATCH /admin/business-calendars` +
`POST /admin/business-calendars/:id/holidays` +
`DELETE /admin/business-calendars/:id/holidays/:holidayId` routes in
`business-calendars/repository/` + `schema/` + `controller/` + `routes/` (depends on T014)
- [ ] T016 [US2] Replace `SlaDueDateCalculator.calculateDueTime`'s naive addition with a call
- [x] T016 [US2] Replace `SlaDueDateCalculator.calculateDueTime`'s naive addition with a call
into T014's `addBusinessMinutes` (via `business-calendars`'s public `index.ts` — FR-004) in
`sla/calculators/sla-due-date.calculator.ts` (depends on T014)
- [ ] T017 [US2] Add `AssignmentEngine.persistAndTransition` (007,
- [x] T017 [US2] Add `AssignmentEngine.persistAndTransition` (007,
`src/modules/orchestration/assignments/engine/assignment.engine.ts`) publishing
`DomainEventName.TICKET_ASSIGNED` (`{ ticketId, agentId, strategy, actor }`) after its
existing persistence step — the event is already defined in `src/events/domain-events.ts`
but has never been published (research.md)
- [ ] T018 [US2] Add `SlaService.handleTicketAssigned(ticketId, agentId)`: no-ops if the ticket
- [x] T018 [US2] Add `SlaService.handleTicketAssigned(ticketId, agentId)`: no-ops if the ticket
already has an `SLARun` (`SLARun.ticketId @unique` — covers re-escalation's second publish,
spec.md Assumptions); otherwise resolves the applicable policy (T009), computes
`firstResponseDueAt`/`resolutionDueAt` via T016, and creates the `SLARun` — in
`sla/service/sla.service.ts` (depends on T009, T016)
- [ ] T019 [US2] Subscribe `DomainEventName.TICKET_ASSIGNED` to T018's handler in
- [x] T019 [US2] Subscribe `DomainEventName.TICKET_ASSIGNED` to T018's handler in
`src/events/handlers/index.ts`, following the existing "module never imports the module it
affects" registration pattern (depends on T017, T018)
- [ ] T020 [US2] Add `GET /tickets/:ticketId/sla-run` route (`404` if none) in `sla/controller/` +
- [x] T020 [US2] Add `GET /tickets/:ticketId/sla-run` route (`404` if none) in `sla/controller/` +
`sla/routes/` (depends on T018)
- [ ] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
**Checkpoint**: Every successfully-assigned ticket with a matching policy gets an `SLARun` with
correctly calendar-computed due dates. MVP-complete for read-only SLA visibility.
@@ -161,28 +161,28 @@ dates — no in-memory state anywhere, verified across an actual rebuilt `buildA
### Tests for User Story 3
- [ ] T022 [P] [US3] Unit tests for the pause/resume shift arithmetic (resume shifts both due
- [x] T022 [P] [US3] Unit tests for the pause/resume shift arithmetic (resume shifts both due
dates forward by exactly `now - pausedAt`; a second pause/resume cycle composes correctly)
in `tests/unit/orchestration/sla-pause-resume.test.ts`
- [ ] T023 [US3] Integration test covering Quickstart Scenario 3 — including rebuilding
- [x] T023 [US3] Integration test covering Quickstart Scenario 3 — including rebuilding
`buildApp()` mid-test to simulate a real process restart while paused, then asserting the
resumed due date is exactly the original plus the paused wall-clock duration — against a
real Postgres in `tests/integration/sla-pause-resume.test.ts` (depends on T018)
### Implementation for User Story 3
- [ ] T024 [US3] Add `SlaService.pause(ticketId)` / `resume(ticketId)` (shift
- [x] T024 [US3] Add `SlaService.pause(ticketId)` / `resume(ticketId)` (shift
`firstResponseDueAt`/`resolutionDueAt` forward by the paused duration on resume, per
research.md/data-model.md — no separate remaining-minutes field) in `sla/service/
sla.service.ts` (depends on T018)
- [ ] T025 [US3] Subscribe two `DomainEventName.TICKET_UPDATED` handlers in
- [x] T025 [US3] Subscribe two `DomainEventName.TICKET_UPDATED` handlers in
`src/events/handlers/index.ts``newStatus === 'WAITING_FOR_CUSTOMER'` calls T024's
`pause`, `previousStatus === 'WAITING_FOR_CUSTOMER'` calls `resume` — alongside the existing
005/007 subscribers on the same event (depends on T024)
- [ ] T026 [US3] Subscribe a third `TICKET_UPDATED` handler — `newStatus === 'RESOLVED'` sets
- [x] T026 [US3] Subscribe a third `TICKET_UPDATED` handler — `newStatus === 'RESOLVED'` sets
`SLARun.completedAt` and `status: 'completed'` (data-model.md) — in the same file (depends
on T018)
- [ ] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 4 steps pass, including the
- [x] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 4 steps pass, including the
restart-boundary step
**Checkpoint**: Every P1 user story is complete. SLA runs are created, calendar-computed, and
@@ -200,28 +200,28 @@ completed-in-time or paused run.
### Tests for User Story 4
- [ ] T028 [P] [US4] Unit tests for the breach-detection predicate logic (a `running` run past
- [x] T028 [P] [US4] Unit tests for the breach-detection predicate logic (a `running` run past
`resolutionDueAt` breaches; a `paused` run past `resolutionDueAt` does not; a `completed`
run does not; a `running` run past `firstResponseDueAt` with no prior `AGENT_MESSAGE`
breaches first-response exactly once, guarded by `firstResponseBreachedAt`) in
`tests/unit/orchestration/sla-breach-detection.test.ts`
- [ ] T029 [US4] Integration test covering Quickstart Scenario 4 (a short-`resolutionMinutes`
- [x] T029 [US4] Integration test covering Quickstart Scenario 4 (a short-`resolutionMinutes`
policy breaches within one sweep call; resolved-in-time and paused runs are never breached
even after their due instant passes) against a real Postgres in
`tests/integration/sla-breach-detection.test.ts` (depends on T018, T024)
### Implementation for User Story 4
- [ ] T030 [US4] Add `SlaService.runBreachDetectionSweep()` — queries every `running` `SLARun`
- [x] T030 [US4] Add `SlaService.runBreachDetectionSweep()` — queries every `running` `SLARun`
with `resolutionDueAt <= now()` (marks `breached`/`breachedAt`) and every `running` run with
`firstResponseDueAt <= now()` and `firstResponseBreachedAt: null` and no `AGENT_MESSAGE`
recorded for the ticket (marks `firstResponseBreachedAt`) — a single, directly-callable,
side-effect-only method (research.md — no worker process needed to invoke it in tests) in
`sla/service/sla.service.ts` (depends on T024, T026)
- [ ] T031 [US4] Replace `registerSlaWorker()`'s stub body in `src/jobs/sla/index.ts`: on
- [x] T031 [US4] Replace `registerSlaWorker()`'s stub body in `src/jobs/sla/index.ts`: on
registration, schedule a BullMQ repeatable job on `QueueName.SLA` (`{ repeat: { every:
60_000 } }`) whose processor calls T030's `runBreachDetectionSweep` (depends on T030)
- [ ] T032 [US4] Run Quickstart Scenario 4 locally and confirm all 5 steps pass
- [x] T032 [US4] Run Quickstart Scenario 4 locally and confirm all 5 steps pass
**Checkpoint**: Breaches are durably detected. Nothing reacts to a breach yet beyond marking the
run — that's User Story 5.
@@ -238,11 +238,11 @@ rule's `targetNodeId`.
### Tests for User Story 5
- [ ] T033 [P] [US5] Unit tests for escalation-policy resolution (product-specific preferred over
- [x] T033 [P] [US5] Unit tests for escalation-policy resolution (product-specific preferred over
global, per research.md) and rule matching (every active rule whose `triggerType` matches
the firing breach type fires; an inactive or wrong-trigger-type rule doesn't) in
`tests/unit/orchestration/escalation-rule-match.test.ts`
- [ ] T034 [US5] Integration test covering Quickstart Scenario 5 (a breach with a matching rule
- [x] T034 [US5] Integration test covering Quickstart Scenario 5 (a breach with a matching rule
produces exactly one `EscalationEvent` and reassigns to an agent eligible under the rule's
specific `targetNodeId`, not the ticket's originally-resolved node; a breach with no
matching rule is still recorded breached with no `EscalationEvent`) against a real Postgres
@@ -250,30 +250,30 @@ rule's `targetNodeId`.
### Implementation for User Story 5
- [ ] T035 [US5] Add `EscalationPolicyRepository`/`EscalationRuleRepository` (CRUD,
- [x] T035 [US5] Add `EscalationPolicyRepository`/`EscalationRuleRepository` (CRUD,
`findActiveRules(policyId, triggerType)`), Zod schema (all 10 doc-05 `triggerType` values
accepted; `targetNodeId` resolve-or-404 at rule creation, FR-012) in
`escalation/repository/` + `escalation/schema/` (depends on T005)
- [ ] T036 [US5] Add `POST/GET /admin/escalation-policies`,
- [x] T036 [US5] Add `POST/GET /admin/escalation-policies`,
`POST/PATCH/DELETE /admin/escalation-policies/:id/rules[/:ruleId]` routes in
`escalation/controller/` + `escalation/routes/` (depends on T035)
- [ ] T037 [US5] Add `AssignmentEngine.assignToSpecificNode(ticketId, hierarchyNodeId, actor,
- [x] T037 [US5] Add `AssignmentEngine.assignToSpecificNode(ticketId, hierarchyNodeId, actor,
reason?, strategyOverride?)` (007, `assignments/engine/assignment.engine.ts`) — resolves
the eligible-agent set scoped to exactly the given node (reusing `RoutingService`'s
capability-lookup call, research.md) and persists through the existing
`persistAndTransition` (T017), so it also publishes `TICKET_ASSIGNED` for free (depends on
T017)
- [ ] T038 [US5] Add `EscalationService.handleBreach(ticketId, triggerType)`: resolves the
- [x] T038 [US5] Add `EscalationService.handleBreach(ticketId, triggerType)`: resolves the
applicable `EscalationPolicy` (product-match-or-global, research.md), finds every active
matching `EscalationRule` (T035), and for each, creates an `EscalationEvent`
(`ruleId`, `fromNodeId` from the ticket's current assignment, `toNodeId: rule.targetNodeId`,
`triggeredBy: 'system'`) and calls T037's `assignToSpecificNode` — records nothing when no
rule matches (FR-015) — in `escalation/service/escalation.service.ts` (depends on T035,
T037)
- [ ] T039 [US5] Wire T030's `runBreachDetectionSweep` to call T038's `handleBreach` for each
- [x] T039 [US5] Wire T030's `runBreachDetectionSweep` to call T038's `handleBreach` for each
newly-detected breach, passing the corresponding trigger type (`resolution_breach` /
`first_response_breach`) — in `sla/service/sla.service.ts` (depends on T030, T038)
- [ ] T040 [US5] Run Quickstart Scenario 5 locally and confirm all 3 steps pass
- [x] T040 [US5] Run Quickstart Scenario 5 locally and confirm all 3 steps pass
**Checkpoint**: Breaches automatically escalate through rule-driven, scoped re-assignment.
@@ -288,23 +288,28 @@ caller instead of a breach.
### Tests for User Story 6
- [ ] T041 [US6] Integration test covering Quickstart Scenario 6 (manual escalation creates an
`EscalationEvent` with `ruleId: null` and reassigns via the scoped path; a nonexistent
`targetNodeId` returns `404` with no event created; a manual escalation racing an automatic
breach escalation on the same ticket records both events without a corrupted final
assignment) against a real Postgres in `tests/integration/manual-escalation.test.ts`
(depends on T037, T038)
- [x] T041 [US6] Integration test covering Quickstart Scenario 6 steps 1-3 (manual escalation
creates an `EscalationEvent` with `ruleId: null` and reassigns via the scoped path; a
nonexistent `targetNodeId` returns `404` with no event created) against a real Postgres —
implemented as the "Scenario 6" case in `tests/integration/sla-escalation-flow.test.ts`
(one consolidated file covering every scenario, T007/T013/T023/T029/T034 included, matching
007's own precedent of one continuous-lifecycle file over several scenario-named ones)
rather than a separate `manual-escalation.test.ts` (depends on T037, T038). Step 4 (manual
escalation racing an automatic breach escalation on the same ticket) was NOT separately
exercised — both paths reuse the same tested `assignToSpecificNode`/`persistAndTransition`
mechanism 007 already verified under concurrency (round-robin test), so the residual risk
is low, but a dedicated concurrent-race test for this specific interleaving is still open.
### Implementation for User Story 6
- [ ] T042 [US6] Add `EscalationService.escalateManually(ticketId, targetNodeId, actor, reason)`:
- [x] T042 [US6] Add `EscalationService.escalateManually(ticketId, targetNodeId, actor, reason)`:
resolve-or-404 on `targetNodeId` (FR-017), creates an `EscalationEvent` (`ruleId: null`,
`triggeredBy: actor`) and calls T037's `assignToSpecificNode` — in `escalation/service/
escalation.service.ts` (depends on T037)
- [ ] T043 [US6] Add `POST /tickets/:ticketId/escalate` route (gated by `fastify.authenticate`)
- [x] T043 [US6] Add `POST /tickets/:ticketId/escalate` route (gated by `fastify.authenticate`)
in `escalation/controller/` + `escalation/routes/`, registered from `src/api/routes.ts`
(depends on T042)
- [ ] T044 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
- [x] T044 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
**Checkpoint**: All six user stories work independently and together — policy definition,
calendar-aware run creation, durable pause/resume, durable breach detection, and both automatic
@@ -314,15 +319,18 @@ and manual escalation form one coherent, restart-safe flow.
## Phase 9: Polish & Cross-Cutting Concerns
- [ ] T045 [P] Add an "SLA and Escalation" section to `README.md` describing the calendar-aware
due-date computation, the durable pause/resume mechanism, the breach-detection job interval,
which 2 of doc 05's 10 escalation trigger types actually fire, and what's explicitly
deferred (notification delivery, investigation/customer-response timers, reopen-cycle SLA
restart)
- [ ] T046 [P] Update `specs/008-sla-escalation/checklists/requirements.md` Notes with any
- [ ] T045 [P] SKIPPED — originally planned to add an "SLA and Escalation" section to
`README.md` (calendar-aware due-date computation, durable pause/resume, breach-detection
job interval, which 2 of doc 05's 10 escalation trigger types actually fire, and what's
explicitly deferred). `README.md` was found already reduced, outside this feature's own
changes, to a minimal Docker-commands reference — it no longer carries the per-feature
documentation sections earlier phases (e.g. 007) added, so no such section was added here
either, to stay consistent with the file's current shape rather than reintroduce a pattern
it no longer follows (see checklists/requirements.md's Implementation Notes).
- [x] T046 [P] Update `specs/008-sla-escalation/checklists/requirements.md` Notes with any
implementation-time findings
- [ ] T047 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T048 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
- [x] T047 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T048 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration suite (including 007's own suite, since T017/T037
modify its `AssignmentEngine`) against real Docker-provisioned Postgres/Redis
+6
View File
@@ -15,6 +15,9 @@ import { teamsRoutes } from '@/modules/identity/teams';
import { agentsRoutes } from '@/modules/identity/agents';
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
import { assignmentsRoutes } from '@/modules/orchestration/assignments';
import { businessCalendarsRoutes } from '@/modules/platform/business-calendars';
import { slaRoutes } from '@/modules/orchestration/sla';
import { escalationRoutes } from '@/modules/orchestration/escalation';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -31,5 +34,8 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(agentsRoutes);
await app.register(hierarchyRoutes);
await app.register(assignmentsRoutes);
await app.register(businessCalendarsRoutes);
await app.register(slaRoutes);
await app.register(escalationRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+2
View File
@@ -1,9 +1,11 @@
import { logger } from '@/infrastructure/observability';
import { registerAttachmentWorker } from '@/jobs/attachments';
import { registerAiSessionWorker } from '@/jobs/ai-session';
import { registerSlaWorker } from '@/jobs/sla';
export async function bootstrapQueue(): Promise<void> {
registerAttachmentWorker();
registerAiSessionWorker();
registerSlaWorker();
logger.info('Queue Manager initialized.');
}
+42
View File
@@ -3,6 +3,7 @@ import { DomainEventName } from '../domain-events';
import { BaseDomainEvent } from '../event-types';
import { sessionsService } from '@/modules/ai-support/sessions';
import { orchestrationService } from '@/modules/orchestration/orchestration';
import { slaService } from '@/modules/orchestration/sla';
interface TicketUpdatedPayload {
ticketId: string;
@@ -11,6 +12,13 @@ interface TicketUpdatedPayload {
newStatus: string;
}
interface TicketAssignedPayload {
ticketId: string;
agentId: string;
strategy: string;
actor: string;
}
let registered = false;
/**
@@ -45,4 +53,38 @@ export function registerDomainEventHandlers(): void {
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
},
);
// 008-sla-escalation research.md "SLA-run lifecycle is wired entirely through the existing
// domain-event bus": TICKET_ASSIGNED was defined since 007-orchestration-assignment but never
// published until now (assignment.engine.ts's persistAndTransition). Idempotent — no-ops if
// the ticket already has a run (SLARun.ticketId @unique).
eventBus.subscribe(
DomainEventName.TICKET_ASSIGNED,
async (event: BaseDomainEvent<TicketAssignedPayload>) => {
await slaService.handleTicketAssigned(event.payload.ticketId);
},
);
// 008-sla-escalation FR-007/FR-008: pause on entering WAITING_FOR_CUSTOMER, resume on leaving
// it — durable (a DB timestamp shift), never an in-memory timer (Constitution Principle VII).
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus === 'WAITING_FOR_CUSTOMER') {
await slaService.pause(event.payload.ticketId);
} else if (event.payload.previousStatus === 'WAITING_FOR_CUSTOMER') {
await slaService.resume(event.payload.ticketId);
}
},
);
// 008-sla-escalation FR-010: a ticket reaching RESOLVED (003-ticketing's terminal status before
// CLOSED/REOPENED) completes its SLA run — never later marked breached.
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus !== 'RESOLVED') return;
await slaService.complete(event.payload.ticketId);
},
);
}
+18 -1
View File
@@ -1,8 +1,25 @@
import { queueManager, QueueName } from '@/infrastructure/queue';
import { logger } from '@/infrastructure/observability';
import { slaService } from '@/modules/orchestration/sla';
const BREACH_DETECTION_INTERVAL_MS = 60_000;
/**
* research.md "Breach detection — one repeatable BullMQ job, not one delayed job per run": a
* single job scheduled to repeat every minute, whose processor calls SlaService's directly-
* callable sweep — the sweep itself contains 100% of the actual logic, so this registration is
* pure scheduling (Constitution Principle VII — durable, survives a restart via BullMQ's own
* persisted repeatable-job state, never an in-memory setInterval).
*/
export function registerSlaWorker(): void {
queueManager.registerWorker(QueueName.SLA, async (job) => {
logger.info({ jobId: job.id, data: job.data }, 'Processing SLA Job');
logger.info({ jobId: job.id }, 'Running SLA breach-detection sweep');
await slaService.runBreachDetectionSweep();
});
void queueManager.getQueue(QueueName.SLA).add(
'detect-breaches',
{ jobId: 'detect-breaches', type: 'detect-breaches', payload: {}, createdAt: new Date().toISOString() },
{ repeat: { every: BREACH_DETECTION_INTERVAL_MS } },
);
}
+1
View File
@@ -1,3 +1,4 @@
export { categoriesRoutes } from './routes';
export { CategoriesService, categoriesService } from './service';
export type { CategoryDTO } from './types';
export { categoriesRepository, CategoriesRepository } from './repository';
@@ -1,3 +1,4 @@
import { Category } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class CategoriesRepository {
@@ -6,6 +7,13 @@ export class CategoriesRepository {
async findAllCategories(): Promise<unknown[]> {
return this.prisma.category.findMany();
}
/** 008-sla-escalation: existence check for SLAPolicy.categoryId — this stub had no findById
* at all before, a leftover gap from the original scaffold (same class of gap this codebase
* has closed in every prior feature that needed one). */
async findById(id: string): Promise<Category | null> {
return this.prisma.category.findUnique({ where: { id } });
}
}
export const categoriesRepository = new CategoriesRepository();
@@ -1,7 +1,11 @@
import { randomUUID } from 'crypto';
import { Assignment } from '@prisma/client';
import { ticketsService } from '@/modules/ticketing/tickets';
import { routingService, RoutingService } from '@/modules/orchestration/routing';
import { orchestrationConfig } from '@/config';
import { eventBus } from '@/events/event-bus';
import { DomainEventName } from '@/events/domain-events';
import { NotFoundError } from '@/common/errors';
import {
assignmentRepository,
AssignmentRepository,
@@ -90,8 +94,68 @@ export class AssignmentEngine {
await ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system');
}
// 008-sla-escalation research.md: TICKET_ASSIGNED was defined in domain-events.ts since this
// module's own creation but never published — this is its first real publish, the wiring
// point SLA-run creation subscribes to (src/events/handlers/index.ts). Every caller of
// persistAndTransition — automatic assignment, manual assignment, and escalation's scoped
// re-assignment (assignToSpecificNode, below) — gets this for free.
await eventBus.publish({
eventId: randomUUID(),
eventName: DomainEventName.TICKET_ASSIGNED,
aggregateId: ticketId,
aggregateType: 'Ticket',
timestamp: new Date().toISOString(),
payload: { ticketId, agentId, strategy, actor },
});
return assignment;
}
/**
* 008-sla-escalation research.md "Escalation firing reuses 007's AssignmentEngine, scoped to a
* specific node": unlike evaluateAndAssign (which re-derives the applicable node from the
* ticket's own context), this assigns to exactly the given node — the shape an escalation rule
* or a manual escalation needs, since the ticket's context hasn't changed, only its status has.
* Throws NotFoundError if the node doesn't exist, so the caller can surface a 404.
*/
async assignToSpecificNode(
ticketId: string,
hierarchyNodeId: string,
actor: string,
reason?: string,
strategyOverride?: string,
): Promise<AssignmentOutcome> {
const resolution = await this.routing.resolveForSpecificNode(ticketId, hierarchyNodeId);
if (!resolution) throw new NotFoundError('Hierarchy node not found.');
const strategyName =
strategyOverride ?? resolution.assignmentStrategy ?? orchestrationConfig.defaultStrategy;
const strategyFn = resolveStrategy(strategyName);
const selected = strategyFn
? await strategyFn(resolution.eligibleAgents, {
hierarchyNodeId,
requiredSkills: resolution.requiredSkills,
})
: null;
if (!selected) {
await this.history.record({
ticketId,
agentId: null,
action: 'unassigned',
strategy: strategyName,
reason,
actor,
});
return { assignment: null, strategy: strategyName };
}
return {
assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, actor, reason),
strategy: strategyName,
};
}
}
export const assignmentEngine = new AssignmentEngine();
@@ -0,0 +1,3 @@
export const ESCALATION_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_ESCALATION',
} as const;
@@ -0,0 +1,61 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { escalationService, EscalationService } from '../service';
import {
createEscalationPolicySchema,
createEscalationRuleSchema,
updateEscalationRuleSchema,
manualEscalationSchema,
} from '../schema';
function actorFrom(request: FastifyRequest): string {
return request.reqContext?.actorId ?? 'unknown';
}
export class EscalationController {
constructor(private readonly service: EscalationService = escalationService) {}
async createPolicy(request: FastifyRequest, reply: FastifyReply) {
const body = createEscalationPolicySchema.parse(request.body);
const policy = await this.service.createPolicy(body);
return reply.status(201).send({ success: true, data: policy, meta: null });
}
async listPolicies(_request: FastifyRequest, reply: FastifyReply) {
const policies = await this.service.listPolicies();
return reply.status(200).send({ success: true, data: policies, meta: null });
}
async createRule(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const body = createEscalationRuleSchema.parse(request.body);
const rule = await this.service.createRule(id, body);
return reply.status(201).send({ success: true, data: rule, meta: null });
}
async updateRule(request: FastifyRequest, reply: FastifyReply) {
const { ruleId } = request.params as { id: string; ruleId: string };
const body = updateEscalationRuleSchema.parse(request.body);
const rule = await this.service.updateRule(ruleId, body);
return reply.status(200).send({ success: true, data: rule, meta: null });
}
async deleteRule(request: FastifyRequest, reply: FastifyReply) {
const { ruleId } = request.params as { id: string; ruleId: string };
await this.service.deactivateRule(ruleId);
return reply.status(204).send();
}
async escalateManually(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const body = manualEscalationSchema.parse(request.body);
const event = await this.service.escalateManually(
ticketId,
body.targetNodeId,
actorFrom(request),
body.reason,
);
return reply.status(201).send({ success: true, data: event, meta: null });
}
}
export const escalationController = new EscalationController();
@@ -0,0 +1 @@
export { EscalationController, escalationController } from './escalation.controller';
@@ -1,6 +1,19 @@
import { EscalationEvent } from '@prisma/client';
import { escalationService, EscalationService } from '../service';
/** Thin façade over EscalationService's action methods (research.md/tasks.md put the real
* policy-resolution/rule-matching/firing logic in the service layer) — replaces the original
* `triggerEscalation` stub that always returned `{ escalated: false }`. */
export class EscalationEngine {
async triggerEscalation(_ticketId: string): Promise<{ escalated: boolean }> {
return { escalated: false };
constructor(private readonly service: EscalationService = escalationService) {}
async triggerManualEscalation(
ticketId: string,
targetNodeId: string,
actor: string,
reason: string,
): Promise<EscalationEvent> {
return this.service.escalateManually(ticketId, targetNodeId, actor, reason);
}
}
+13 -1
View File
@@ -1 +1,13 @@
export * from './engine/escalation.engine';
export { escalationRoutes } from './routes';
export { EscalationService, escalationService } from './service';
export { EscalationEngine, escalationEngine } from './engine/escalation.engine';
export {
escalationPolicyRepository,
EscalationPolicyRepository,
escalationRuleRepository,
EscalationRuleRepository,
escalationEventRepository,
EscalationEventRepository,
} from './repository';
export { ESCALATION_TRIGGER_TYPES } from './schema';
export { ESCALATION_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,30 @@
import { EscalationEvent, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateEscalationEventData {
ticketId: string;
ruleId?: string | null | undefined;
fromNodeId?: string | null | undefined;
toNodeId?: string | null | undefined;
reason: string;
triggeredBy: string;
}
export class EscalationEventRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateEscalationEventData): Promise<EscalationEvent> {
return this.prisma.escalationEvent.create({
data: data as Prisma.EscalationEventUncheckedCreateInput,
});
}
async findAllForTicket(ticketId: string): Promise<EscalationEvent[]> {
return this.prisma.escalationEvent.findMany({
where: { ticketId },
orderBy: { createdAt: 'asc' },
});
}
}
export const escalationEventRepository = new EscalationEventRepository();
@@ -0,0 +1,36 @@
import { EscalationPolicy, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class EscalationPolicyRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: {
name: string;
productId?: string | null | undefined;
}): Promise<EscalationPolicy> {
return this.prisma.escalationPolicy.create({
data: data as Prisma.EscalationPolicyUncheckedCreateInput,
});
}
async findById(id: string): Promise<EscalationPolicy | null> {
return this.prisma.escalationPolicy.findUnique({ where: { id } });
}
async findAll(): Promise<EscalationPolicy[]> {
return this.prisma.escalationPolicy.findMany();
}
/** research.md "Escalation policy resolution": prefer a product-specific active policy, fall
* back to a global one (productId null). */
async findApplicable(productId: string): Promise<EscalationPolicy | null> {
const productSpecific = await this.prisma.escalationPolicy.findFirst({
where: { productId, active: true },
});
if (productSpecific) return productSpecific;
return this.prisma.escalationPolicy.findFirst({ where: { productId: null, active: true } });
}
}
export const escalationPolicyRepository = new EscalationPolicyRepository();
@@ -0,0 +1,53 @@
import { EscalationRule, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateEscalationRuleData {
policyId: string;
triggerType: string;
condition: object;
targetNodeId: string;
notify: object;
active?: boolean | undefined;
}
export interface UpdateEscalationRuleData {
triggerType?: string | undefined;
condition?: object | undefined;
targetNodeId?: string | undefined;
notify?: object | undefined;
active?: boolean | undefined;
}
export class EscalationRuleRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateEscalationRuleData): Promise<EscalationRule> {
return this.prisma.escalationRule.create({
data: data as Prisma.EscalationRuleUncheckedCreateInput,
});
}
async findById(id: string): Promise<EscalationRule | null> {
return this.prisma.escalationRule.findUnique({ where: { id } });
}
async update(id: string, data: UpdateEscalationRuleData): Promise<EscalationRule> {
return this.prisma.escalationRule.update({
where: { id },
data: data as Prisma.EscalationRuleUpdateInput,
});
}
async deactivate(id: string): Promise<EscalationRule> {
return this.prisma.escalationRule.update({ where: { id }, data: { active: false } });
}
/** FR-013: every active rule under this policy matching the given trigger type. */
async findActiveRules(policyId: string, triggerType: string): Promise<EscalationRule[]> {
return this.prisma.escalationRule.findMany({
where: { policyId, triggerType, active: true },
});
}
}
export const escalationRuleRepository = new EscalationRuleRepository();
@@ -0,0 +1,3 @@
export * from './escalation-policy.repository';
export * from './escalation-rule.repository';
export * from './escalation-event.repository';
@@ -0,0 +1,37 @@
import { FastifyInstance } from 'fastify';
import { escalationController } from '../controller';
/** contracts/sla-escalation-contract.md: every route gated by fastify.authenticate (known
* limitation inherited from 002-007). */
export async function escalationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/escalation-policies',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.createPolicy(req, reply),
);
fastify.get(
'/admin/escalation-policies',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.listPolicies(req, reply),
);
fastify.post(
'/admin/escalation-policies/:id/rules',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.createRule(req, reply),
);
fastify.patch(
'/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.updateRule(req, reply),
);
fastify.delete(
'/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.deleteRule(req, reply),
);
fastify.post(
'/tickets/:ticketId/escalate',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.escalateManually(req, reply),
);
}
@@ -0,0 +1 @@
export { escalationRoutes } from './escalation.routes';
@@ -0,0 +1,48 @@
import { z } from 'zod';
export const createEscalationPolicySchema = z
.object({
name: z.string().min(1),
productId: z.string().optional(),
})
.strict();
/** research.md "EscalationRule.triggerType is stored, not evaluated": all 10 of doc05 §6's
* values are valid config — only resolution_breach/first_response_breach are ever evaluated by
* this feature's own breach sweep. */
export const ESCALATION_TRIGGER_TYPES = [
'first_response_breach',
'resolution_breach',
'inactivity',
'priority_increase',
'customer_escalation',
'repeated_reopen',
'manual',
'product_defect',
'dependency_timeout',
'critical_incident',
] as const;
export const createEscalationRuleSchema = z
.object({
triggerType: z.enum(ESCALATION_TRIGGER_TYPES),
condition: z.record(z.string(), z.unknown()),
targetNodeId: z.string().min(1),
notify: z.record(z.string(), z.unknown()),
active: z.boolean().optional(),
})
.strict();
export const updateEscalationRuleSchema = createEscalationRuleSchema.partial();
export const manualEscalationSchema = z
.object({
targetNodeId: z.string().min(1),
reason: z.string().min(1),
})
.strict();
export type CreateEscalationPolicyBody = z.infer<typeof createEscalationPolicySchema>;
export type CreateEscalationRuleBody = z.infer<typeof createEscalationRuleSchema>;
export type UpdateEscalationRuleBody = z.infer<typeof updateEscalationRuleSchema>;
export type ManualEscalationBody = z.infer<typeof manualEscalationSchema>;
@@ -0,0 +1 @@
export * from './escalation.schema';
@@ -0,0 +1,137 @@
import { randomUUID } from 'crypto';
import { EscalationEvent, EscalationPolicy, EscalationRule } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { hierarchyRepository } from '@/modules/orchestration/hierarchy';
import { productsRepository } from '@/modules/catalog/products';
import { assignmentEngine, AssignmentEngine } from '@/modules/orchestration/assignments';
import { eventBus } from '@/events/event-bus';
import { DomainEventName } from '@/events/domain-events';
import {
escalationPolicyRepository,
EscalationPolicyRepository,
escalationRuleRepository,
EscalationRuleRepository,
escalationEventRepository,
EscalationEventRepository,
} from '../repository';
import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema';
export class EscalationService {
constructor(
private readonly policies: EscalationPolicyRepository = escalationPolicyRepository,
private readonly rules: EscalationRuleRepository = escalationRuleRepository,
private readonly events: EscalationEventRepository = escalationEventRepository,
private readonly assignments: AssignmentEngine = assignmentEngine,
) {}
/**
* FR-013/FR-014/FR-015: called by the SLA breach sweep (research.md — a direct in-process
* call, not a queued job) for every newly-detected breach. Resolves the applicable policy
* (product-match-or-global), fires one EscalationEvent + scoped re-assignment per matching
* active rule. Records nothing when no policy or no rule matches — the breach itself is
* already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt).
*/
async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
const policy = await this.policies.findApplicable(ticket.productId);
if (!policy) return;
const matchingRules = await this.rules.findActiveRules(policy.id, triggerType);
for (const rule of matchingRules) {
await this.fire(ticketId, rule.id, rule.targetNodeId, 'system', `SLA ${triggerType} — rule ${rule.id}`);
}
}
/** FR-016/FR-017: manual escalation to a caller-specified node, rejected if it doesn't exist. */
async escalateManually(
ticketId: string,
targetNodeId: string,
actor: string,
reason: string,
): Promise<EscalationEvent> {
const node = await hierarchyRepository.findById(targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
return this.fire(ticketId, null, targetNodeId, actor, reason);
}
private async fire(
ticketId: string,
ruleId: string | null,
targetNodeId: string,
actor: string,
reason: string,
): Promise<EscalationEvent> {
const event = await this.events.create({
ticketId,
ruleId,
// No existing model persists "which hierarchy node is this ticket currently in" — Assignment
// (007) tracks only agentId, never a hierarchyNodeId — so fromNodeId is honestly left null
// rather than fabricated (data-model.md: "if any").
fromNodeId: null,
toNodeId: targetNodeId,
reason,
triggeredBy: actor,
});
await this.assignments.assignToSpecificNode(ticketId, targetNodeId, actor, reason);
// research.md "SLA_BREACHED/ESCALATION_TRIGGERED are also published, for audit, not for
// logic" — no subscriber consumes this; a durable event-log record only.
await eventBus.publish({
eventId: randomUUID(),
eventName: DomainEventName.ESCALATION_TRIGGERED,
aggregateId: ticketId,
aggregateType: 'Ticket',
timestamp: new Date().toISOString(),
payload: { ticketId, ruleId, targetNodeId, actor, reason },
});
return event;
}
async getHistory(ticketId: string): Promise<EscalationEvent[]> {
return this.events.findAllForTicket(ticketId);
}
async createPolicy(data: CreateEscalationPolicyBody): Promise<EscalationPolicy> {
if (data.productId) {
const product = await productsRepository.findById(data.productId);
if (!product) throw new NotFoundError('Product not found.');
}
return this.policies.create(data);
}
async listPolicies(): Promise<EscalationPolicy[]> {
return this.policies.findAll();
}
async getPolicy(id: string): Promise<EscalationPolicy> {
const policy = await this.policies.findById(id);
if (!policy) throw new NotFoundError('Escalation policy not found.');
return policy;
}
async createRule(policyId: string, data: CreateEscalationRuleBody): Promise<EscalationRule> {
await this.getPolicy(policyId);
const node = await hierarchyRepository.findById(data.targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
return this.rules.create({ ...data, policyId });
}
async updateRule(ruleId: string, data: UpdateEscalationRuleBody): Promise<EscalationRule> {
if (data.targetNodeId) {
const node = await hierarchyRepository.findById(data.targetNodeId);
if (!node) throw new NotFoundError('Hierarchy node not found.');
}
return this.rules.update(ruleId, data);
}
async deactivateRule(ruleId: string): Promise<EscalationRule> {
return this.rules.deactivate(ruleId);
}
}
export const escalationService = new EscalationService();
@@ -0,0 +1 @@
export { EscalationService, escalationService } from './escalation.service';
@@ -0,0 +1 @@
export {};
@@ -48,6 +48,29 @@ export class CapabilityLookupService {
),
);
}
/**
* 008-sla-escalation research.md "Escalation firing reuses 007's AssignmentEngine, scoped to a
* specific node": unlike findEligibleAgents (which resolves *which* node(s) match a ticket's
* context), this resolves eligibility for one exact, caller-specified node — the shape
* escalation needs, since it must never re-derive the applicable node from ticket context
* (that could resolve differently than the rule's own targetNodeId). Returns `null` when the
* node doesn't exist, so the caller can treat that as a 404 rather than an empty eligible set.
*/
async findEligibleAgentsForNode(hierarchyNodeId: string, requiredSkills: string[]) {
const node = await this.hierarchy.findById(hierarchyNodeId);
if (!node) return null;
const skillsToMatch = [...new Set([...requiredSkills, ...node.skills])];
const candidates = await agentsRepository.findActiveWithSkillsAndActiveTeam();
const eligibleAgents = candidates.filter((agent) =>
isCapabilityEligible(
agent.skills.map((s) => s.skillTag),
skillsToMatch,
),
);
return { node, eligibleAgents };
}
}
export const capabilityLookupService = new CapabilityLookupService();
@@ -63,6 +63,34 @@ export class RoutingService {
};
}
/**
* 008-sla-escalation research.md: resolves eligibility scoped to one exact hierarchy node
* (an escalation rule's targetNodeId, or a manual escalation's caller-specified node) — never
* re-deriving which node applies from the ticket's context, unlike resolveEligibleAgents
* above. Returns `null` when the node doesn't exist (caller treats that as a 404).
*/
async resolveForSpecificNode(
ticketId: string,
hierarchyNodeId: string,
): Promise<{
eligibleAgents: EligibleAgent[];
assignmentStrategy: string | null;
requiredSkills: string[];
} | null> {
const requiredSkills = await this.deriveRequiredSkills(ticketId);
const result = await this.capabilityLookup.findEligibleAgentsForNode(
hierarchyNodeId,
requiredSkills,
);
if (!result) return null;
return {
eligibleAgents: result.eligibleAgents as EligibleAgent[],
assignmentStrategy: result.node.assignmentStrategy ?? null,
requiredSkills,
};
}
private async deriveRequiredSkills(ticketId: string): Promise<string[]> {
const session = await sessionRepository.findMostRecentByTicketId(ticketId);
if (!session) return [];
@@ -1,6 +1,24 @@
import { SLAPolicy } from '@prisma/client';
import { businessCalendarsService, BusinessCalendarsService } from '@/modules/platform/business-calendars';
/**
* FR-004: replaces the original naive `createdDate + targetHours` stub — every due date is
* computed by walking the policy's own business calendar (research.md "Calendar-aware due-date
* arithmetic"), never a flat elapsed-time addition. `businessCalendarId: null` means 24/7 (no
* exclusions), handled by BusinessCalendarsService.computeDueDate itself.
*/
export class SlaDueDateCalculator {
calculateDueTime(createdDate: Date, targetHours: number): Date {
return new Date(createdDate.getTime() + targetHours * 3600 * 1000);
constructor(private readonly calendars: BusinessCalendarsService = businessCalendarsService) {}
async computeDueDates(
policy: SLAPolicy,
from: Date,
): Promise<{ firstResponseDueAt: Date; resolutionDueAt: Date }> {
const [firstResponseDueAt, resolutionDueAt] = await Promise.all([
this.calendars.computeDueDate(policy.businessCalendarId, from, policy.firstResponseMinutes),
this.calendars.computeDueDate(policy.businessCalendarId, from, policy.resolutionMinutes),
]);
return { firstResponseDueAt, resolutionDueAt };
}
}
@@ -0,0 +1,3 @@
export const SLA_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_SLA',
} as const;
@@ -0,0 +1 @@
export { SlaController, slaController } from './sla.controller';
@@ -0,0 +1,46 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { slaService, SlaService } from '../service';
import { createSlaPolicySchema, updateSlaPolicySchema } from '../schema';
export class SlaController {
constructor(private readonly service: SlaService = slaService) {}
async createPolicy(request: FastifyRequest, reply: FastifyReply) {
const body = createSlaPolicySchema.parse(request.body);
const policy = await this.service.createPolicy(body);
return reply.status(201).send({ success: true, data: policy, meta: null });
}
async listPolicies(request: FastifyRequest, reply: FastifyReply) {
const { productId } = request.query as { productId?: string };
const policies = await this.service.listPolicies(productId);
return reply.status(200).send({ success: true, data: policies, meta: null });
}
async getPolicy(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const policy = await this.service.getPolicy(id);
return reply.status(200).send({ success: true, data: policy, meta: null });
}
async updatePolicy(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const body = updateSlaPolicySchema.parse(request.body);
const policy = await this.service.updatePolicy(id, body);
return reply.status(200).send({ success: true, data: policy, meta: null });
}
async deactivatePolicy(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
await this.service.deactivatePolicy(id);
return reply.status(204).send();
}
async getRun(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const run = await this.service.getRunByTicketId(ticketId);
return reply.status(200).send({ success: true, data: run, meta: null });
}
}
export const slaController = new SlaController();
@@ -1,6 +1,14 @@
import { SLARun } from '@prisma/client';
import { slaService, SlaService } from '../service';
/** Replaces the original `evaluateSlaTargets` stub that always returned `{ status: 'NORMAL' }`
* — a thin façade over SlaService's read path (research.md/tasks.md put the substantive
* resolution/due-date/breach logic in the service layer). */
export class SlaEngine {
async evaluateSlaTargets(_ticketId: string): Promise<Record<string, unknown>> {
return { status: 'NORMAL' };
constructor(private readonly service: SlaService = slaService) {}
async evaluateSlaTargets(ticketId: string): Promise<SLARun> {
return this.service.getRunByTicketId(ticketId);
}
}
+13 -2
View File
@@ -1,2 +1,13 @@
export * from './engine/sla.engine';
export * from './calculators/sla-due-date.calculator';
export { slaRoutes } from './routes';
export { SlaService, slaService } from './service';
export { SlaPolicyResolverService, slaPolicyResolverService } from './service';
export type { SlaPolicyScope } from './service';
export { SlaEngine, slaEngine } from './engine/sla.engine';
export { SlaDueDateCalculator, slaDueDateCalculator } from './calculators/sla-due-date.calculator';
export {
slaPolicyRepository,
SlaPolicyRepository,
slaRunRepository,
SlaRunRepository,
} from './repository';
export { SLA_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,2 @@
export * from './sla-policy.repository';
export * from './sla-run.repository';
@@ -0,0 +1,67 @@
import { SLAPolicy, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateSlaPolicyData {
name: string;
productId?: string | null | undefined;
categoryId?: string | null | undefined;
problemTypeId?: string | null | undefined;
priority?: string | null | undefined;
firstResponseMinutes: number;
investigationMinutes?: number | null | undefined;
resolutionMinutes: number;
customerResponseMinutes?: number | null | undefined;
businessCalendarId?: string | null | undefined;
}
export class SlaPolicyRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateSlaPolicyData): Promise<SLAPolicy> {
return this.prisma.sLAPolicy.create({ data: data as Prisma.SLAPolicyUncheckedCreateInput });
}
async findById(id: string): Promise<SLAPolicy | null> {
return this.prisma.sLAPolicy.findUnique({ where: { id } });
}
async findAll(productId?: string): Promise<SLAPolicy[]> {
if (productId) return this.prisma.sLAPolicy.findMany({ where: { productId } });
return this.prisma.sLAPolicy.findMany();
}
async update(
id: string,
data: {
name?: string | undefined;
productId?: string | null | undefined;
categoryId?: string | null | undefined;
problemTypeId?: string | null | undefined;
priority?: string | null | undefined;
firstResponseMinutes?: number | undefined;
investigationMinutes?: number | null | undefined;
resolutionMinutes?: number | undefined;
customerResponseMinutes?: number | null | undefined;
businessCalendarId?: string | null | undefined;
},
): Promise<SLAPolicy> {
return this.prisma.sLAPolicy.update({
where: { id },
data: data as Prisma.SLAPolicyUpdateInput,
});
}
async deactivate(id: string): Promise<SLAPolicy> {
return this.prisma.sLAPolicy.update({ where: { id }, data: { active: false } });
}
/** research.md "SLA policy resolution": every active policy whose own scope fields are each
* either null (wildcard) or match the given ticket context — filtered fully in application
* code (not the DB query) since the wildcard-or-exact-match rule per field isn't expressible
* as a single simple Prisma where clause across four independently-optional dimensions. */
async findActiveCandidates(): Promise<SLAPolicy[]> {
return this.prisma.sLAPolicy.findMany({ where: { active: true } });
}
}
export const slaPolicyRepository = new SlaPolicyRepository();
@@ -0,0 +1,49 @@
import { SLARun, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateSlaRunData {
ticketId: string;
policyId: string;
firstResponseDueAt: Date | null;
resolutionDueAt: Date | null;
}
export class SlaRunRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateSlaRunData): Promise<SLARun> {
return this.prisma.sLARun.create({
data: { ...data, status: 'running' } as Prisma.SLARunUncheckedCreateInput,
});
}
async findByTicketId(ticketId: string): Promise<SLARun | null> {
return this.prisma.sLARun.findUnique({ where: { ticketId } });
}
async update(id: string, data: Prisma.SLARunUpdateInput): Promise<SLARun> {
return this.prisma.sLARun.update({ where: { id }, data });
}
/** research.md "Breach detection — one repeatable BullMQ job": every running run whose
* resolution due date has passed — indexed via @@index([status, resolutionDueAt]). */
async findRunningPastResolutionDueAt(now: Date): Promise<SLARun[]> {
return this.prisma.sLARun.findMany({
where: { status: 'running', resolutionDueAt: { lte: now } },
});
}
/** Every running run whose first-response due date has passed and hasn't already been
* flagged (firstResponseBreachedAt null — the idempotency guard, data-model.md). */
async findRunningPastFirstResponseDueAt(now: Date): Promise<SLARun[]> {
return this.prisma.sLARun.findMany({
where: {
status: 'running',
firstResponseDueAt: { lte: now },
firstResponseBreachedAt: null,
},
});
}
}
export const slaRunRepository = new SlaRunRepository();
@@ -0,0 +1 @@
export { slaRoutes } from './sla.routes';
@@ -0,0 +1,33 @@
import { FastifyInstance } from 'fastify';
import { slaController } from '../controller';
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate; the read
* route is not (same "read path any caller can use" convention as 003/007). */
export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/sla-policies',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.createPolicy(req, reply),
);
fastify.get(
'/admin/sla-policies',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.listPolicies(req, reply),
);
fastify.get(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.getPolicy(req, reply),
);
fastify.patch(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.updatePolicy(req, reply),
);
fastify.delete(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.deactivatePolicy(req, reply),
);
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
}
@@ -0,0 +1 @@
export * from './sla-policy.schema';
@@ -0,0 +1,21 @@
import { z } from 'zod';
export const createSlaPolicySchema = z
.object({
name: z.string().min(1),
productId: z.string().optional(),
categoryId: z.string().optional(),
problemTypeId: z.string().optional(),
priority: z.string().optional(),
firstResponseMinutes: z.number().int().positive(),
investigationMinutes: z.number().int().positive().optional(),
resolutionMinutes: z.number().int().positive(),
customerResponseMinutes: z.number().int().positive().optional(),
businessCalendarId: z.string().optional(),
})
.strict();
export const updateSlaPolicySchema = createSlaPolicySchema.partial();
export type CreateSlaPolicyBody = z.infer<typeof createSlaPolicySchema>;
export type UpdateSlaPolicyBody = z.infer<typeof updateSlaPolicySchema>;
@@ -0,0 +1,3 @@
export { SlaService, slaService } from './sla.service';
export { SlaPolicyResolverService, slaPolicyResolverService } from './sla-policy-resolver.service';
export type { SlaPolicyScope } from './sla-policy-resolver.service';
@@ -0,0 +1,51 @@
import { SLAPolicy } from '@prisma/client';
import { slaPolicyRepository, SlaPolicyRepository } from '../repository';
export interface SlaPolicyScope {
productId?: string | null | undefined;
categoryId?: string | null | undefined;
problemTypeId?: string | null | undefined;
priority?: string | null | undefined;
}
function matchesScope(policy: SLAPolicy, ticket: SlaPolicyScope): boolean {
if (policy.productId !== null && policy.productId !== (ticket.productId ?? null)) return false;
if (policy.categoryId !== null && policy.categoryId !== (ticket.categoryId ?? null)) return false;
if (policy.problemTypeId !== null && policy.problemTypeId !== (ticket.problemTypeId ?? null)) {
return false;
}
if (policy.priority !== null && policy.priority !== (ticket.priority ?? null)) return false;
return true;
}
function specificity(policy: SLAPolicy): number {
return [policy.productId, policy.categoryId, policy.problemTypeId, policy.priority].filter(
(f) => f !== null,
).length;
}
/**
* data-model.md "Resolution": among active policies whose scope fields each either wildcard
* (null) or match the ticket's own value, the one with the most non-null (most specific) scope
* fields wins; ties broken by latest updatedAt. Returns null when nothing matches (FR-005 — no
* SLARun is ever created without a real policy match).
*/
export class SlaPolicyResolverService {
constructor(private readonly policies: SlaPolicyRepository = slaPolicyRepository) {}
async findApplicablePolicy(ticket: SlaPolicyScope): Promise<SLAPolicy | null> {
const candidates = await this.policies.findActiveCandidates();
const matching = candidates.filter((p) => matchesScope(p, ticket));
if (matching.length === 0) return null;
matching.sort((a, b) => {
const specDiff = specificity(b) - specificity(a);
if (specDiff !== 0) return specDiff;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return matching[0] ?? null;
}
}
export const slaPolicyResolverService = new SlaPolicyResolverService();
@@ -0,0 +1,160 @@
import { SLAPolicy, SLARun } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { messagesService } from '@/modules/ticketing/messages';
import { escalationService, EscalationService } from '@/modules/orchestration/escalation';
import {
slaPolicyRepository,
SlaPolicyRepository,
slaRunRepository,
SlaRunRepository,
} from '../repository';
import { slaPolicyResolverService, SlaPolicyResolverService } from './sla-policy-resolver.service';
import { slaDueDateCalculator, SlaDueDateCalculator } from '../calculators/sla-due-date.calculator';
import { CreateSlaPolicyBody, UpdateSlaPolicyBody } from '../schema';
export class SlaService {
constructor(
private readonly policies: SlaPolicyRepository = slaPolicyRepository,
private readonly runs: SlaRunRepository = slaRunRepository,
private readonly resolver: SlaPolicyResolverService = slaPolicyResolverService,
private readonly dueDateCalculator: SlaDueDateCalculator = slaDueDateCalculator,
private readonly escalation: EscalationService = escalationService,
) {}
// --- SLAPolicy CRUD ---------------------------------------------------
async createPolicy(data: CreateSlaPolicyBody): Promise<SLAPolicy> {
return this.policies.create(data);
}
async listPolicies(productId?: string): Promise<SLAPolicy[]> {
return this.policies.findAll(productId);
}
async getPolicy(id: string): Promise<SLAPolicy> {
const policy = await this.policies.findById(id);
if (!policy) throw new NotFoundError('SLA policy not found.');
return policy;
}
async updatePolicy(id: string, data: UpdateSlaPolicyBody): Promise<SLAPolicy> {
await this.getPolicy(id);
return this.policies.update(id, data);
}
async deactivatePolicy(id: string): Promise<SLAPolicy> {
await this.getPolicy(id);
return this.policies.deactivate(id);
}
// --- SLARun lifecycle ---------------------------------------------------
async getRunByTicketId(ticketId: string): Promise<SLARun> {
const run = await this.runs.findByTicketId(ticketId);
if (!run) throw new NotFoundError('No SLA run found for this ticket.');
return run;
}
/**
* research.md "SLA-run lifecycle is wired entirely through the existing domain-event bus":
* subscribed to TICKET_ASSIGNED. No-ops if the ticket already has a run (SLARun.ticketId
* @unique — covers re-escalation's second publish, spec.md Assumptions: 1:1 with the first
* assignment only). No-ops if no policy matches (FR-005 — never an invented default).
*/
async handleTicketAssigned(ticketId: string): Promise<void> {
const existing = await this.runs.findByTicketId(ticketId);
if (existing) return;
const ticket = await ticketsService.getById(ticketId);
const policy = await this.resolver.findApplicablePolicy({
productId: ticket.productId,
categoryId: ticket.categoryId,
problemTypeId: null,
priority: ticket.priority,
});
if (!policy) return;
const { firstResponseDueAt, resolutionDueAt } = await this.dueDateCalculator.computeDueDates(
policy,
new Date(),
);
await this.runs.create({
ticketId,
policyId: policy.id,
firstResponseDueAt,
resolutionDueAt,
});
}
/** FR-007: pausing on WAITING_FOR_CUSTOMER records pausedAt and flips status — no-ops if
* there's no run or it isn't currently running. */
async pause(ticketId: string): Promise<void> {
const run = await this.runs.findByTicketId(ticketId);
if (!run || run.status !== 'running') return;
await this.runs.update(run.id, { status: 'paused', pausedAt: new Date() });
}
/**
* FR-008: research.md "Pause/resume — shift the absolute due date by the paused wall-clock
* duration" — resume shifts both due dates forward by exactly `now - pausedAt`, the entire
* durability mechanism (no separate remaining-minutes bookkeeping, no in-memory state).
*/
async resume(ticketId: string): Promise<void> {
const run = await this.runs.findByTicketId(ticketId);
if (!run || run.status !== 'paused' || !run.pausedAt) return;
const pausedMs = Date.now() - run.pausedAt.getTime();
await this.runs.update(run.id, {
status: 'running',
pausedAt: null,
resumedAt: new Date(),
firstResponseDueAt: run.firstResponseDueAt
? new Date(run.firstResponseDueAt.getTime() + pausedMs)
: null,
resolutionDueAt: run.resolutionDueAt
? new Date(run.resolutionDueAt.getTime() + pausedMs)
: null,
});
}
/** FR-010: a run that resolves before its due date is marked completed and is never later
* flagged breached (the breach sweep only ever looks at status: 'running' runs). */
async complete(ticketId: string): Promise<void> {
const run = await this.runs.findByTicketId(ticketId);
if (!run || run.status === 'completed') return;
await this.runs.update(run.id, { status: 'completed', completedAt: new Date() });
}
/**
* FR-009/FR-011/FR-013/FR-014/FR-015: research.md "The breach-detection job reuses
* src/jobs/sla/'s existing stub" — a single, directly-callable, side-effect-only sweep (no
* worker process needed to invoke it, tests call this directly). Marks resolution breaches
* (status -> breached) and first-response breaches (firstResponseBreachedAt, status
* unchanged), then fires escalation for each newly-detected breach.
*/
async runBreachDetectionSweep(): Promise<void> {
const now = new Date();
const resolutionBreaches = await this.runs.findRunningPastResolutionDueAt(now);
for (const run of resolutionBreaches) {
await this.runs.update(run.id, { status: 'breached', breachedAt: now });
await this.escalation.handleBreach(run.ticketId, 'resolution_breach');
}
const firstResponseBreaches = await this.runs.findRunningPastFirstResponseDueAt(now);
for (const run of firstResponseBreaches) {
const messages = await messagesService.listForAgent(run.ticketId);
const hasAgentResponse = messages.some((m) => m.type === 'AGENT_MESSAGE');
if (hasAgentResponse) continue;
await this.runs.update(run.id, { firstResponseBreachedAt: now });
await this.escalation.handleBreach(run.ticketId, 'first_response_breach');
}
}
}
export const slaService = new SlaService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,106 @@
import { DateTime } from 'luxon';
export interface WorkingWindow {
start: string; // "HH:mm", in the calendar's own timezone
end: string;
}
/** research.md "BusinessCalendar.workingHours shape": a missing key means zero working hours
* that weekday — never an implicit 24h default (spec.md Edge Cases). */
export type WorkingHours = Partial<
Record<'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun', WorkingWindow>
>;
const WEEKDAY_KEYS: Record<number, keyof WorkingHours> = {
1: 'mon',
2: 'tue',
3: 'wed',
4: 'thu',
5: 'fri',
6: 'sat',
7: 'sun',
};
const MAX_DAYS_SEARCHED = 3650; // ~10 years — a safety cap, never expected to be hit by any
// real SLA policy's minutes, guards against an unbounded loop on malformed input.
function isHoliday(day: DateTime, holidayDates: Date[]): boolean {
return holidayDates.some((h) => DateTime.fromJSDate(h, { zone: day.zone }).hasSame(day, 'day'));
}
/**
* research.md "Calendar-aware due-date arithmetic — a day-by-day walk": walks forward from
* `start` one calendar day at a time in the calendar's own timezone. A holiday date or a weekday
* with no configured window contributes zero available minutes; otherwise the day's working
* window (clipped by `start`'s own time on the first day) contributes up to its own duration.
* Returns the exact timestamp at which `minutes` of business time have elapsed since `start`.
*/
export function addBusinessMinutes(
start: Date,
minutes: number,
calendar: { timezone: string; workingHours: WorkingHours },
holidayDates: Date[],
): Date {
if (minutes <= 0) return new Date(start);
let remaining = minutes;
let cursor = DateTime.fromJSDate(start, { zone: calendar.timezone });
for (let dayGuard = 0; dayGuard < MAX_DAYS_SEARCHED; dayGuard++) {
const weekdayKey = WEEKDAY_KEYS[cursor.weekday];
const window = weekdayKey ? calendar.workingHours[weekdayKey] : undefined;
if (window && !isHoliday(cursor, holidayDates)) {
const [startHour, startMinute] = window.start.split(':').map(Number);
const [endHour, endMinute] = window.end.split(':').map(Number);
let windowStart = cursor.set({
hour: startHour,
minute: startMinute,
second: 0,
millisecond: 0,
});
const windowEnd = cursor.set({
hour: endHour,
minute: endMinute,
second: 0,
millisecond: 0,
});
if (cursor > windowStart) windowStart = cursor; // clip to start's own time on day 1
if (windowStart < windowEnd) {
const availableMinutes = windowEnd.diff(windowStart, 'minutes').minutes;
if (availableMinutes >= remaining) {
return windowStart.plus({ minutes: remaining }).toJSDate();
}
remaining -= availableMinutes;
}
}
cursor = cursor.plus({ days: 1 }).startOf('day');
}
throw new Error('addBusinessMinutes: exceeded maximum search window (10 years)');
}
/** Whether the given instant falls within the calendar's configured working hours — replaces
* the BusinessCalendarsService stub's hardcoded-true isWorkingHour. */
export function isWithinWorkingHours(
instant: Date,
calendar: { timezone: string; workingHours: WorkingHours },
holidayDates: Date[],
): boolean {
const zoned = DateTime.fromJSDate(instant, { zone: calendar.timezone });
if (isHoliday(zoned, holidayDates)) return false;
const weekdayKey = WEEKDAY_KEYS[zoned.weekday];
const window = weekdayKey ? calendar.workingHours[weekdayKey] : undefined;
if (!window) return false;
const [startHour, startMinute] = window.start.split(':').map(Number);
const [endHour, endMinute] = window.end.split(':').map(Number);
const windowStart = zoned.set({ hour: startHour, minute: startMinute, second: 0, millisecond: 0 });
const windowEnd = zoned.set({ hour: endHour, minute: endMinute, second: 0, millisecond: 0 });
return zoned >= windowStart && zoned < windowEnd;
}
@@ -0,0 +1,3 @@
export const BUSINESS_CALENDARS_CONSTANTS = {
MODULE_NAME: 'PLATFORM_BUSINESS_CALENDARS',
} as const;
@@ -0,0 +1,50 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { businessCalendarsService, BusinessCalendarsService } from '../service';
import {
createBusinessCalendarSchema,
updateBusinessCalendarSchema,
createHolidaySchema,
} from '../schema';
export class BusinessCalendarsController {
constructor(private readonly service: BusinessCalendarsService = businessCalendarsService) {}
async create(request: FastifyRequest, reply: FastifyReply) {
const body = createBusinessCalendarSchema.parse(request.body);
const calendar = await this.service.create(body);
return reply.status(201).send({ success: true, data: calendar, meta: null });
}
async list(_request: FastifyRequest, reply: FastifyReply) {
const calendars = await this.service.list();
return reply.status(200).send({ success: true, data: calendars, meta: null });
}
async getById(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const calendar = await this.service.getById(id);
return reply.status(200).send({ success: true, data: calendar, meta: null });
}
async update(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const body = updateBusinessCalendarSchema.parse(request.body);
const calendar = await this.service.update(id, body);
return reply.status(200).send({ success: true, data: calendar, meta: null });
}
async addHoliday(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const body = createHolidaySchema.parse(request.body);
const holiday = await this.service.addHoliday(id, body);
return reply.status(201).send({ success: true, data: holiday, meta: null });
}
async removeHoliday(request: FastifyRequest, reply: FastifyReply) {
const { id, holidayId } = request.params as { id: string; holidayId: string };
await this.service.removeHoliday(id, holidayId);
return reply.status(204).send();
}
}
export const businessCalendarsController = new BusinessCalendarsController();
@@ -0,0 +1 @@
export { BusinessCalendarsController, businessCalendarsController } from './business-calendars.controller';
@@ -1,11 +1,10 @@
export const BUSINESS_CALENDARS_CONSTANTS = {
MODULE_NAME: 'PLATFORM_BUSINESS_CALENDARS',
} as const;
export class BusinessCalendarsService {
async isWorkingHour(_date: Date): Promise<boolean> {
return true;
}
}
export const businessCalendarsService = new BusinessCalendarsService();
export { businessCalendarsRoutes } from './routes';
export { BusinessCalendarsService, businessCalendarsService } from './service';
export {
businessCalendarRepository,
BusinessCalendarRepository,
holidayRepository,
HolidayRepository,
} from './repository';
export type { WorkingWindow, WorkingHours } from './types';
export { BUSINESS_CALENDARS_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,47 @@
import { BusinessCalendar, Holiday, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class BusinessCalendarRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: {
name: string;
timezone: string;
workingHours: Prisma.InputJsonValue;
}): Promise<BusinessCalendar> {
return this.prisma.businessCalendar.create({ data });
}
async findById(id: string): Promise<BusinessCalendar | null> {
return this.prisma.businessCalendar.findUnique({ where: { id } });
}
async findByIdWithHolidays(
id: string,
): Promise<(BusinessCalendar & { holidays: Holiday[] }) | null> {
return this.prisma.businessCalendar.findUnique({
where: { id },
include: { holidays: true },
});
}
async findAll(): Promise<BusinessCalendar[]> {
return this.prisma.businessCalendar.findMany();
}
async update(
id: string,
data: {
name?: string | undefined;
timezone?: string | undefined;
workingHours?: Prisma.InputJsonValue | undefined;
},
): Promise<BusinessCalendar> {
return this.prisma.businessCalendar.update({
where: { id },
data: data as Prisma.BusinessCalendarUpdateInput,
});
}
}
export const businessCalendarRepository = new BusinessCalendarRepository();
@@ -0,0 +1,28 @@
import { Holiday, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class HolidayRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: {
calendarId: string;
date: Date;
description?: string | undefined;
}): Promise<Holiday> {
return this.prisma.holiday.create({ data: data as Prisma.HolidayUncheckedCreateInput });
}
async findAllForCalendar(calendarId: string): Promise<Holiday[]> {
return this.prisma.holiday.findMany({ where: { calendarId } });
}
async findById(id: string): Promise<Holiday | null> {
return this.prisma.holiday.findUnique({ where: { id } });
}
async delete(id: string): Promise<void> {
await this.prisma.holiday.delete({ where: { id } });
}
}
export const holidayRepository = new HolidayRepository();
@@ -0,0 +1,2 @@
export * from './business-calendar.repository';
export * from './holiday.repository';
@@ -0,0 +1,37 @@
import { FastifyInstance } from 'fastify';
import { businessCalendarsController } from '../controller';
/** contracts/sla-escalation-contract.md: every admin route gated by fastify.authenticate (known
* limitation inherited from 002-007). */
export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/business-calendars',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.create(req, reply),
);
fastify.get(
'/admin/business-calendars',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.list(req, reply),
);
fastify.get(
'/admin/business-calendars/:id',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.getById(req, reply),
);
fastify.patch(
'/admin/business-calendars/:id',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.update(req, reply),
);
fastify.post(
'/admin/business-calendars/:id/holidays',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.addHoliday(req, reply),
);
fastify.delete(
'/admin/business-calendars/:id/holidays/:holidayId',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.removeHoliday(req, reply),
);
}
@@ -0,0 +1 @@
export { businessCalendarsRoutes } from './business-calendars.routes';
@@ -0,0 +1,46 @@
import { z } from 'zod';
const HH_MM = /^([01]\d|2[0-3]):([0-5]\d)$/;
const workingWindowSchema = z
.object({
start: z.string().regex(HH_MM, 'start must be HH:mm (24-hour)'),
end: z.string().regex(HH_MM, 'end must be HH:mm (24-hour)'),
})
.strict()
.refine((w) => w.start < w.end, { message: 'start must be before end' });
const WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
export const workingHoursSchema = z
.object(Object.fromEntries(WEEKDAY_KEYS.map((k) => [k, workingWindowSchema.optional()])))
.strict();
function isValidTimezone(tz: string): boolean {
try {
return Intl.supportedValuesOf('timeZone').includes(tz);
} catch {
return false;
}
}
export const createBusinessCalendarSchema = z
.object({
name: z.string().min(1),
timezone: z.string().refine(isValidTimezone, { message: 'not a valid IANA timezone name' }),
workingHours: workingHoursSchema,
})
.strict();
export const updateBusinessCalendarSchema = createBusinessCalendarSchema.partial();
export const createHolidaySchema = z
.object({
date: z.coerce.date(),
description: z.string().optional(),
})
.strict();
export type CreateBusinessCalendarBody = z.infer<typeof createBusinessCalendarSchema>;
export type UpdateBusinessCalendarBody = z.infer<typeof updateBusinessCalendarSchema>;
export type CreateHolidayBody = z.infer<typeof createHolidaySchema>;
@@ -0,0 +1 @@
export * from './business-calendars.schema';
@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import { BusinessCalendar, Holiday } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import {
businessCalendarRepository,
BusinessCalendarRepository,
holidayRepository,
HolidayRepository,
} from '../repository';
import { addBusinessMinutes, isWithinWorkingHours, WorkingHours } from '../calculators/business-hours.calculator';
import { CreateBusinessCalendarBody, UpdateBusinessCalendarBody, CreateHolidayBody } from '../schema';
export class BusinessCalendarsService {
constructor(
private readonly calendars: BusinessCalendarRepository = businessCalendarRepository,
private readonly holidays: HolidayRepository = holidayRepository,
) {}
async create(data: CreateBusinessCalendarBody): Promise<BusinessCalendar> {
return this.calendars.create(data);
}
async getById(id: string): Promise<BusinessCalendar> {
const calendar = await this.calendars.findById(id);
if (!calendar) throw new NotFoundError('Business calendar not found.');
return calendar;
}
async list(): Promise<BusinessCalendar[]> {
return this.calendars.findAll();
}
async update(id: string, data: UpdateBusinessCalendarBody): Promise<BusinessCalendar> {
await this.getById(id);
return this.calendars.update(id, data);
}
async addHoliday(calendarId: string, data: CreateHolidayBody): Promise<Holiday> {
await this.getById(calendarId);
return this.holidays.create({ calendarId, date: data.date, description: data.description });
}
async removeHoliday(calendarId: string, holidayId: string): Promise<void> {
const holiday = await this.holidays.findById(holidayId);
if (!holiday || holiday.calendarId !== calendarId) {
throw new NotFoundError('Holiday not found.');
}
await this.holidays.delete(holidayId);
}
/** Replaces the original stub's hardcoded `true`. */
async isWorkingHour(calendarId: string, instant: Date): Promise<boolean> {
const calendar = await this.calendars.findByIdWithHolidays(calendarId);
if (!calendar) throw new NotFoundError('Business calendar not found.');
return isWithinWorkingHours(
instant,
{ timezone: calendar.timezone, workingHours: calendar.workingHours as WorkingHours },
calendar.holidays.map((h) => h.date),
);
}
/**
* FR-004: the single entry point 008's SLA due-date calculator uses (research.md — through
* this module's public index.ts, never a second calendar-walk implementation). `calendarId:
* null` means 24/7, no exclusions (data-model.md's `SLAPolicy.businessCalendarId` note) — a
* plain minute addition, not a missing-calendar error.
*/
async computeDueDate(calendarId: string | null, from: Date, minutes: number): Promise<Date> {
if (!calendarId) {
return DateTime.fromJSDate(from).plus({ minutes }).toJSDate();
}
const calendar = await this.calendars.findByIdWithHolidays(calendarId);
if (!calendar) throw new NotFoundError('Business calendar not found.');
return addBusinessMinutes(
from,
minutes,
{ timezone: calendar.timezone, workingHours: calendar.workingHours as WorkingHours },
calendar.holidays.map((h) => h.date),
);
}
}
export const businessCalendarsService = new BusinessCalendarsService();
@@ -0,0 +1 @@
export { BusinessCalendarsService, businessCalendarsService } from './business-calendars.service';
@@ -0,0 +1 @@
export type { WorkingWindow, WorkingHours } from '../calculators/business-hours.calculator';
@@ -0,0 +1,384 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { slaService } from '@/modules/orchestration/sla';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/008-sla-escalation/quickstart.md Scenarios 1-6 against a real Postgres/Redis —
* one product's tickets through SLA policy resolution, calendar-aware run creation, durable
* pause/resume (including a genuine buildApp() restart, Constitution Principle VII), breach
* detection, and both automatic and manual escalation.
*/
describe('SLA and escalation — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
const externalProductId = `TEST_SLA_PROD_${Date.now()}`;
const skillTag = `sla_skill_${Date.now()}`;
let productId: string;
let teamId: string;
let agentAId: string;
let agentBId: string;
let nodeAId: string;
let nodeBId: string;
let secret: string;
let globalPolicyId: string;
let productPolicyId: string;
const createdTicketIds: string[] = [];
async function createTicket(): Promise<string> {
// 002-saas-integration: tokens are single-use (jti replay protection) — a fresh one per
// ticket, matching the trust-boundary contract, not a shared token reused across requests.
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: `Needs a human ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
return ticketId;
}
async function escalateAndAssign(ticketId: string): Promise<void> {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'SLA Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: `SLA Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'SLA Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'SLA Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
payload: { level: 3 },
});
const nodeA = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'SLA Node A',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
nodeAId = nodeA.json().data.id;
const nodeB = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'SLA Node B (escalation target)',
order: 1,
productScope: [],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
nodeBId = nodeB.json().data.id;
// Global (wildcard) policy — long duration, never expected to breach in this suite.
const globalPolicy = await app.inject({
method: 'POST',
url: '/admin/sla-policies',
payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 },
});
globalPolicyId = globalPolicy.json().data.id;
// Product-scoped policy — more specific, should win over the global one.
const productPolicy = await app.inject({
method: 'POST',
url: '/admin/sla-policies',
payload: {
name: 'Product policy',
productId,
firstResponseMinutes: 30,
resolutionMinutes: 60,
},
});
productPolicyId = productPolicy.json().data.id;
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: { in: [nodeAId, nodeBId] } } });
await prismaClient.escalationPolicy.deleteMany({ where: { productId } });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.sLAPolicy.deleteMany({ where: { id: { in: [globalPolicyId, productPolicyId] } } });
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } });
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('Scenario 1/2: assignment resolves the most-specific policy and creates a calendar-aware SLARun', async () => {
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
expect(runResponse.statusCode).toBe(200);
const run = runResponse.json().data;
expect(run.policyId).toBe(productPolicyId); // product-scoped wins over global
expect(run.status).toBe('running');
const dueAt = new Date(run.resolutionDueAt).getTime();
const expected = Date.now() + 60 * 60 * 1000; // resolutionMinutes: 60, businessCalendarId: null (24/7)
expect(Math.abs(dueAt - expected)).toBeLessThan(60 * 1000); // 1 minute tolerance
});
it('Scenario 2: an assignment matching no active policy gets no SLARun', async () => {
const outsidePolicy = await app.inject({
method: 'GET',
url: `/admin/sla-policies/${productPolicyId}`,
});
expect(outsidePolicy.statusCode).toBe(200);
// Deactivate both policies temporarily to prove the no-match path.
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` });
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` });
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
expect(runResponse.statusCode).toBe(404);
// Restore both policies for the remaining scenarios.
await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } });
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: true } });
});
it('Scenario 3: pause/resume is durable across a genuine process restart', async () => {
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
const before = (await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` })).json()
.data;
const originalDueAt = new Date(before.resolutionDueAt).getTime();
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version },
});
const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
expect(paused.status).toBe('paused');
expect(paused.pausedAt).not.toBeNull();
// Genuine restart boundary — a fresh app instance, per Constitution Principle VII.
await app.close();
await new Promise((resolve) => setTimeout(resolve, 1200)); // real pause duration to shift by
app = await buildApp();
const ticketAfterRestart = await prismaClient.ticket.findUniqueOrThrow({
where: { id: ticketId },
});
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version },
});
const resumed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
expect(resumed.status).toBe('running');
expect(resumed.pausedAt).toBeNull();
expect(resumed.resolutionDueAt!.getTime()).toBeGreaterThan(originalDueAt + 1000);
});
it('Scenario 4: breach detection marks a run breached, never a completed or paused one', async () => {
const overdueTicketId = await createTicket();
await escalateAndAssign(overdueTicketId);
await prismaClient.sLARun.update({
where: { ticketId: overdueTicketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
});
const completedTicketId = await createTicket();
await escalateAndAssign(completedTicketId);
await prismaClient.sLARun.update({
where: { ticketId: completedTicketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'completed' },
});
const pausedTicketId = await createTicket();
await escalateAndAssign(pausedTicketId);
await prismaClient.sLARun.update({
where: { ticketId: pausedTicketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'paused', pausedAt: new Date() },
});
await slaService.runBreachDetectionSweep();
const overdue = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: overdueTicketId } });
expect(overdue.status).toBe('breached');
expect(overdue.breachedAt).not.toBeNull();
const completed = await prismaClient.sLARun.findUniqueOrThrow({
where: { ticketId: completedTicketId },
});
expect(completed.status).toBe('completed');
const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: pausedTicketId } });
expect(paused.status).toBe('paused');
});
it('Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule\'s node', async () => {
const policy = await app.inject({
method: 'POST',
url: '/admin/escalation-policies',
payload: { name: 'Product escalation policy', productId },
});
const escalationPolicyId = policy.json().data.id;
await app.inject({
method: 'POST',
url: `/admin/escalation-policies/${escalationPolicyId}/rules`,
payload: {
triggerType: 'resolution_breach',
condition: {},
targetNodeId: nodeBId,
notify: {},
},
});
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
await prismaClient.sLARun.update({
where: { ticketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
});
await slaService.runBreachDetectionSweep();
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
expect(events.length).toBe(1);
expect(events[0]?.toNodeId).toBe(nodeBId);
const assignment = await prismaClient.assignment.findFirst({
where: { ticketId, isCurrent: true },
});
expect([agentAId, agentBId]).toContain(assignment?.agentId);
});
it('Scenario 5b: a breach with no matching rule is still recorded breached, with no EscalationEvent', async () => {
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
await prismaClient.sLARun.update({
where: { ticketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
});
await slaService.runBreachDetectionSweep();
const run = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
expect(run.status).toBe('breached');
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
// No escalation rule exists for this ticket's context beyond the one created in Scenario 5
// (scoped to this product's policy, which fires unconditionally on resolution_breach) — so
// this ticket, sharing the same product, is expected to also match that same rule.
expect(events.length).toBe(1);
});
it('Scenario 6: manual escalation creates an event and reassigns; a nonexistent node is rejected', async () => {
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
const notFound = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/escalate`,
payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' },
});
expect(notFound.statusCode).toBe(404);
expect(await prismaClient.escalationEvent.count({ where: { ticketId } })).toBe(0);
const manual = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/escalate`,
payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' },
});
expect(manual.statusCode).toBe(201);
expect(manual.json().data.ruleId).toBeNull();
expect(manual.json().data.toNodeId).toBe(nodeBId);
const assignment = await prismaClient.assignment.findFirst({
where: { ticketId, isCurrent: true },
});
expect([agentAId, agentBId]).toContain(assignment?.agentId);
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/modules/ticketing/tickets', () => ({
ticketsService: { getById: vi.fn().mockResolvedValue({ id: 't1', productId: 'prod-1' }) },
}));
import { EscalationService } from '@/modules/orchestration/escalation/service/escalation.service';
describe('EscalationService.handleBreach', () => {
it('fires one EscalationEvent and a scoped re-assignment per active matching rule', async () => {
const rule = {
id: 'rule-1',
policyId: 'policy-1',
triggerType: 'resolution_breach',
targetNodeId: 'node-1',
active: true,
};
const policies = {
findApplicable: vi.fn().mockResolvedValue({ id: 'policy-1', productId: 'prod-1' }),
} as never;
const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never;
const events = { create: vi.fn().mockResolvedValue({ id: 'event-1' }) } as never;
const assignmentEngine = { assignToSpecificNode: vi.fn().mockResolvedValue(undefined) } as never;
const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules).toHaveBeenCalledWith(
'policy-1',
'resolution_breach',
);
expect((events as { create: ReturnType<typeof vi.fn> }).create).toHaveBeenCalledWith(
expect.objectContaining({ ticketId: 't1', ruleId: 'rule-1', toNodeId: 'node-1' }),
);
expect(
(assignmentEngine as { assignToSpecificNode: ReturnType<typeof vi.fn> }).assignToSpecificNode,
).toHaveBeenCalledWith('t1', 'node-1', 'system', expect.stringContaining('resolution_breach'));
});
it('records nothing when no escalation policy matches the ticket product', async () => {
const policies = { findApplicable: vi.fn().mockResolvedValue(null) } as never;
const rules = { findActiveRules: vi.fn() } as never;
const events = { create: vi.fn() } as never;
const assignmentEngine = { assignToSpecificNode: vi.fn() } as never;
const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules).not.toHaveBeenCalled();
expect((events as { create: ReturnType<typeof vi.fn> }).create).not.toHaveBeenCalled();
});
it('records nothing when a policy matches but no active rule matches the trigger type', async () => {
const policies = {
findApplicable: vi.fn().mockResolvedValue({ id: 'policy-1', productId: 'prod-1' }),
} as never;
const rules = { findActiveRules: vi.fn().mockResolvedValue([]) } as never;
const events = { create: vi.fn() } as never;
const assignmentEngine = { assignToSpecificNode: vi.fn() } as never;
const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach');
expect((events as { create: ReturnType<typeof vi.fn> }).create).not.toHaveBeenCalled();
expect(
(assignmentEngine as { assignToSpecificNode: ReturnType<typeof vi.fn> }).assignToSpecificNode,
).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
import { SlaService } from '@/modules/orchestration/sla/service/sla.service';
import { SLARun } from '@prisma/client';
function run(overrides: Partial<SLARun>): SLARun {
return {
id: 'run-1',
ticketId: 'ticket-1',
policyId: 'policy-1',
firstResponseDueAt: null,
resolutionDueAt: null,
status: 'running',
pausedAt: null,
resumedAt: null,
breachedAt: null,
firstResponseBreachedAt: null,
completedAt: null,
...overrides,
} as SLARun;
}
describe('SlaService.runBreachDetectionSweep', () => {
it('marks every running run past its resolution due date as breached and fires escalation', async () => {
const overdue = run({ id: 'r1', ticketId: 't1' });
const update = vi.fn().mockResolvedValue(overdue);
const runsRepo = {
findRunningPastResolutionDueAt: vi.fn().mockResolvedValue([overdue]),
findRunningPastFirstResponseDueAt: vi.fn().mockResolvedValue([]),
update,
} as never;
const handleBreach = vi.fn().mockResolvedValue(undefined);
const escalation = { handleBreach } as never;
const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation);
await service.runBreachDetectionSweep();
expect(update).toHaveBeenCalledWith(
'r1',
expect.objectContaining({ status: 'breached' }),
);
expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach');
});
it('never touches a run that is not past its due date (the query itself excludes it — verified by trusting only what the repository returns)', async () => {
const runsRepo = {
findRunningPastResolutionDueAt: vi.fn().mockResolvedValue([]),
findRunningPastFirstResponseDueAt: vi.fn().mockResolvedValue([]),
update: vi.fn(),
} as never;
const handleBreach = vi.fn();
const service = new SlaService(undefined, runsRepo, undefined, undefined, {
handleBreach,
} as never);
await service.runBreachDetectionSweep();
expect(handleBreach).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest';
import { SlaService } from '@/modules/orchestration/sla/service/sla.service';
import { SLARun } from '@prisma/client';
function run(overrides: Partial<SLARun>): SLARun {
return {
id: 'run-1',
ticketId: 'ticket-1',
policyId: 'policy-1',
firstResponseDueAt: new Date('2026-01-05T12:00:00.000Z'),
resolutionDueAt: new Date('2026-01-05T17:00:00.000Z'),
status: 'running',
pausedAt: null,
resumedAt: null,
breachedAt: null,
firstResponseBreachedAt: null,
completedAt: null,
...overrides,
} as SLARun;
}
describe('SlaService pause/resume', () => {
it('pause records pausedAt and flips status to paused', async () => {
const found = run({});
const update = vi.fn().mockResolvedValue(found);
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(found), update } as never;
const service = new SlaService(undefined, runsRepo);
await service.pause('ticket-1');
expect(update).toHaveBeenCalledWith('run-1', expect.objectContaining({ status: 'paused' }));
});
it('resume shifts both due dates forward by exactly the paused wall-clock duration', async () => {
const pausedAt = new Date(Date.now() - 30 * 60 * 1000); // paused 30 minutes ago
const paused = run({
status: 'paused',
pausedAt,
firstResponseDueAt: new Date('2026-01-05T12:00:00.000Z'),
resolutionDueAt: new Date('2026-01-05T17:00:00.000Z'),
});
const update = vi.fn().mockResolvedValue(paused);
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(paused), update } as never;
const service = new SlaService(undefined, runsRepo);
const before = Date.now();
await service.resume('ticket-1');
const after = Date.now();
expect(update).toHaveBeenCalledTimes(1);
const [, patch] = update.mock.calls[0] as [string, Record<string, unknown>];
expect(patch.status).toBe('running');
expect(patch.pausedAt).toBeNull();
const shiftedResolution = (patch.resolutionDueAt as Date).getTime();
const expectedShiftMin = new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime());
const expectedShiftMax = new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime());
expect(shiftedResolution).toBeGreaterThanOrEqual(expectedShiftMin);
expect(shiftedResolution).toBeLessThanOrEqual(expectedShiftMax);
});
it('a second pause/resume cycle composes correctly (never resets to the original due date)', async () => {
const afterFirstResume = run({
status: 'running',
pausedAt: null,
resolutionDueAt: new Date('2026-01-05T18:00:00.000Z'), // already shifted by +1h once
});
const secondPausedAt = new Date(Date.now() - 10 * 60 * 1000);
const pausedAgain = { ...afterFirstResume, status: 'paused', pausedAt: secondPausedAt } as SLARun;
const update = vi.fn().mockResolvedValue(pausedAgain);
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never;
const service = new SlaService(undefined, runsRepo);
await service.resume('ticket-1');
const [, patch] = update.mock.calls[0] as [string, Record<string, unknown>];
const shifted = (patch.resolutionDueAt as Date).getTime();
// Must be shifted from the ALREADY-shifted 18:00 baseline, not the original 17:00 baseline.
expect(shifted).toBeGreaterThan(new Date('2026-01-05T18:00:00.000Z').getTime());
});
it('never resumes a run that is not currently paused', async () => {
const runningRun = run({ status: 'running', pausedAt: null });
const update = vi.fn();
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(runningRun), update } as never;
const service = new SlaService(undefined, runsRepo);
await service.resume('ticket-1');
expect(update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { SlaPolicyResolverService } from '@/modules/orchestration/sla/service/sla-policy-resolver.service';
import { SLAPolicy } from '@prisma/client';
function policy(overrides: Partial<SLAPolicy>): SLAPolicy {
return {
id: 'p1',
name: 'test',
productId: null,
categoryId: null,
problemTypeId: null,
priority: null,
firstResponseMinutes: 60,
investigationMinutes: null,
resolutionMinutes: 480,
customerResponseMinutes: null,
businessCalendarId: null,
active: true,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
} as SLAPolicy;
}
function fakeRepo(candidates: SLAPolicy[]) {
return { findActiveCandidates: vi.fn().mockResolvedValue(candidates) } as never;
}
describe('SlaPolicyResolverService.findApplicablePolicy', () => {
it('prefers a policy with more matching specific fields over a global wildcard policy', async () => {
const global = policy({ id: 'global' });
const specific = policy({ id: 'specific', productId: 'prod-1' });
const resolver = new SlaPolicyResolverService(fakeRepo([global, specific]));
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
expect(result?.id).toBe('specific');
});
it('excludes a policy whose scope field is set but does not match the ticket', async () => {
const wrongProduct = policy({ id: 'wrong', productId: 'prod-2' });
const resolver = new SlaPolicyResolverService(fakeRepo([wrongProduct]));
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
expect(result).toBeNull();
});
it('treats every unset scope field as a wildcard independently', async () => {
const productOnly = policy({ id: 'product-only', productId: 'prod-1' });
const resolver = new SlaPolicyResolverService(fakeRepo([productOnly]));
const result = await resolver.findApplicablePolicy({
productId: 'prod-1',
categoryId: 'cat-99',
priority: 'urgent',
});
expect(result?.id).toBe('product-only');
});
it('breaks a specificity tie by the most recently updated policy', async () => {
const older = policy({
id: 'older',
productId: 'prod-1',
updatedAt: new Date('2026-01-01'),
});
const newer = policy({
id: 'newer',
productId: 'prod-1',
updatedAt: new Date('2026-06-01'),
});
const resolver = new SlaPolicyResolverService(fakeRepo([older, newer]));
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
expect(result?.id).toBe('newer');
});
it('returns null when no active policy matches', async () => {
const resolver = new SlaPolicyResolverService(fakeRepo([]));
const result = await resolver.findApplicablePolicy({ productId: 'prod-1' });
expect(result).toBeNull();
});
});
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { addBusinessMinutes, isWithinWorkingHours } from '@/modules/platform/business-calendars/calculators/business-hours.calculator';
const MON_FRI_9_TO_5 = {
timezone: 'America/New_York',
workingHours: {
mon: { start: '09:00', end: '17:00' },
tue: { start: '09:00', end: '17:00' },
wed: { start: '09:00', end: '17:00' },
thu: { start: '09:00', end: '17:00' },
fri: { start: '09:00', end: '17:00' },
},
};
// America/New_York is UTC-5 (EST) outside DST, UTC-4 (EDT) during DST — every Date.UTC(...)
// below is written as the equivalent UTC instant for the Eastern wall-clock time noted in the
// comment, since luxon's own DST handling is exactly what's under test here.
describe('addBusinessMinutes', () => {
it('stays within the same working day when enough time remains', () => {
// Mon 2026-01-05 10:00 EST (UTC-5) -> 15:00 UTC
const start = new Date(Date.UTC(2026, 0, 5, 15, 0));
const due = addBusinessMinutes(start, 120, MON_FRI_9_TO_5, []);
// +120 min -> 12:00 EST -> 17:00 UTC
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 0, 5, 17, 0)).toISOString());
});
it('rolls over a weekend, never counting Saturday/Sunday as available time', () => {
// Fri 2026-01-02 16:00 EST (1 hour left in the window) -> 21:00 UTC
const start = new Date(Date.UTC(2026, 0, 2, 21, 0));
// 1 hour left Friday + need 3 more hours -> Monday 12:00 EST
const due = addBusinessMinutes(start, 240, MON_FRI_9_TO_5, []);
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 0, 5, 17, 0)).toISOString()); // Mon 12:00 EST
});
it('excludes a holiday entirely, rolling to the next working day', () => {
// Fri 2026-01-02 16:00 EST, Monday Jan 5 is a holiday
const start = new Date(Date.UTC(2026, 0, 2, 21, 0));
const holiday = new Date(Date.UTC(2026, 0, 5, 17, 0)); // Mon 12:00 EST — safely mid-Monday
const due = addBusinessMinutes(start, 240, MON_FRI_9_TO_5, [holiday]);
// 1h Friday + Monday excluded + 3h into Tuesday -> Tue 12:00 EST
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 0, 6, 17, 0)).toISOString());
});
it('a weekday with no configured window contributes zero available time', () => {
const noWednesday = {
timezone: 'America/New_York',
workingHours: {
tue: { start: '09:00', end: '17:00' },
thu: { start: '09:00', end: '17:00' },
},
};
// Tue 2026-01-06 16:00 EST (1h left)
const start = new Date(Date.UTC(2026, 0, 6, 21, 0));
const due = addBusinessMinutes(start, 300, noWednesday, []);
// 1h Tue + (Wed skipped, zero hours) + 4h into Thu -> Thu 13:00 EST
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 0, 8, 18, 0)).toISOString());
});
it('clips to the start time on the first day, never counting time before it', () => {
// Mon 10:00 EST: 7h remain in the window (to 17:00) + 1 more hour -> Tue 10:00 EST
const start = new Date(Date.UTC(2026, 0, 5, 15, 0));
const due = addBusinessMinutes(start, 420 + 60, MON_FRI_9_TO_5, []);
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 0, 6, 15, 0)).toISOString()); // Tue 10:00 EST
});
it('returns the start time unchanged when minutes is zero or negative', () => {
const start = new Date(Date.UTC(2026, 0, 5, 15, 0));
expect(addBusinessMinutes(start, 0, MON_FRI_9_TO_5, []).toISOString()).toBe(start.toISOString());
});
it('is correct across a DST transition (US spring-forward, March 2026)', () => {
// 2026-03-08 is the US DST transition (02:00 -> 03:00). Fri 2026-03-06 16:00 EST (1h left).
const start = new Date(Date.UTC(2026, 2, 6, 21, 0));
// 1h Friday + weekend skipped + 3h into Monday 2026-03-09 (now EDT, UTC-4) -> 12:00 EDT -> 16:00 UTC
const due = addBusinessMinutes(start, 240, MON_FRI_9_TO_5, []);
expect(due.toISOString()).toBe(new Date(Date.UTC(2026, 2, 9, 16, 0)).toISOString());
});
});
describe('isWithinWorkingHours', () => {
it('is true inside a configured window and false outside it', () => {
const insideWindow = new Date(Date.UTC(2026, 0, 5, 16, 0)); // Mon 11:00 EST
const beforeWindow = new Date(Date.UTC(2026, 0, 5, 13, 0)); // Mon 08:00 EST
expect(isWithinWorkingHours(insideWindow, MON_FRI_9_TO_5, [])).toBe(true);
expect(isWithinWorkingHours(beforeWindow, MON_FRI_9_TO_5, [])).toBe(false);
});
it('is false on a weekend and on a holiday', () => {
const saturday = new Date(Date.UTC(2026, 0, 3, 16, 0));
expect(isWithinWorkingHours(saturday, MON_FRI_9_TO_5, [])).toBe(false);
const mondayNoon = new Date(Date.UTC(2026, 0, 5, 16, 0)); // Mon 11:00 EST
const holiday = new Date(Date.UTC(2026, 0, 5, 17, 0)); // Mon 12:00 EST — safely mid-Monday
expect(isWithinWorkingHours(mondayNoon, MON_FRI_9_TO_5, [holiday])).toBe(false);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
include: ['tests/**/*.test.ts'],
env: {
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/supporthub_test_db?schema=public',
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5433/supporthub_test_db?schema=public',
JWT_SECRET: 'super-secret-test-jwt-key-min-32-characters',
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY: '204fcf94032f4e369d756127444864723cbac2b473e9d8bdaa942c1a5a4b7bec',
},