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:
co-authored by
Claude Sonnet 5
parent
bb31e9d641
commit
9357f03e1d
@@ -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;
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user