diff --git a/prisma/migrations/20260903070436_add_sla_escalation/migration.sql b/prisma/migrations/20260903070436_add_sla_escalation/migration.sql new file mode 100644 index 0000000..359bf0b --- /dev/null +++ b/prisma/migrations/20260903070436_add_sla_escalation/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 074cdbb..2d554c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,8 @@ model Product { knownIssues KnownIssue[] runbooks Runbook[] aiConfidencePolicies AIConfidencePolicy[] + slaPolicies SLAPolicy[] + escalationPolicies EscalationPolicy[] @@map("products") } @@ -90,9 +92,10 @@ model Category { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - problems Problem[] - tickets Ticket[] + 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 | | + createdAt DateTime @default(now()) + + @@index([ticketId, createdAt]) + @@map("escalation_events") +} diff --git a/specs/008-sla-escalation/checklists/requirements.md b/specs/008-sla-escalation/checklists/requirements.md index 496cc02..2d4cb8e 100644 --- a/specs/008-sla-escalation/checklists/requirements.md +++ b/specs/008-sla-escalation/checklists/requirements.md @@ -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). diff --git a/specs/008-sla-escalation/plan.md b/specs/008-sla-escalation/plan.md index 7e150c7..f64a2b3 100644 --- a/specs/008-sla-escalation/plan.md +++ b/specs/008-sla-escalation/plan.md @@ -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 | diff --git a/specs/008-sla-escalation/research.md b/specs/008-sla-escalation/research.md index e9a655b..6febafd 100644 --- a/specs/008-sla-escalation/research.md +++ b/specs/008-sla-escalation/research.md @@ -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- diff --git a/specs/008-sla-escalation/tasks.md b/specs/008-sla-escalation/tasks.md index 4934ec2..20e6601 100644 --- a/specs/008-sla-escalation/tasks.md +++ b/specs/008-sla-escalation/tasks.md @@ -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 diff --git a/src/api/routes.ts b/src/api/routes.ts index 72b3c49..a6f82d6 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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 { await app.register(healthRoutes); @@ -31,5 +34,8 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise 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 } diff --git a/src/bootstrap/queue.bootstrap.ts b/src/bootstrap/queue.bootstrap.ts index 55b0f7b..400a6ac 100644 --- a/src/bootstrap/queue.bootstrap.ts +++ b/src/bootstrap/queue.bootstrap.ts @@ -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 { registerAttachmentWorker(); registerAiSessionWorker(); + registerSlaWorker(); logger.info('Queue Manager initialized.'); } diff --git a/src/events/handlers/index.ts b/src/events/handlers/index.ts index c5605de..96b1857 100644 --- a/src/events/handlers/index.ts +++ b/src/events/handlers/index.ts @@ -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) => { + 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) => { + 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) => { + if (event.payload.newStatus !== 'RESOLVED') return; + await slaService.complete(event.payload.ticketId); + }, + ); } diff --git a/src/jobs/sla/index.ts b/src/jobs/sla/index.ts index 64a9d2b..c77e62d 100644 --- a/src/jobs/sla/index.ts +++ b/src/jobs/sla/index.ts @@ -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 } }, + ); } diff --git a/src/modules/catalog/categories/index.ts b/src/modules/catalog/categories/index.ts index 3f15c2c..4c8e6b9 100644 --- a/src/modules/catalog/categories/index.ts +++ b/src/modules/catalog/categories/index.ts @@ -1,3 +1,4 @@ export { categoriesRoutes } from './routes'; export { CategoriesService, categoriesService } from './service'; export type { CategoryDTO } from './types'; +export { categoriesRepository, CategoriesRepository } from './repository'; diff --git a/src/modules/catalog/categories/repository/categories.repository.ts b/src/modules/catalog/categories/repository/categories.repository.ts index dcd53fc..b53f8c5 100644 --- a/src/modules/catalog/categories/repository/categories.repository.ts +++ b/src/modules/catalog/categories/repository/categories.repository.ts @@ -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 { 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 { + return this.prisma.category.findUnique({ where: { id } }); + } } export const categoriesRepository = new CategoriesRepository(); diff --git a/src/modules/orchestration/assignments/engine/assignment.engine.ts b/src/modules/orchestration/assignments/engine/assignment.engine.ts index 9a5d17f..92959f2 100644 --- a/src/modules/orchestration/assignments/engine/assignment.engine.ts +++ b/src/modules/orchestration/assignments/engine/assignment.engine.ts @@ -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 { + 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(); diff --git a/src/modules/orchestration/escalation/constants/index.ts b/src/modules/orchestration/escalation/constants/index.ts new file mode 100644 index 0000000..11ee859 --- /dev/null +++ b/src/modules/orchestration/escalation/constants/index.ts @@ -0,0 +1,3 @@ +export const ESCALATION_CONSTANTS = { + MODULE_NAME: 'ORCHESTRATION_ESCALATION', +} as const; diff --git a/src/modules/orchestration/escalation/controller/escalation.controller.ts b/src/modules/orchestration/escalation/controller/escalation.controller.ts new file mode 100644 index 0000000..e49c76a --- /dev/null +++ b/src/modules/orchestration/escalation/controller/escalation.controller.ts @@ -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(); diff --git a/src/modules/orchestration/escalation/controller/index.ts b/src/modules/orchestration/escalation/controller/index.ts new file mode 100644 index 0000000..47174cc --- /dev/null +++ b/src/modules/orchestration/escalation/controller/index.ts @@ -0,0 +1 @@ +export { EscalationController, escalationController } from './escalation.controller'; diff --git a/src/modules/orchestration/escalation/engine/escalation.engine.ts b/src/modules/orchestration/escalation/engine/escalation.engine.ts index 91e2268..95920aa 100644 --- a/src/modules/orchestration/escalation/engine/escalation.engine.ts +++ b/src/modules/orchestration/escalation/engine/escalation.engine.ts @@ -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 { + return this.service.escalateManually(ticketId, targetNodeId, actor, reason); } } diff --git a/src/modules/orchestration/escalation/index.ts b/src/modules/orchestration/escalation/index.ts index 2ab5eb9..b6f411e 100644 --- a/src/modules/orchestration/escalation/index.ts +++ b/src/modules/orchestration/escalation/index.ts @@ -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'; diff --git a/src/modules/orchestration/escalation/mapper/index.ts b/src/modules/orchestration/escalation/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/escalation/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/orchestration/escalation/repository/escalation-event.repository.ts b/src/modules/orchestration/escalation/repository/escalation-event.repository.ts new file mode 100644 index 0000000..099a846 --- /dev/null +++ b/src/modules/orchestration/escalation/repository/escalation-event.repository.ts @@ -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 { + return this.prisma.escalationEvent.create({ + data: data as Prisma.EscalationEventUncheckedCreateInput, + }); + } + + async findAllForTicket(ticketId: string): Promise { + return this.prisma.escalationEvent.findMany({ + where: { ticketId }, + orderBy: { createdAt: 'asc' }, + }); + } +} + +export const escalationEventRepository = new EscalationEventRepository(); diff --git a/src/modules/orchestration/escalation/repository/escalation-policy.repository.ts b/src/modules/orchestration/escalation/repository/escalation-policy.repository.ts new file mode 100644 index 0000000..19f0925 --- /dev/null +++ b/src/modules/orchestration/escalation/repository/escalation-policy.repository.ts @@ -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 { + return this.prisma.escalationPolicy.create({ + data: data as Prisma.EscalationPolicyUncheckedCreateInput, + }); + } + + async findById(id: string): Promise { + return this.prisma.escalationPolicy.findUnique({ where: { id } }); + } + + async findAll(): Promise { + 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 { + 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(); diff --git a/src/modules/orchestration/escalation/repository/escalation-rule.repository.ts b/src/modules/orchestration/escalation/repository/escalation-rule.repository.ts new file mode 100644 index 0000000..2bcec92 --- /dev/null +++ b/src/modules/orchestration/escalation/repository/escalation-rule.repository.ts @@ -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 { + return this.prisma.escalationRule.create({ + data: data as Prisma.EscalationRuleUncheckedCreateInput, + }); + } + + async findById(id: string): Promise { + return this.prisma.escalationRule.findUnique({ where: { id } }); + } + + async update(id: string, data: UpdateEscalationRuleData): Promise { + return this.prisma.escalationRule.update({ + where: { id }, + data: data as Prisma.EscalationRuleUpdateInput, + }); + } + + async deactivate(id: string): Promise { + 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 { + return this.prisma.escalationRule.findMany({ + where: { policyId, triggerType, active: true }, + }); + } +} + +export const escalationRuleRepository = new EscalationRuleRepository(); diff --git a/src/modules/orchestration/escalation/repository/index.ts b/src/modules/orchestration/escalation/repository/index.ts new file mode 100644 index 0000000..54807a7 --- /dev/null +++ b/src/modules/orchestration/escalation/repository/index.ts @@ -0,0 +1,3 @@ +export * from './escalation-policy.repository'; +export * from './escalation-rule.repository'; +export * from './escalation-event.repository'; diff --git a/src/modules/orchestration/escalation/routes/escalation.routes.ts b/src/modules/orchestration/escalation/routes/escalation.routes.ts new file mode 100644 index 0000000..56dd0a1 --- /dev/null +++ b/src/modules/orchestration/escalation/routes/escalation.routes.ts @@ -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 { + 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), + ); +} diff --git a/src/modules/orchestration/escalation/routes/index.ts b/src/modules/orchestration/escalation/routes/index.ts new file mode 100644 index 0000000..7ef39cd --- /dev/null +++ b/src/modules/orchestration/escalation/routes/index.ts @@ -0,0 +1 @@ +export { escalationRoutes } from './escalation.routes'; diff --git a/src/modules/orchestration/escalation/schema/escalation.schema.ts b/src/modules/orchestration/escalation/schema/escalation.schema.ts new file mode 100644 index 0000000..0db04b1 --- /dev/null +++ b/src/modules/orchestration/escalation/schema/escalation.schema.ts @@ -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; +export type CreateEscalationRuleBody = z.infer; +export type UpdateEscalationRuleBody = z.infer; +export type ManualEscalationBody = z.infer; diff --git a/src/modules/orchestration/escalation/schema/index.ts b/src/modules/orchestration/escalation/schema/index.ts new file mode 100644 index 0000000..533e321 --- /dev/null +++ b/src/modules/orchestration/escalation/schema/index.ts @@ -0,0 +1 @@ +export * from './escalation.schema'; diff --git a/src/modules/orchestration/escalation/service/escalation.service.ts b/src/modules/orchestration/escalation/service/escalation.service.ts new file mode 100644 index 0000000..6bb154f --- /dev/null +++ b/src/modules/orchestration/escalation/service/escalation.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.events.findAllForTicket(ticketId); + } + + async createPolicy(data: CreateEscalationPolicyBody): Promise { + 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 { + return this.policies.findAll(); + } + + async getPolicy(id: string): Promise { + 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 { + 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 { + 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 { + return this.rules.deactivate(ruleId); + } +} + +export const escalationService = new EscalationService(); diff --git a/src/modules/orchestration/escalation/service/index.ts b/src/modules/orchestration/escalation/service/index.ts new file mode 100644 index 0000000..7fb1a43 --- /dev/null +++ b/src/modules/orchestration/escalation/service/index.ts @@ -0,0 +1 @@ +export { EscalationService, escalationService } from './escalation.service'; diff --git a/src/modules/orchestration/escalation/types/index.ts b/src/modules/orchestration/escalation/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/escalation/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts b/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts index 22aa730..ee120be 100644 --- a/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts +++ b/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts @@ -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(); diff --git a/src/modules/orchestration/routing/service/routing.service.ts b/src/modules/orchestration/routing/service/routing.service.ts index b68d537..9b5f457 100644 --- a/src/modules/orchestration/routing/service/routing.service.ts +++ b/src/modules/orchestration/routing/service/routing.service.ts @@ -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 { const session = await sessionRepository.findMostRecentByTicketId(ticketId); if (!session) return []; diff --git a/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts b/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts index 9f803c0..9895939 100644 --- a/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts +++ b/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts @@ -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 }; } } diff --git a/src/modules/orchestration/sla/constants/index.ts b/src/modules/orchestration/sla/constants/index.ts new file mode 100644 index 0000000..bba3cdf --- /dev/null +++ b/src/modules/orchestration/sla/constants/index.ts @@ -0,0 +1,3 @@ +export const SLA_CONSTANTS = { + MODULE_NAME: 'ORCHESTRATION_SLA', +} as const; diff --git a/src/modules/orchestration/sla/controller/index.ts b/src/modules/orchestration/sla/controller/index.ts new file mode 100644 index 0000000..4ccc958 --- /dev/null +++ b/src/modules/orchestration/sla/controller/index.ts @@ -0,0 +1 @@ +export { SlaController, slaController } from './sla.controller'; diff --git a/src/modules/orchestration/sla/controller/sla.controller.ts b/src/modules/orchestration/sla/controller/sla.controller.ts new file mode 100644 index 0000000..5127f6b --- /dev/null +++ b/src/modules/orchestration/sla/controller/sla.controller.ts @@ -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(); diff --git a/src/modules/orchestration/sla/engine/sla.engine.ts b/src/modules/orchestration/sla/engine/sla.engine.ts index 00ad950..eeaf910 100644 --- a/src/modules/orchestration/sla/engine/sla.engine.ts +++ b/src/modules/orchestration/sla/engine/sla.engine.ts @@ -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> { - return { status: 'NORMAL' }; + constructor(private readonly service: SlaService = slaService) {} + + async evaluateSlaTargets(ticketId: string): Promise { + return this.service.getRunByTicketId(ticketId); } } diff --git a/src/modules/orchestration/sla/index.ts b/src/modules/orchestration/sla/index.ts index 1d83c5b..489b161 100644 --- a/src/modules/orchestration/sla/index.ts +++ b/src/modules/orchestration/sla/index.ts @@ -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'; diff --git a/src/modules/orchestration/sla/mapper/index.ts b/src/modules/orchestration/sla/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/sla/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/orchestration/sla/repository/index.ts b/src/modules/orchestration/sla/repository/index.ts new file mode 100644 index 0000000..fc25f68 --- /dev/null +++ b/src/modules/orchestration/sla/repository/index.ts @@ -0,0 +1,2 @@ +export * from './sla-policy.repository'; +export * from './sla-run.repository'; diff --git a/src/modules/orchestration/sla/repository/sla-policy.repository.ts b/src/modules/orchestration/sla/repository/sla-policy.repository.ts new file mode 100644 index 0000000..4559adf --- /dev/null +++ b/src/modules/orchestration/sla/repository/sla-policy.repository.ts @@ -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 { + return this.prisma.sLAPolicy.create({ data: data as Prisma.SLAPolicyUncheckedCreateInput }); + } + + async findById(id: string): Promise { + return this.prisma.sLAPolicy.findUnique({ where: { id } }); + } + + async findAll(productId?: string): Promise { + 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 { + return this.prisma.sLAPolicy.update({ + where: { id }, + data: data as Prisma.SLAPolicyUpdateInput, + }); + } + + async deactivate(id: string): Promise { + 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 { + return this.prisma.sLAPolicy.findMany({ where: { active: true } }); + } +} + +export const slaPolicyRepository = new SlaPolicyRepository(); diff --git a/src/modules/orchestration/sla/repository/sla-run.repository.ts b/src/modules/orchestration/sla/repository/sla-run.repository.ts new file mode 100644 index 0000000..b0f2ef9 --- /dev/null +++ b/src/modules/orchestration/sla/repository/sla-run.repository.ts @@ -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 { + return this.prisma.sLARun.create({ + data: { ...data, status: 'running' } as Prisma.SLARunUncheckedCreateInput, + }); + } + + async findByTicketId(ticketId: string): Promise { + return this.prisma.sLARun.findUnique({ where: { ticketId } }); + } + + async update(id: string, data: Prisma.SLARunUpdateInput): Promise { + 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 { + 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 { + return this.prisma.sLARun.findMany({ + where: { + status: 'running', + firstResponseDueAt: { lte: now }, + firstResponseBreachedAt: null, + }, + }); + } +} + +export const slaRunRepository = new SlaRunRepository(); diff --git a/src/modules/orchestration/sla/routes/index.ts b/src/modules/orchestration/sla/routes/index.ts new file mode 100644 index 0000000..47df044 --- /dev/null +++ b/src/modules/orchestration/sla/routes/index.ts @@ -0,0 +1 @@ +export { slaRoutes } from './sla.routes'; diff --git a/src/modules/orchestration/sla/routes/sla.routes.ts b/src/modules/orchestration/sla/routes/sla.routes.ts new file mode 100644 index 0000000..9cdc556 --- /dev/null +++ b/src/modules/orchestration/sla/routes/sla.routes.ts @@ -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 { + 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)); +} diff --git a/src/modules/orchestration/sla/schema/index.ts b/src/modules/orchestration/sla/schema/index.ts new file mode 100644 index 0000000..6d89e41 --- /dev/null +++ b/src/modules/orchestration/sla/schema/index.ts @@ -0,0 +1 @@ +export * from './sla-policy.schema'; diff --git a/src/modules/orchestration/sla/schema/sla-policy.schema.ts b/src/modules/orchestration/sla/schema/sla-policy.schema.ts new file mode 100644 index 0000000..5f402bd --- /dev/null +++ b/src/modules/orchestration/sla/schema/sla-policy.schema.ts @@ -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; +export type UpdateSlaPolicyBody = z.infer; diff --git a/src/modules/orchestration/sla/service/index.ts b/src/modules/orchestration/sla/service/index.ts new file mode 100644 index 0000000..c8fb970 --- /dev/null +++ b/src/modules/orchestration/sla/service/index.ts @@ -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'; diff --git a/src/modules/orchestration/sla/service/sla-policy-resolver.service.ts b/src/modules/orchestration/sla/service/sla-policy-resolver.service.ts new file mode 100644 index 0000000..fb31c11 --- /dev/null +++ b/src/modules/orchestration/sla/service/sla-policy-resolver.service.ts @@ -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 { + 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(); diff --git a/src/modules/orchestration/sla/service/sla.service.ts b/src/modules/orchestration/sla/service/sla.service.ts new file mode 100644 index 0000000..18f1d1e --- /dev/null +++ b/src/modules/orchestration/sla/service/sla.service.ts @@ -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 { + return this.policies.create(data); + } + + async listPolicies(productId?: string): Promise { + return this.policies.findAll(productId); + } + + async getPolicy(id: string): Promise { + 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 { + await this.getPolicy(id); + return this.policies.update(id, data); + } + + async deactivatePolicy(id: string): Promise { + await this.getPolicy(id); + return this.policies.deactivate(id); + } + + // --- SLARun lifecycle --------------------------------------------------- + + async getRunByTicketId(ticketId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/orchestration/sla/types/index.ts b/src/modules/orchestration/sla/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/sla/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts b/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts new file mode 100644 index 0000000..3836c63 --- /dev/null +++ b/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts @@ -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 = { + 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; +} diff --git a/src/modules/platform/business-calendars/constants/index.ts b/src/modules/platform/business-calendars/constants/index.ts new file mode 100644 index 0000000..f4f18e6 --- /dev/null +++ b/src/modules/platform/business-calendars/constants/index.ts @@ -0,0 +1,3 @@ +export const BUSINESS_CALENDARS_CONSTANTS = { + MODULE_NAME: 'PLATFORM_BUSINESS_CALENDARS', +} as const; diff --git a/src/modules/platform/business-calendars/controller/business-calendars.controller.ts b/src/modules/platform/business-calendars/controller/business-calendars.controller.ts new file mode 100644 index 0000000..0882c84 --- /dev/null +++ b/src/modules/platform/business-calendars/controller/business-calendars.controller.ts @@ -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(); diff --git a/src/modules/platform/business-calendars/controller/index.ts b/src/modules/platform/business-calendars/controller/index.ts new file mode 100644 index 0000000..4f97e39 --- /dev/null +++ b/src/modules/platform/business-calendars/controller/index.ts @@ -0,0 +1 @@ +export { BusinessCalendarsController, businessCalendarsController } from './business-calendars.controller'; diff --git a/src/modules/platform/business-calendars/index.ts b/src/modules/platform/business-calendars/index.ts index 16084e6..1c15d18 100644 --- a/src/modules/platform/business-calendars/index.ts +++ b/src/modules/platform/business-calendars/index.ts @@ -1,11 +1,10 @@ -export const BUSINESS_CALENDARS_CONSTANTS = { - MODULE_NAME: 'PLATFORM_BUSINESS_CALENDARS', -} as const; - -export class BusinessCalendarsService { - async isWorkingHour(_date: Date): Promise { - 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'; diff --git a/src/modules/platform/business-calendars/mapper/index.ts b/src/modules/platform/business-calendars/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/platform/business-calendars/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/platform/business-calendars/repository/business-calendar.repository.ts b/src/modules/platform/business-calendars/repository/business-calendar.repository.ts new file mode 100644 index 0000000..3d74154 --- /dev/null +++ b/src/modules/platform/business-calendars/repository/business-calendar.repository.ts @@ -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 { + return this.prisma.businessCalendar.create({ data }); + } + + async findById(id: string): Promise { + 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 { + return this.prisma.businessCalendar.findMany(); + } + + async update( + id: string, + data: { + name?: string | undefined; + timezone?: string | undefined; + workingHours?: Prisma.InputJsonValue | undefined; + }, + ): Promise { + return this.prisma.businessCalendar.update({ + where: { id }, + data: data as Prisma.BusinessCalendarUpdateInput, + }); + } +} + +export const businessCalendarRepository = new BusinessCalendarRepository(); diff --git a/src/modules/platform/business-calendars/repository/holiday.repository.ts b/src/modules/platform/business-calendars/repository/holiday.repository.ts new file mode 100644 index 0000000..e9748fb --- /dev/null +++ b/src/modules/platform/business-calendars/repository/holiday.repository.ts @@ -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 { + return this.prisma.holiday.create({ data: data as Prisma.HolidayUncheckedCreateInput }); + } + + async findAllForCalendar(calendarId: string): Promise { + return this.prisma.holiday.findMany({ where: { calendarId } }); + } + + async findById(id: string): Promise { + return this.prisma.holiday.findUnique({ where: { id } }); + } + + async delete(id: string): Promise { + await this.prisma.holiday.delete({ where: { id } }); + } +} + +export const holidayRepository = new HolidayRepository(); diff --git a/src/modules/platform/business-calendars/repository/index.ts b/src/modules/platform/business-calendars/repository/index.ts new file mode 100644 index 0000000..44f4f50 --- /dev/null +++ b/src/modules/platform/business-calendars/repository/index.ts @@ -0,0 +1,2 @@ +export * from './business-calendar.repository'; +export * from './holiday.repository'; diff --git a/src/modules/platform/business-calendars/routes/business-calendars.routes.ts b/src/modules/platform/business-calendars/routes/business-calendars.routes.ts new file mode 100644 index 0000000..efc6c85 --- /dev/null +++ b/src/modules/platform/business-calendars/routes/business-calendars.routes.ts @@ -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 { + 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), + ); +} diff --git a/src/modules/platform/business-calendars/routes/index.ts b/src/modules/platform/business-calendars/routes/index.ts new file mode 100644 index 0000000..d79499e --- /dev/null +++ b/src/modules/platform/business-calendars/routes/index.ts @@ -0,0 +1 @@ +export { businessCalendarsRoutes } from './business-calendars.routes'; diff --git a/src/modules/platform/business-calendars/schema/business-calendars.schema.ts b/src/modules/platform/business-calendars/schema/business-calendars.schema.ts new file mode 100644 index 0000000..55ed8c7 --- /dev/null +++ b/src/modules/platform/business-calendars/schema/business-calendars.schema.ts @@ -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; +export type UpdateBusinessCalendarBody = z.infer; +export type CreateHolidayBody = z.infer; diff --git a/src/modules/platform/business-calendars/schema/index.ts b/src/modules/platform/business-calendars/schema/index.ts new file mode 100644 index 0000000..a677670 --- /dev/null +++ b/src/modules/platform/business-calendars/schema/index.ts @@ -0,0 +1 @@ +export * from './business-calendars.schema'; diff --git a/src/modules/platform/business-calendars/service/business-calendars.service.ts b/src/modules/platform/business-calendars/service/business-calendars.service.ts new file mode 100644 index 0000000..dd0e1fa --- /dev/null +++ b/src/modules/platform/business-calendars/service/business-calendars.service.ts @@ -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 { + return this.calendars.create(data); + } + + async getById(id: string): Promise { + const calendar = await this.calendars.findById(id); + if (!calendar) throw new NotFoundError('Business calendar not found.'); + return calendar; + } + + async list(): Promise { + return this.calendars.findAll(); + } + + async update(id: string, data: UpdateBusinessCalendarBody): Promise { + await this.getById(id); + return this.calendars.update(id, data); + } + + async addHoliday(calendarId: string, data: CreateHolidayBody): Promise { + await this.getById(calendarId); + return this.holidays.create({ calendarId, date: data.date, description: data.description }); + } + + async removeHoliday(calendarId: string, holidayId: string): Promise { + 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 { + 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 { + 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(); diff --git a/src/modules/platform/business-calendars/service/index.ts b/src/modules/platform/business-calendars/service/index.ts new file mode 100644 index 0000000..2405f96 --- /dev/null +++ b/src/modules/platform/business-calendars/service/index.ts @@ -0,0 +1 @@ +export { BusinessCalendarsService, businessCalendarsService } from './business-calendars.service'; diff --git a/src/modules/platform/business-calendars/types/index.ts b/src/modules/platform/business-calendars/types/index.ts new file mode 100644 index 0000000..7c5b12f --- /dev/null +++ b/src/modules/platform/business-calendars/types/index.ts @@ -0,0 +1 @@ +export type { WorkingWindow, WorkingHours } from '../calculators/business-hours.calculator'; diff --git a/tests/integration/sla-escalation-flow.test.ts b/tests/integration/sla-escalation-flow.test.ts new file mode 100644 index 0000000..0543869 --- /dev/null +++ b/tests/integration/sla-escalation-flow.test.ts @@ -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 { + // 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 { + 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); + }); +}); diff --git a/tests/unit/orchestration/escalation-rule-match.test.ts b/tests/unit/orchestration/escalation-rule-match.test.ts new file mode 100644 index 0000000..c2c88d9 --- /dev/null +++ b/tests/unit/orchestration/escalation-rule-match.test.ts @@ -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 }).findActiveRules).toHaveBeenCalledWith( + 'policy-1', + 'resolution_breach', + ); + expect((events as { create: ReturnType }).create).toHaveBeenCalledWith( + expect.objectContaining({ ticketId: 't1', ruleId: 'rule-1', toNodeId: 'node-1' }), + ); + expect( + (assignmentEngine as { assignToSpecificNode: ReturnType }).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 }).findActiveRules).not.toHaveBeenCalled(); + expect((events as { create: ReturnType }).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 }).create).not.toHaveBeenCalled(); + expect( + (assignmentEngine as { assignToSpecificNode: ReturnType }).assignToSpecificNode, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/orchestration/sla-breach-detection.test.ts b/tests/unit/orchestration/sla-breach-detection.test.ts new file mode 100644 index 0000000..379ec0d --- /dev/null +++ b/tests/unit/orchestration/sla-breach-detection.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tests/unit/orchestration/sla-pause-resume.test.ts b/tests/unit/orchestration/sla-pause-resume.test.ts new file mode 100644 index 0000000..0fab623 --- /dev/null +++ b/tests/unit/orchestration/sla-pause-resume.test.ts @@ -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 { + 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]; + 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]; + 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(); + }); +}); diff --git a/tests/unit/orchestration/sla-policy-match.test.ts b/tests/unit/orchestration/sla-policy-match.test.ts new file mode 100644 index 0000000..0874170 --- /dev/null +++ b/tests/unit/orchestration/sla-policy-match.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tests/unit/platform/business-calendars/calendar-walk.test.ts b/tests/unit/platform/business-calendars/calendar-walk.test.ts new file mode 100644 index 0000000..64edd93 --- /dev/null +++ b/tests/unit/platform/business-calendars/calendar-walk.test.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0c25bfe..8ab49b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', },