From 1ad9007e7984985bb34b646cf1424a84abcfdf8c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 12:16:05 +0530 Subject: [PATCH 1/5] docs: spec for SLA and escalation feature (008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 of the roadmap: SLA policy engine (most-specific-match, calendar- aware due dates), durable pause/resume and breach detection (never an in-memory timer), and a rule-driven escalation engine that re-assigns via 007's engine scoped to a specific target node. Bounded to the two SLA-derived trigger types this feature can compute a real signal for; the other eight doc 05 §6 trigger types remain valid rule configuration without a wired event source yet. First feature to give 006's HierarchyNode.slaPolicyId/escalationPolicyId fields a real target. Co-Authored-By: Claude Sonnet 5 --- .../checklists/requirements.md | 51 +++ specs/008-sla-escalation/spec.md | 308 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 specs/008-sla-escalation/checklists/requirements.md create mode 100644 specs/008-sla-escalation/spec.md diff --git a/specs/008-sla-escalation/checklists/requirements.md b/specs/008-sla-escalation/checklists/requirements.md new file mode 100644 index 0000000..496cc02 --- /dev/null +++ b/specs/008-sla-escalation/checklists/requirements.md @@ -0,0 +1,51 @@ +# Specification Quality Checklist: SLA and Escalation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Scope is Phase 8 per `docs/10-implementation-roadmap.md`: the SLA policy engine, business + calendar/holiday support, durable pause/resume, and the rule-driven escalation engine — + explicitly bounded to the two SLA-derived trigger types (`resolution_breach`/ + `first_response_breach`) this feature can compute a real signal for, out of doc 05 §6's ten; + the other eight remain valid, storable rule configuration with no event source feeding them + yet, same convention 006 already established for `HierarchyNode.assignmentStrategy`/ + `slaPolicyId` being real data before 007/008 gave them real consumers. +- This is the first feature to give 006's `HierarchyNode.slaPolicyId`/`escalationPolicyId` fields + (stored as free-text references since 006, unvalidated) a real target to resolve against. +- Escalation's re-assignment path reuses 007's `AssignmentEngine` scoped to a *specific* target + node, not 007's general unscoped resolution — a genuinely different call shape 007 doesn't + expose yet, to be added during planning. +- Constitution Principle VII is directly load-bearing here in a new way: this is the first + feature whose entire second half (SLA pause/resume/breach) is *only* correct if it survives a + process restart — 003's ticket-status concurrency and 007's round-robin concurrency both + 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. diff --git a/specs/008-sla-escalation/spec.md b/specs/008-sla-escalation/spec.md new file mode 100644 index 0000000..cd686b3 --- /dev/null +++ b/specs/008-sla-escalation/spec.md @@ -0,0 +1,308 @@ +# Feature Specification: SLA and Escalation + +**Feature Branch**: `008-sla-escalation` + +**Created**: 2026-09-03 + +**Status**: Draft + +**Input**: User description: "Phase 8 of docs/10-implementation-roadmap.md: SLA policy engine, +business calendar/holiday support, durable pause/resume via BullMQ, rule-driven escalation +engine, escalation event audit. Per docs/05-orchestration-sla-escalation.md §5-6 and +docs/06-database-schema.md 'Domain: SLA' / 'Domain: Escalation'." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - An admin defines SLA policies as configuration (Priority: P1) + +An administrator defines an SLA policy — first-response/investigation/resolution/customer- +response time limits — optionally scoped to a product, category, problem type, and/or priority, +and tied to a business calendar. Nothing about SLA thresholds is hardcoded anywhere in the +system. + +**Why this priority**: Every later capability in this feature reads a policy that has to exist +first. + +**Independent Test**: Create an SLA policy with a resolution limit; confirm it's retrievable and +its fields are stored exactly as given. + +**Acceptance Scenarios**: + +1. **Given** an admin creates an SLA policy with a resolution time limit, **When** it's saved, + **Then** it's retrievable with every field exactly as given, `active: true` by default. +2. **Given** a policy scoped to a product/category/problem type/priority, **When** two policies + could both apply to the same ticket, **Then** the more specific one is preferred — same + most-specific-match convention this system already uses for confidence policy (005) and + capability scope (006). +3. **Given** an admin creates a business calendar with working hours and holidays, **When** it's + referenced by a policy, **Then** due-date calculations for tickets under that policy use it. + +--- + +### User Story 2 - An SLA run starts automatically when a ticket is assigned, with calendar-aware due dates (Priority: P1) + +When orchestration (007) assigns a ticket, an SLA run starts for it automatically, with due dates +computed against the applicable policy's business calendar — never a naive `createdAt + N hours` +that ignores weekends, holidays, or working hours. + +**Why this priority**: Nothing else in this feature — breach detection, pause/resume, escalation +— has anything to act on until a real, calendar-correct due date exists. + +**Independent Test**: Assign a ticket under a policy with a resolution limit and a calendar whose +working hours exclude a weekend; confirm the computed due date skips the excluded time rather +than counting straight through it. + +**Acceptance Scenarios**: + +1. **Given** a ticket is assigned (007), **When** an applicable SLA policy resolves for it (User + Story 1's most-specific-match rule), **Then** an SLA run starts with a resolution due date + computed against that policy's calendar. +2. **Given** no policy matches, **When** a ticket is assigned, **Then** no SLA run is created + rather than applying an arbitrary default — this feature does not invent a policy that was + never configured. +3. **Given** a calendar with defined working hours and a holiday, **When** a due date is + computed, **Then** time outside working hours and on holidays is excluded from the countdown. + +--- + +### User Story 3 - SLA pause and resume are durable, not in-memory (Priority: P1) + +When a ticket's status indicates it's waiting on the customer, its SLA run pauses — the clock +stops counting against the agent. When it resumes, the clock continues from where it left off, +never from zero and never having kept counting while paused. This state survives a process +restart. + +**Why this priority**: Constitution Principle VII names this exact scenario — an SLA that keeps +counting during a customer-caused delay, or that resets on a restart, actively misrepresents +whether a real commitment was honored. + +**Independent Test**: Start an SLA run, pause it, wait, resume it; confirm the resulting due date +reflects the paused duration being excluded, not counted twice. + +**Acceptance Scenarios**: + +1. **Given** a running SLA, **When** the ticket transitions to a waiting-for-customer state, + **Then** the run's status becomes `paused` and its due date stops approaching. +2. **Given** a paused SLA, **When** the ticket transitions back to in-progress, **Then** the run's + status becomes `running` again and the remaining time is preserved from the pause point. +3. **Given** a paused or running SLA run, **When** the process restarts, **Then** its state is + unchanged on restart — pause/resume state is never held only in memory. + +--- + +### User Story 4 - A breach is detected durably, never silently missed (Priority: P2) + +When a running SLA's due date passes without the corresponding milestone happening, the run is +marked breached — detected by a durable background job, not a timer that only fires if the +process happens to still be running at the right moment. + +**Why this priority**: Depends on User Stories 2-3 (a real due date, correctly paused/resumed) +existing first. A breach that's never detected is worse than no SLA at all — it's a false sense +of a commitment being tracked. + +**Independent Test**: Start an SLA run with a very short resolution limit; confirm it's marked +`breached` once the due date passes, even simulating the checking job running in a separate +process invocation from the one that started the run. + +**Acceptance Scenarios**: + +1. **Given** a running SLA run whose due date has passed, **When** the breach-detection job next + runs, **Then** the run's status becomes `breached` and its `breachedAt` timestamp is recorded. +2. **Given** an SLA run that completes (its milestone actually happens) before its due date, + **When** breach detection later runs, **Then** it is not marked breached — completion is + checked against the actual event, not assumed from elapsed time alone. +3. **Given** a paused SLA run, **When** breach detection runs while it's paused, **Then** it is + never marked breached — a paused clock cannot breach. + +--- + +### User Story 5 - A breach automatically triggers rule-driven escalation (Priority: P2) + +An administrator defines escalation rules — trigger condition, target support-hierarchy node, who +to notify. When an SLA breach (or another configured trigger) occurs, the matching rule fires +automatically: the ticket moves toward the rule's target node and orchestration (007) +re-assigns it there, and an escalation event is durably recorded. This is never a hardcoded +`if L1 then L2` — it's evaluated against configured rules. + +**Why this priority**: Depends on User Story 4 (a real breach signal to trigger on). This is the +other half of "durable SLA enforcement actually matters" — detecting a breach that nobody acts on +isn't meaningfully different from not detecting it. + +**Independent Test**: Configure an escalation rule for `resolution_breach` targeting a specific +hierarchy node; breach an SLA run; confirm an escalation event is recorded, the ticket is +re-assigned via 007 scoped to that specific node, and the rule's configured notification target +is recorded (not necessarily delivered — see Assumptions). + +**Acceptance Scenarios**: + +1. **Given** an active escalation rule for `resolution_breach` scoped to a target node, **When** + a matching SLA run breaches, **Then** an `EscalationEvent` is recorded with the rule, the + reason, and `triggeredBy: system`. +2. **Given** an escalation event fires, **When** it completes, **Then** 007's assignment engine + re-runs scoped specifically to the rule's target node — not a fresh, unscoped resolution — + and the ticket's assignment history (007) reflects the new assignment with `strategy` reused + from whatever the target node itself configures. +3. **Given** no escalation rule matches a breach, **When** the breach is detected, **Then** the + SLA run is still marked breached (User Story 4) — the absence of a matching rule doesn't + suppress breach detection, it only means no automatic escalation follows. +4. **Given** multiple active rules could match the same trigger, **When** more than one does, + **Then** every matching rule fires its own escalation event — this feature does not pick just + one. + +--- + +### User Story 6 - A human can manually trigger escalation, audited the same way (Priority: P3) + +An agent or admin can explicitly escalate a ticket to a specific target node, for a stated +reason, without waiting for an automatic trigger — recorded through the same `EscalationEvent` +audit trail as an automatic one. + +**Why this priority**: Depends on User Story 5's event/re-assignment mechanism already existing. +Automatic triggers won't cover every real reason to escalate (doc 05 §10 names several this +feature doesn't compute automatically — see Assumptions); a human needs an explicit path that +still produces the same durable record. + +**Independent Test**: Manually escalate a ticket to a named target node with a reason; confirm an +`EscalationEvent` with `triggeredBy` set to the calling actor is recorded and the ticket is +re-assigned via 007 to that node. + +**Acceptance Scenarios**: + +1. **Given** an admin manually escalates a ticket to a target node, **When** it completes, + **Then** an `EscalationEvent` is recorded with `triggeredBy` set to the actor (never + `system`), and 007 re-assigns the ticket scoped to that node. +2. **Given** a manual escalation targets a node that doesn't exist, **When** it's attempted, + **Then** it's rejected — never a dangling escalation event pointing nowhere. + +--- + +### Edge Cases + +- What happens if a ticket has no assigned agent yet when its SLA would otherwise start? An SLA + run only starts on a successful assignment (User Story 2) — an unassigned ticket (007's "no + eligible agent" outcome) has no SLA run to track, consistent with there being no one yet to + hold to a commitment. +- What happens if a business calendar has no working hours configured at all for a given day? + That day contributes zero time toward any due-date countdown — an unconfigured day is never + silently treated as 24 available hours. +- What happens if an SLA policy's calendar is deleted or unreferenced after runs already exist + against it? Out of scope — this feature does not implement calendar deletion, only creation and + the `active` state every other configuration entity in this system already uses. +- What happens when a ticket is reopened after being resolved, with a completed SLA run already + on record? Out of scope for this feature to define a new run automatically — reopening (a + future problem-resolution-phase concept) may need its own SLA-run-restart decision; this + feature's SLA runs are 1:1 with a ticket's first, straightforward assignment→resolution + lifecycle. +- What happens if two escalation rules would move a ticket to the same target node at once (a + race between an automatic breach and a simultaneous manual escalation)? Both `EscalationEvent` + rows are recorded (never lost — durable audit is unconditional); 007's own re-assignment + path already handles a ticket being assigned twice in quick succession correctly (it's the same + version-row-per-period `Assignment` mechanism 007 already built and tested for concurrent + writes), so no new concurrency mechanism is needed here. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST let an admin create an SLA policy with first-response/ + investigation/resolution/customer-response time limits, optionally scoped to product/category/ + problem type/priority, referencing a business calendar. +- **FR-002**: When multiple SLA policies could apply to the same ticket context, the system MUST + prefer the most specific match — never an arbitrary or first-found selection. +- **FR-003**: The system MUST let an admin create a business calendar with working hours and + holidays. +- **FR-004**: Due-date computation MUST exclude time outside a calendar's working hours and on + its holidays — MUST NOT compute a due date as a naive elapsed-time addition that ignores the + calendar. +- **FR-005**: An SLA run MUST start automatically when a ticket is successfully assigned (007), + using the most-specific-matching policy (FR-002); when no policy matches, no run is created. +- **FR-006**: An SLA run's status MUST be one of `running`, `paused`, `warning`, `breached`, or + `completed`, matching doc 06's defined set. +- **FR-007**: When a ticket transitions to a waiting-for-customer state, its SLA run MUST pause; + when it transitions back, the run MUST resume with its remaining time preserved — never reset + to the full original duration and never having continued counting while paused. +- **FR-008**: SLA pause/resume state and due dates MUST be durable — recomputable and correct + after a process restart, never dependent on an in-memory timer (Constitution Principle VII). +- **FR-009**: A running SLA run whose due date has passed MUST be detected and marked `breached` + by a durable background job — never missed because the triggering process wasn't running at + the exact due moment. +- **FR-010**: An SLA run that completes its milestone before its due date MUST NOT be marked + breached, regardless of what a naive elapsed-time check alone would suggest. +- **FR-011**: A paused SLA run MUST NOT be marked breached while paused. +- **FR-012**: The system MUST let an admin create an escalation rule — trigger type, condition, + target hierarchy node, and who to notify — scoped to a policy, matching doc 06's + `EscalationRule` shape. +- **FR-013**: An SLA breach (FR-009) MUST be evaluated against every active escalation rule + configured for `resolution_breach` (and, where applicable, the other SLA-derived trigger + types this feature computes — see Assumptions); every matching rule MUST fire its own + escalation event — never just the first match. +- **FR-014**: Firing an escalation rule MUST record a durable `EscalationEvent` (rule, reason, + `triggeredBy`, timestamp) and MUST re-run 007's assignment engine scoped specifically to the + rule's `targetNodeId` — never a fresh, unscoped resolution that could land elsewhere. +- **FR-015**: A breach with no matching escalation rule MUST still be recorded as breached + (FR-009) — the absence of a rule never suppresses breach detection itself. +- **FR-016**: The system MUST let an admin or agent manually escalate a ticket to a specific, + existing target node with a reason, recorded through the same `EscalationEvent` mechanism as an + automatic escalation, with `triggeredBy` set to the calling actor. +- **FR-017**: A manual escalation targeting a nonexistent hierarchy node MUST be rejected. + +### Key Entities + +- **SLA Policy**: A configured set of time limits (first response/investigation/resolution/ + customer response) scoped to product/category/problem type/priority, referencing a business + calendar — never a hardcoded threshold. +- **SLA Run**: The durable, per-ticket tracking of one policy's due dates and status against a + real ticket, survivable across a process restart. +- **Business Calendar / Holiday**: Working hours and excluded dates a due-date calculation + respects — the mechanism that keeps SLA math honest. +- **Escalation Policy / Rule**: Configured trigger conditions and target hierarchy nodes — never + a hardcoded escalation ladder. +- **Escalation Event**: The durable, audited record of every escalation, automatic or manual, + including which rule (if any) fired it and who/what triggered it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of due-date calculations under a policy with a configured calendar exclude + non-working time — verified against a calendar with at least one full excluded day. +- **SC-002**: 100% of SLA runs correctly reflect pause/resume across a simulated process restart + — the paused duration is never double-counted and never dropped. +- **SC-003**: 100% of SLA runs whose due date has passed are marked `breached` within one + breach-detection job cycle, even when the detecting process is a different invocation than the + one that started the run. +- **SC-004**: 100% of SLA breaches with a matching active escalation rule produce both an + `EscalationEvent` and a re-assignment scoped to the rule's target node. +- **SC-005**: 100% of manual escalations targeting a nonexistent node are rejected, never + producing a dangling event. + +## Assumptions + +- **This feature does not build a notification-delivery mechanism** — an escalation rule's + `notify` field (doc 06) is stored and returned as configured, but this feature does not send an + email/Slack/webhook notification; `platform/notifications` (doc 07) remains an untouched module + group, same convention as every prior feature leaving an adjacent, not-yet-built module alone. +- **Only `resolution_breach` and `first_response_breach` are wired to a real trigger signal in + this feature** — doc 05 §6 lists ten trigger types; the other eight (inactivity, priority + increase, customer escalation request, repeated reopen, manual [built as its own user story, + User Story 6, not a rule trigger], product defect, dependency timeout, critical incident) + require signals this codebase doesn't compute yet (idle-time tracking, reopen counting, + explicit defect/incident flagging) — `EscalationRule.triggerType` accepts any of doc 06's + values as configuration data, but only the two SLA-breach types are ever actually evaluated by + this feature. This mirrors 006's `assignmentStrategy`/`slaPolicyId` fields being stored as real + data before this feature gave them a real consumer. +- **Investigation SLA and customer-response SLA are stored as policy fields (FR-001) but this + feature only computes/tracks the resolution and first-response due dates on `SLARun`** — doc 06's + `SLARun` itself only models `firstResponseDueAt`/`resolutionDueAt` explicitly; investigation and + customer-response timers would need their own due-date fields doc 06 doesn't define, which is a + refinement left for whichever future phase actually needs to enforce them (matching this + system's "refine the conceptual schema when a feature needs the refinement, not speculatively" + convention). +- **SLA run creation happens once, on a ticket's first successful assignment** — reopening, + multiple resolution cycles, and re-running an SLA clock for a reassigned-after-resolution + ticket are out of scope (Edge Cases) — that's future problem-resolution-phase territory. +- **Breach detection runs on a durable, periodically-scheduled BullMQ job** (a repeatable job, + not a per-run delayed job scheduled at creation time) — checking every active `running` run's + due date against the current time on each tick, rather than scheduling one delayed job per SLA + run. This is a deliberate simplicity/robustness tradeoff, not an aspiration to replace later — + see research.md for the full reasoning. From 199bd4eb4e5dab14cce0c2541c4e7107713f5afb Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 12:23:35 +0530 Subject: [PATCH 2/5] plan: design for SLA and escalation feature (008) Phase 0 research resolves the business-calendar working-hours algorithm (day-by-day walk via luxon, the first date/timezone dependency in this codebase), the workingHours JSON shape, most-specific SLA-policy match (reusing 005/006's resolution pattern), durable pause/resume (absolute due-date shift, no in-memory state), and a repeatable-job breach-detection design over per-run delayed jobs. Phase 1 adds data-model.md (one additive refinement beyond doc06: SLARun.firstResponseBreachedAt), the admin/read contract, and six quickstart scenarios including a genuine process-restart boundary test for Constitution Principle VII. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 9 + package.json | 2 + .../contracts/sla-escalation-contract.md | 77 ++++++++ specs/008-sla-escalation/data-model.md | 127 +++++++++++++ specs/008-sla-escalation/plan.md | 151 +++++++++++++++ specs/008-sla-escalation/quickstart.md | 77 ++++++++ specs/008-sla-escalation/research.md | 175 ++++++++++++++++++ 7 files changed, 618 insertions(+) create mode 100644 specs/008-sla-escalation/contracts/sla-escalation-contract.md create mode 100644 specs/008-sla-escalation/data-model.md create mode 100644 specs/008-sla-escalation/plan.md create mode 100644 specs/008-sla-escalation/quickstart.md create mode 100644 specs/008-sla-escalation/research.md diff --git a/package-lock.json b/package-lock.json index 22df418..8f7bebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,12 +24,14 @@ "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", "ioredis": "^5.3.2", + "luxon": "^3.7.2", "pino": "^8.20.0", "pino-pretty": "^11.0.0", "prom-client": "^15.1.1", "zod": "^3.22.4" }, "devDependencies": { + "@types/luxon": "^3.7.5", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", @@ -1973,6 +1975,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", + "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", diff --git a/package.json b/package.json index a969315..54696a4 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,14 @@ "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", "ioredis": "^5.3.2", + "luxon": "^3.7.2", "pino": "^8.20.0", "pino-pretty": "^11.0.0", "prom-client": "^15.1.1", "zod": "^3.22.4" }, "devDependencies": { + "@types/luxon": "^3.7.5", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", diff --git a/specs/008-sla-escalation/contracts/sla-escalation-contract.md b/specs/008-sla-escalation/contracts/sla-escalation-contract.md new file mode 100644 index 0000000..0e84548 --- /dev/null +++ b/specs/008-sla-escalation/contracts/sla-escalation-contract.md @@ -0,0 +1,77 @@ +# Contract: SLA and Escalation + +Every admin CRUD/manual-escalation route below is gated by `fastify.authenticate` (research.md — +known limitation inherited from 002/003/004/005/006/007). SLA-run creation, pause/resume, and +breach detection have no public trigger endpoint — they run automatically off the domain event +bus and the breach-detection BullMQ job (research.md), matching 007's "orchestration has no +manual trigger endpoint" precedent. + +## SLA Policy admin + +- `POST /admin/sla-policies` — body `{ name, productId?, categoryId?, problemTypeId?, priority?, + firstResponseMinutes, investigationMinutes?, resolutionMinutes, customerResponseMinutes?, + businessCalendarId? }`. `404` if `productId`/`categoryId`/`businessCalendarId` is given but + doesn't exist. +- `GET /admin/sla-policies` — list, optionally filtered by `productId`. +- `GET /admin/sla-policies/:id` — `404` if not found. +- `PATCH /admin/sla-policies/:id` — partial update, same existence checks as create. +- `DELETE /admin/sla-policies/:id` — soft delete (`active: false`), never a hard delete (matches + 005/006 precedent for policy-shaped config the system may still reference). + +## Business Calendar admin + +- `POST /admin/business-calendars` — body `{ name, timezone, workingHours }`. `400` if + `timezone` isn't a valid IANA zone name, or if any `workingHours` entry's `start`/`end` isn't a + valid `HH:mm` pair with `start < end`. +- `GET /admin/business-calendars` / `GET /admin/business-calendars/:id` — `404` if not found. +- `PATCH /admin/business-calendars/:id` — same validation as create. +- `POST /admin/business-calendars/:id/holidays` — body `{ date, description? }`. +- `DELETE /admin/business-calendars/:id/holidays/:holidayId`. + +## Escalation Policy / Rule admin + +- `POST /admin/escalation-policies` — body `{ name, productId? }`. `404` if `productId` given + but doesn't exist. +- `GET /admin/escalation-policies` / `GET /admin/escalation-policies/:id`. +- `POST /admin/escalation-policies/:id/rules` — body `{ triggerType, condition, targetNodeId, + notify, active? }`. `triggerType` validated against doc 05 §6's full 10-value set (research.md + — only 2 are ever evaluated, all 10 are valid config). `404` if `targetNodeId` doesn't + reference an existing `HierarchyNode` (FR-012). +- `PATCH /admin/escalation-policies/:id/rules/:ruleId` — same validation as create. +- `DELETE /admin/escalation-policies/:id/rules/:ruleId` — soft delete (`active: false`). + +## SLA run reads + +- `GET /tickets/:ticketId/sla-run` — the current `SLARun` for the ticket, or `404` if none was + ever created (e.g. the ticket was never assigned, or no policy matched at assignment time). + +## Manual escalation + +- `POST /tickets/:ticketId/escalate` — body `{ targetNodeId, reason }`. `404` if `ticketId` or + `targetNodeId` doesn't exist (FR-017). Records an `EscalationEvent` with `triggeredBy` set to + the calling actor and re-assigns via the same scoped-assignment path a rule-fired escalation + uses (research.md). + +## Guarantees (callable contract) + +1. **An `SLARun` is created the moment a ticket receives its first successful assignment (007), + if and only if an active `SLAPolicy` matches the ticket's context** — never for an unassigned + ticket, never inventing a default policy when none matches (FR-005, US2). +2. **`firstResponseDueAt`/`resolutionDueAt` are always computed by walking the resolved policy's + business calendar**, excluding non-working hours, weekends, and holidays — never a naive + `createdAt + N hours` addition (FR-004, SC-001). +3. **A ticket entering `WAITING_FOR_CUSTOMER` pauses its running `SLARun`; leaving it resumes + with the remaining time preserved exactly** — the paused duration is neither double-counted + nor dropped, and this holds even if the process restarts while paused (FR-007/FR-008, SC-002). +4. **A breach is detected within one breach-detection job cycle of its due date passing**, even + if the process wasn't running at the exact due instant — never silently missed (FR-009, + SC-003). +5. **A run that completes before its due date is never marked breached; a paused run is never + marked breached** (FR-010/FR-011). +6. **Every `resolution_breach` or `first_response_breach` detection evaluates every active + `EscalationRule` matching that trigger type under the ticket's resolved `EscalationPolicy`, + firing one `EscalationEvent` (and one scoped re-assignment) per matching rule** — a breach + with no matching rule is still recorded as breached, with no `EscalationEvent` (FR-013/FR-014/ + FR-015, SC-004). +7. **A manual escalation to a nonexistent `targetNodeId` always returns `404` and creates neither + an `EscalationEvent` nor a reassignment** (FR-017, SC-005). diff --git a/specs/008-sla-escalation/data-model.md b/specs/008-sla-escalation/data-model.md new file mode 100644 index 0000000..e7444be --- /dev/null +++ b/specs/008-sla-escalation/data-model.md @@ -0,0 +1,127 @@ +# Data Model: SLA and Escalation + +Field shapes below match `docs/06-database-schema.md` "Domain: SLA" / "Domain: Escalation" +exactly, with two additive refinements called out explicitly (both purely additive — nothing in +doc 06's shape is removed or narrowed). + +## SLAPolicy + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `name` | `String` | | +| `productId` | `String?` | wildcard when `null` — FK to `Product.id` | +| `categoryId` | `String?` | wildcard when `null` — FK to `Category.id` | +| `problemTypeId` | `String?` | wildcard when `null` — free-text reference, no `ProblemType` table exists in this codebase (problem taxonomy lives on `Problem` directly, per 004/005); stored and matched as opaque text | +| `priority` | `String?` | wildcard when `null` — free-text, matches `Ticket.priority` | +| `firstResponseMinutes` | `Int` | required — every policy must define a first-response target | +| `investigationMinutes` | `Int?` | stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) | +| `resolutionMinutes` | `Int` | required | +| `customerResponseMinutes` | `Int?` | stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) | +| `businessCalendarId` | `String?` | FK to `BusinessCalendar.id`; `null` means "24/7, no exclusions" (an explicit policy choice, not a missing-calendar error) | +| `active` | `Boolean @default(true)` | inactive policies are excluded from resolution | +| `createdAt` / `updatedAt` | `DateTime` | `updatedAt` used as the resolution tie-break (research.md) | + +**Validation** (Zod, at the schema layer): `firstResponseMinutes > 0`, `resolutionMinutes > 0`, +`investigationMinutes`/`customerResponseMinutes` positive when present; `productId`/`categoryId`/ +`businessCalendarId` must reference an existing row when provided (repository-level existence +check, same convention as every prior feature's FK-shaped free-form input). + +**Resolution** (`findApplicablePolicy(ticket)`): among active policies where each set scope field +equals the ticket's corresponding value and each unset field is a wildcard, return the one with +the fewest wildcards; tie-break by latest `updatedAt`. No match → no `SLARun` is created (FR-005). + +## SLARun + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `ticketId` | `String @unique` | one run per ticket — no reopen-cycle support (spec.md Assumptions) | +| `policyId` | `String` | FK to `SLAPolicy.id`, the policy resolved at creation time | +| `firstResponseDueAt` | `DateTime?` | computed via the calendar walk from `assignedAt`; `null` when the policy has no `firstResponseMinutes`... (always present per policy validation, so effectively always set) | +| `resolutionDueAt` | `DateTime?` | computed the same way from `resolutionMinutes` | +| `status` | `String` | `running \| paused \| warning \| breached \| completed` — matches doc 06 exactly | +| `pausedAt` | `DateTime?` | set when `status` transitions to `paused`; cleared on resume | +| `resumedAt` | `DateTime?` | last resume timestamp, informational (audit convenience, mirrors `AssignmentHistory`'s always-append style) | +| `breachedAt` | `DateTime?` | set once, the first time `resolutionDueAt` is detected passed while `running` | +| `completedAt` | `DateTime?` | set when the ticket reaches a resolved/closed status; a completed run is never later marked breached (FR-011) | +| **`firstResponseBreachedAt`** | `DateTime?` | **additive refinement, not in doc 06's literal listing** — records the first-response breach separately from `status`/`breachedAt`, which this feature reserves for the resolution timer; doubles as the idempotency guard for the breach-detection job (research.md) | + +**Status transitions** (enforced in the service layer, not a DB constraint — same convention as +`Ticket.status`'s 12-state machine in 003): `running → paused` (on ticket entering +`WAITING_FOR_CUSTOMER`) → `running` (on leaving it, due dates shifted forward by the pause +duration) → `breached` (resolution due date passed while running) → `completed` (ticket resolved/ +closed, from any of `running`/`paused`/`breached`). `warning` is reserved by doc 06's enum for a +future near-breach signal; no code path in this feature sets it (documented, not implemented — +same discipline as the 8 inert `EscalationRule.triggerType` values). + +## BusinessCalendar + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `name` | `String` | | +| `timezone` | `String` | IANA zone name (e.g. `"America/New_York"`), validated against `Intl.supportedValuesOf('timeZone')` at the schema layer | +| `workingHours` | `Json` | shape: `{ mon?: {start: "HH:mm", end: "HH:mm"}, tue?: ..., wed?: ..., thu?: ..., fri?: ..., sat?: ..., sun?: ... }` — a missing key means zero working hours that weekday (research.md) | +| `holidays` | `Holiday[]` | | + +## Holiday + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `calendarId` | `String` | FK to `BusinessCalendar.id` | +| `date` | `DateTime` | compared by calendar date only (year/month/day in the calendar's own timezone), not by exact instant | +| `description` | `String?` | | + +## EscalationPolicy + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `name` | `String` | | +| `productId` | `String?` | wildcard (global) when `null` | +| `active` | `Boolean @default(true)` | | +| `rules` | `EscalationRule[]` | | + +## EscalationRule + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `policyId` | `String` | FK to `EscalationPolicy.id` | +| `triggerType` | `String` | one of doc 05 §6's 10 values; schema accepts all 10, only `resolution_breach`/`first_response_breach` are ever evaluated (research.md) | +| `condition` | `Json` | stored, not evaluated, by this feature (research.md) | +| `targetNodeId` | `String` | FK to `HierarchyNode.id`, validated to exist at creation time (FR-017's rejection rule applies identically here) | +| `notify` | `Json` | who/how to notify — stored and returned only; no delivery mechanism exists (spec.md Assumptions, `platform/notifications` untouched) | +| `active` | `Boolean @default(true)` | | + +## EscalationEvent + +| Field | Type | Notes | +|---|---|---| +| `id` | `String @id @default(cuid())` | | +| `ticketId` | `String` | FK to `Ticket.id` | +| `ruleId` | `String?` | `null` for a manual escalation or a breach with no matching rule | +| `fromNodeId` | `String?` | the node the ticket was assigned to immediately before this event, if any | +| `toNodeId` | `String?` | the rule's `targetNodeId` (or the manually-specified node); `null` when no rule matched | +| `reason` | `String` | free text — for a rule firing, a generated description (e.g. `"resolution SLA breached"`); for manual escalation, the caller-supplied reason | +| `triggeredBy` | `String` | `system \| \| ` — never a bare `"customer"` literal in this feature's own write paths (doc 06 lists it as a valid value for a future customer-initiated trigger type, not one this feature fires) | +| `createdAt` | `DateTime @default(now())` | | + +## Relations added to existing models + +- `Ticket.slaRun SLARun?` (inverse of `SLARun.ticketId @unique`) +- `Ticket.escalationEvents EscalationEvent[]` +- `Product.slaPolicies SLAPolicy[]`, `Product.escalationPolicies EscalationPolicy[]` +- `Category.slaPolicies SLAPolicy[]` +- `HierarchyNode.escalationRules EscalationRule[]` (inverse of `targetNodeId`) + +## Out of scope for this data model (per spec.md Assumptions) + +- No `investigationDueAt`/`customerResponseDueAt` fields — doc 06's `SLARun` doesn't define them, + and nothing in spec.md's acceptance scenarios exercises them; `investigationMinutes`/ + `customerResponseMinutes` remain stored-but-unused on `SLAPolicy`, same as doc 06 itself defines. +- No FK tightening of `HierarchyNode.slaPolicyId`/`escalationPolicyId` (still free-text, per 006) — + SLA policy resolution in this feature is scope-based (product/category/problemType/priority), + not looked up through those two fields; they remain unvalidated free text, unchanged from 006. diff --git a/specs/008-sla-escalation/plan.md b/specs/008-sla-escalation/plan.md new file mode 100644 index 0000000..393c202 --- /dev/null +++ b/specs/008-sla-escalation/plan.md @@ -0,0 +1,151 @@ +# Implementation Plan: SLA and Escalation + +**Branch**: `008-sla-escalation` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/008-sla-escalation/spec.md` + +## Summary + +Populate the existing `platform/business-calendars`, `orchestration/sla`, and +`orchestration/escalation` stub directories (today: `isWorkingHour` hardcoded `true`, a +`SlaDueDateCalculator` doing naive `createdAt + hours` addition, an `EscalationEngine` that +always returns `{ escalated: false }`) with the real engine: `business-calendars` walks a +`BusinessCalendar`'s `workingHours`/`Holiday` records via `luxon` to compute calendar-aware +durations; `sla` resolves the most-specific matching `SLAPolicy` on a ticket's first successful +007 assignment, computes `firstResponseDueAt`/`resolutionDueAt` through the calendar walk, +durably pauses/resumes on `WAITING_FOR_CUSTOMER` transitions (a `TICKET_UPDATED` domain-event +subscriber), and detects breaches via a repeatable BullMQ job; `escalation` resolves the +applicable `EscalationPolicy`, fires an `EscalationEvent` per matching active `EscalationRule` on +a breach (or on a manual request), and re-assigns through a new, specifically-scoped entry point +added to 007's `AssignmentEngine`. + +## Technical Context + +**Language/Version**: TypeScript 5.4 / Node.js 20+. + +**Primary Dependencies**: Prisma (new models), Zod, BullMQ (already a dependency — new +repeatable job, same queue infrastructure as 003's attachment scan and 005's AI session queues), +`luxon` (**new** — the first date/timezone library in this codebase; research.md). + +**Storage**: PostgreSQL via Prisma (new `SLAPolicy`, `SLARun`, `BusinessCalendar`, `Holiday`, +`EscalationPolicy`, `EscalationRule`, `EscalationEvent` models). No new infrastructure — reuses +`src/infrastructure/queue` for the breach-detection job, same as every prior BullMQ consumer. + +**Testing**: Vitest — unit tests for the calendar-walk algorithm (weekend/holiday exclusion, +partial-day clipping, timezone correctness), the most-specific SLA-policy match, and pause/resume +arithmetic; integration tests for the full assignment→SLA-run→pause/resume→breach→escalation +flow against real Postgres/Redis, including one test that rebuilds `buildApp()` mid-test to +verify pause/resume state survives a genuine process-restart boundary (Constitution Principle +VII, quickstart Scenario 3) — the first feature in this codebase whose correctness depends on +that guarantee specifically, not just within-process concurrency safety. + +**Target Platform**: Same Fastify modular monolith. Populates existing module directories: +`src/modules/platform/business-calendars/`, `src/modules/orchestration/{sla,escalation}/`. Adds +one new BullMQ worker registration alongside the existing ones in `src/infrastructure/queue`. + +**Project Type**: Backend service — single project. + +**Performance Goals**: The breach-detection job must complete a full scan-and-mark pass in +well under its own tick interval even as `SLARun` rows accumulate — indexed on +`(status, resolutionDueAt)` so the query stays a targeted range scan, not a table scan. Not +otherwise performance-sensitive. + +**Constraints**: MUST NOT compute due dates naively (FR-004); MUST create an `SLARun` only on a +successful assignment with a matching policy (FR-005); MUST survive a process restart for +pause/resume and breach detection (FR-007/FR-008/FR-009, Constitution Principle VII); MUST never +mark a completed-in-time or paused run breached (FR-010/FR-011); MUST re-assign scoped to the +rule's exact `targetNodeId`, not a fresh unscoped resolution (FR-014). + +**Scale/Scope**: Three populated modules, one new dependency, three admin CRUD surfaces (SLA +policies, business calendars, escalation policies/rules), one manual-escalation endpoint, one +new BullMQ repeatable job, one new method on 007's `AssignmentEngine`. Explicitly excludes: +notification delivery, 8 of doc 05's 10 escalation trigger types, investigation/customer-response +timers, SLA restart on ticket reopen (see spec.md Assumptions). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle / Section | Check | Result | +|---|---|---| +| 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 | +| 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 | +| VII. Concurrency-Safe, Durable Job Handling | This principle's "state must survive a process restart" clause is directly load-bearing here for the first time as the primary correctness requirement (not just a concurrent-request race) — pause/resume and breach detection are both pure-DB-state-plus-polling-job, no in-memory timer anywhere (research.md, quickstart Scenario 3). | PASS | +| VIII. Problem and Ticket Are Separate, Related Entities | SLA/escalation reference `Ticket`, not `Problem` — doesn't touch the distinction. | PASS — N/A | +| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure, plus the one new `luxon` dependency (justified in research.md — no timezone-correct alternative already exists in this codebase). | PASS | + +No violations requiring Complexity Tracking justification. + +## Post-Design Constitution Re-check + +All gates above remain PASS after Phase 1 design. Worth calling out against Principle VII +explicitly: pause/resume shifts a single absolute `DateTime` column and breach detection is a +plain polling query — by design there is no code path in this feature that could even *appear* +to depend on in-memory state surviving a restart, which is what makes the restart-boundary +integration test (quickstart Scenario 3) a meaningful verification rather than a formality. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-sla-escalation/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (/speckit-tasks — not created here) +``` + +### Source Code (repository root) + +```text +supporthub-api/ +├── package.json # MODIFIED — add luxon, @types/luxon +├── prisma/ +│ └── schema.prisma # MODIFIED — add SLAPolicy, SLARun, +│ BusinessCalendar, Holiday, EscalationPolicy, +│ EscalationRule, EscalationEvent +├── src/ +│ ├── infrastructure/ +│ │ └── queue/ # MODIFIED — register the breach-detection +│ │ repeatable job alongside existing workers +│ └── modules/ +│ ├── platform/ +│ │ └── business-calendars/ # REPLACED stub — full standard shape + +│ │ ├── controller/ routes/ schema/ calculators/ for the day-walk algorithm +│ │ │ repository/ service/ types/ +│ │ │ mapper/ constants/ index.ts +│ │ └── calculators/ +│ └── orchestration/ +│ ├── sla/ # REPLACED stub — full standard shape, keeps +│ │ ├── controller/ routes/ schema/ its existing calculators/ dir (due-date +│ │ │ repository/ service/ types/ calculator replaced, not removed) and adds +│ │ │ mapper/ constants/ index.ts engine/ for breach evaluation +│ │ ├── engine/ (policy resolution + breach detection) +│ │ └── calculators/ (due-date calculator, replaced) +│ └── escalation/ # REPLACED stub — full standard shape, keeps +│ ├── controller/ routes/ schema/ engine/ for rule matching + firing +│ │ repository/ service/ types/ +│ │ mapper/ constants/ index.ts +│ └── engine/ +└── tests/ + ├── unit/ + │ ├── platform/business-calendars/ # calendar-walk algorithm + │ └── orchestration/{sla,escalation}/ # policy match, breach logic, rule match + └── integration/ # full flow incl. restart-boundary test +``` + +**Structure Decision**: Single project. `business-calendars` gets a full standard shape (not +internal-only) since it needs its own CRUD surface for calendars/holidays, unlike 007's +internal-only `routing` module. `sla` and `escalation` each keep the `engine/` extension doc 07 +§8 reserves for modules with real decision logic, matching 005/007 precedent. + +## Complexity Tracking + +*No constitution violations — table intentionally omitted.* diff --git a/specs/008-sla-escalation/quickstart.md b/specs/008-sla-escalation/quickstart.md new file mode 100644 index 0000000..efcef31 --- /dev/null +++ b/specs/008-sla-escalation/quickstart.md @@ -0,0 +1,77 @@ +# Quickstart: Validating SLA and Escalation + +Prerequisites: migrations applied; at least one hierarchy node/agent/product set up per +006-support-organization's and 007-orchestration-assignment's own quickstarts, since this feature +starts an `SLARun` on a successful 007 assignment and escalation re-assigns through 007's engine. + +## Scenario 1 — policy definition and most-specific match (User Story 1) + +1. Create a global `SLAPolicy` (`productId: null`, ...) and a second, product-scoped policy for + the same product with tighter minutes. +2. Assign a ticket for that product (triggers Scenario 2's creation path). +3. **Expected**: the `SLARun` resolves the product-scoped policy, not the global one. +4. Delete the product-scoped policy's applicability (set `active: false`). Assign a new ticket for + the same product. **Expected**: falls back to the global policy. + +## Scenario 2 — calendar-aware due dates on assignment (User Story 2) + +1. Create a `BusinessCalendar` with `workingHours` only Mon-Fri 09:00-17:00, `timezone` + `"America/New_York"`, and one `Holiday` next Monday. Attach it to an `SLAPolicy` with + `resolutionMinutes: 480` (one working day). +2. Assign a ticket late on a Friday afternoon so that a naive `createdAt + 480min` would land on + Saturday. +3. **Expected**: `resolutionDueAt` lands the following Tuesday (Monday excluded as a holiday), + never on the weekend. +4. Assign a ticket for a product/category/priority combination matching no active policy. + **Expected**: no `SLARun` is created; `GET /tickets/:ticketId/sla-run` returns `404`. + +## Scenario 3 — durable pause/resume across a process restart (User Story 3) + +1. Assign a ticket (Scenario 2), note `resolutionDueAt`. +2. Transition the ticket to `WAITING_FOR_CUSTOMER`. **Expected**: `SLARun.status` becomes + `paused`, `pausedAt` set. +3. Restart the application process (rebuild `buildApp()` fresh, simulating the restart the + constitution's Principle VII requires surviving). +4. Wait a real interval, then transition the ticket out of `WAITING_FOR_CUSTOMER`. + **Expected**: `SLARun.status` becomes `running`; the new `resolutionDueAt` equals the original + plus exactly the paused wall-clock duration — never reset to a fresh full duration. + +## Scenario 4 — durable breach detection (User Story 4) + +1. Assign a ticket against a policy with a very short `resolutionMinutes` (e.g. `1`) and a 24/7 + calendar (`businessCalendarId: null`). +2. Wait past `resolutionDueAt` without resolving the ticket. +3. **Expected**: within one breach-detection job tick, `SLARun.status` becomes `breached`, + `breachedAt` set. +4. Repeat, but resolve the ticket before `resolutionDueAt` passes. **Expected**: `status` reaches + `completed` and is never later flipped to `breached` by a subsequent job tick. +5. Repeat, but pause the run before `resolutionDueAt` passes. **Expected**: the run is never + marked `breached` while paused, even after the due instant passes. + +## Scenario 5 — breach-triggered escalation and scoped re-assignment (User Story 5) + +1. Create an `EscalationPolicy` scoped to the ticket's product with an active `EscalationRule` + (`triggerType: "resolution_breach"`, `targetNodeId` set to a second hierarchy node with a + different eligible agent). +2. Reach a `breached` run (Scenario 4). **Expected**: exactly one `EscalationEvent` is created + (`ruleId` set, `toNodeId` the rule's `targetNodeId`), and the ticket is reassigned to an agent + eligible under that specific node — not re-resolved from the ticket's original context. +3. Repeat with no matching `EscalationRule` for the resolved policy. **Expected**: the run is + still marked `breached`; no `EscalationEvent` is created. + +## Scenario 6 — manual escalation (User Story 6) + +1. `POST /tickets/:ticketId/escalate` with a valid `targetNodeId` and a reason. +2. **Expected**: an `EscalationEvent` is created (`ruleId: null`, `triggeredBy` the calling + actor), and the ticket is reassigned through the same scoped path as Scenario 5. +3. Repeat with a nonexistent `targetNodeId`. **Expected**: `404`, no `EscalationEvent` created. +4. Trigger a manual escalation on a ticket whose run is concurrently being auto-escalated by + Scenario 5's breach path. **Expected**: both `EscalationEvent` rows are recorded; the final + assignment reflects 007's already-tested concurrency handling, not a corrupted double-write. + +## What "done" looks like + +All six scenarios pass, and together they demonstrate every functional requirement and success +criterion in `spec.md` — including SC-002's explicit restart-survival requirement, which must be +verified by an actual fresh `buildApp()` in the middle of the test, not merely by asserting on +stored field values without ever exercising a real process boundary. diff --git a/specs/008-sla-escalation/research.md b/specs/008-sla-escalation/research.md new file mode 100644 index 0000000..4e0037c --- /dev/null +++ b/specs/008-sla-escalation/research.md @@ -0,0 +1,175 @@ +# Phase 0 Research: SLA and Escalation + +## Decision: Module placement — three existing stubs, mapped directly + +- **Decision**: `platform/business-calendars` (currently a one-file stub, + `isWorkingHour` hardcoded `true`), `orchestration/sla` (stub `SlaEngine.evaluateSlaTargets` + always returns `NORMAL`; stub `SlaDueDateCalculator` does naive `createdAt + hours`), and + `orchestration/escalation` (stub `EscalationEngine.triggerEscalation` always returns + `{ escalated: false }`) are populated directly, matching doc 07's placement exactly — no new + module locations invented. +- **Rationale**: Documented layout, not an open choice; every stub's current behavior is exactly + what doc 05 §5 explicitly warns against (`SlaDueDateCalculator`'s naive addition is the literal + anti-pattern FR-004 forbids) — replacing it is the point of this feature. +- **Alternatives considered**: None. + +## Decision: A real timezone-aware date library — `luxon` — is a genuinely new dependency + +- **Decision**: Add `luxon` (a single package, no companion timezone package needed, IANA + timezone support built in) for every calendar-aware date computation in this feature. +- **Rationale**: No date/timezone library exists anywhere in this codebase yet — every prior + feature's `DateTime`/`Json`-typed "schedule" fields (e.g. 006's `AgentAvailability. + workingHours`) were stored but never actually walked by any code. This feature is the first to + need to *compute* against calendar time correctly (FR-004's explicit "MUST NOT... ignore the + calendar"), and hand-rolling DST-correct, IANA-timezone-aware business-hour arithmetic without + a library is exactly the kind of mistake this system's own constitution warns against elsewhere + ("don't reinvent what a library already solves correctly" is this codebase's working norm, even + if not literally in the constitution's text) — matching the same "one new dependency for the + one new genuinely-needed capability" precedent 005 set for `@anthropic-ai/sdk`. +- **Alternatives considered**: `date-fns` + `date-fns-tz` (two packages for the same + capability) — rejected in favor of the single-package option. Hand-rolled arithmetic — + rejected; timezone/DST correctness is precisely the kind of subtly-wrong-most-of-the-time code + a library exists to prevent. + +## Decision: `BusinessCalendar.workingHours` shape — one window per weekday + +- **Decision**: `{ mon?: { start: "09:00", end: "17:00" }, tue?: ..., ..., sun?: ... }` — three- + letter weekday keys, `HH:mm` 24-hour strings interpreted in the calendar's own `timezone`, a + missing key meaning "not a working day" (FR's "unconfigured day contributes zero time"). +- **Rationale**: Doc 05 §5's stated need ("business hours, weekends... per-team schedules") is + satisfied by one contiguous window per day — doc 06 doesn't specify a richer shape (split + shifts), and nothing in spec.md asks for one; a single window per day is the simplest structure + that satisfies every acceptance scenario without speculative complexity. +- **Alternatives considered**: An array of windows per day (split-shift support) — rejected as + unrequested scope; the shape can be extended later (an array is a strict superset) without a + breaking change to a single-window calendar's own data. + +## Decision: Calendar-aware due-date arithmetic — a day-by-day walk + +- **Decision**: `addBusinessMinutes(start, minutes, calendar, holidays)` walks forward from + `start` one calendar day at a time (in the calendar's 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, + consumed from the running `minutes` total; the walk ends the moment `minutes` reaches zero, + returning that exact timestamp. +- **Rationale**: This directly implements FR-004 — every acceptance scenario (weekend/holiday + exclusion) is a direct consequence of this algorithm, not a special case bolted on. A + day-granularity loop is bounded (even a multi-week SLA window is, at most, a few dozen + iterations) and easy to unit-test exhaustively. +- **Alternatives considered**: Minute-by-minute simulation — rejected as needlessly slow and + harder to reason about for the same result; day-granularity with within-day clipping is exactly + as correct and far simpler. + +## Decision: SLA policy resolution — most-specific match, same shape as 005's confidence policy + +- **Decision**: Given a ticket's `productId`/`categoryId`/`problemTypeId`/`priority`, an active + `SLAPolicy` matches when each of its own scope fields is either `null` (wildcard) or equal to + the ticket's corresponding value. Among matches, the one with the fewest `null` scope fields + (most specific) wins; a tie is broken by most-recently-`updatedAt`. +- **Rationale**: FR-002 requires most-specific-match, not first-found — this is the same + resolution shape 005's `AIConfidencePolicy` and 006's hierarchy scope matching already + established in this codebase, reused rather than reinvented a third time. +- **Alternatives considered**: A single global default policy with per-scope overrides (005's + `(productId, categoryId)` two-level shape) — rejected; SLA policy has four independent scope + dimensions doc 06 itself defines, so a strict specificity count (not a fixed lookup order) is + the correct generalization. + +## Decision: Pause/resume — shift the absolute due date by the paused wall-clock duration + +- **Decision**: Pausing records `pausedAt = now()` (status → `paused`); resuming shifts + `resolutionDueAt` (and `firstResponseDueAt`, if still pending) forward by `now() - pausedAt` + and clears `pausedAt` (status → `running`). No separate "remaining minutes" bookkeeping field + is needed — the absolute due-date field itself, shifted, *is* the remaining-time record. +- **Rationale**: FR-007/FR-008/SC-002 require the paused duration to be excluded, durably, across + a restart — shifting an absolute timestamp already stored in Postgres satisfies both with the + simplest possible mechanism; no in-memory state exists at any point. +- **Alternatives considered**: Storing remaining minutes and recomputing the due date via the + calendar walk on every resume — rejected as unnecessary; the pause window itself doesn't need + calendar-awareness (a paused SLA isn't "elapsing" business time by definition, so shifting by + real wall-clock pause duration is exactly correct, not an approximation). + +## Decision: Breach detection — one repeatable BullMQ job, not one delayed job per run + +- **Decision**: A single repeatable job (e.g. every 60 seconds) queries every `SLARun` with + `status: 'running'` whose `resolutionDueAt <= now()`, marking each `breached` — and separately, + every running run with `firstResponseDueAt <= now()` and no `firstResponseBreachedAt` yet + (data-model.md refinement) and no `AGENT_MESSAGE` recorded for the ticket, marking + `firstResponseBreachedAt`. Each newly-detected breach triggers escalation-rule evaluation + (research.md below). +- **Rationale**: Constitution Principle VII requires durability, not sub-second precision — a + short-interval polling job is trivially durable (BullMQ's repeatable jobs are themselves + persisted, and a missed tick is caught by the next one) and avoids the bookkeeping a + per-run delayed-job approach would need on every pause/resume (canceling and rescheduling a + delayed job each time, versus just updating a timestamp a polling query already reads). +- **Alternatives considered**: One delayed BullMQ job scheduled per `SLARun`, rescheduled on every + pause/resume — rejected; every pause/resume would need to cancel and re-add a job, doubling the + operations pause/resume already does, for a precision (sub-minute breach detection) nothing in + spec.md actually requires. + +## Decision: Escalation firing reuses 007's `AssignmentEngine`, scoped to a specific node + +- **Decision**: `AssignmentEngine` (007) gains a new method, `assignToSpecificNode(ticketId, + hierarchyNodeId, strategyOverride?, actor, reason?)` — resolves the eligible-agent set the same + way `RoutingService` already does, but scoped to exactly the given node (its own `skills` + unioned with the ticket's derived required skills, per 007's existing composition rule) rather + than 007's general "find whichever node matches the ticket's context" resolution. Runs the + node's own configured strategy (or `strategyOverride`) and persists through the same + `Assignment`/`AssignmentHistory` mechanism 007 already built and tested for concurrent writes. +- **Rationale**: FR-014 requires escalation to land the ticket specifically at the rule's + `targetNodeId` — 007's existing `evaluateAndAssign` always re-derives the applicable node from + ticket context, which could resolve to a *different* node than the one the rule targeted (the + ticket's context hasn't changed, only its status has). A new, explicit "assign to this node" + entry point is the correct extension, not a workaround. +- **Alternatives considered**: Having 008 duplicate 007's eligible-agent-resolution and + `Assignment`-persistence logic — rejected; directly against this codebase's repeated "extend an + existing module's public surface for a later feature" precedent (004's `productsRepository`, + 005's `problemsRepository`, 007's own reuse of 006's `capabilityLookupService`). + +## Decision: `EscalationRule.triggerType` is stored broadly; only two types are ever evaluated + +- **Decision**: The Zod schema for creating a rule accepts any of doc 06's ten `triggerType` + values — an admin can configure a rule for `inactivity` or `critical_incident` today, and it + will simply never fire (no code path evaluates those triggers yet), rather than being rejected + at creation time. +- **Rationale**: spec.md's Assumptions state this explicitly — storing configuration ahead of the + event source that will eventually feed it is this codebase's established pattern (006's + `slaPolicyId` stored before this feature existed to validate it); rejecting valid doc-06-shaped + configuration at the schema layer would be a regression from that pattern, not a safety + improvement (nothing unsafe happens from an inert rule sitting unfired). +- **Alternatives considered**: Restricting the schema to only the two implemented trigger types — + rejected; would force a breaking schema change on every future phase that wires up one more + trigger type, for no correctness benefit today. + +## Decision: Escalation policy resolution — product match or global, most-specific first + +- **Decision**: `EscalationPolicy.productId` is the only scope dimension doc 06 gives it (unlike + `SLAPolicy`'s four). Resolution: prefer an active policy whose `productId` equals the ticket's + product; fall back to an active policy with `productId: null` (a global policy) if no + product-specific one exists. A breach with neither is recorded breached with no rule evaluated + (spec.md Edge Cases: "a breach with no matching rule is still recorded as breached"). +- **Rationale**: Same most-specific-first shape as `SLAPolicy`, degenerately simple because doc 06 + only gives `EscalationPolicy` one scope field — no new resolution mechanism invented. +- **Alternatives considered**: None; doc 06's shape leaves no other reasonable reading. + +## Decision: `EscalationRule.condition` is stored, not evaluated, by this feature + +- **Decision**: Every active `EscalationRule` under the resolved policy whose `triggerType` + matches the firing breach type (`resolution_breach` or `first_response_breach`) fires — the + `condition` Json field is persisted as given at creation but not parsed or evaluated as a + filter. +- **Rationale**: spec.md's FR-013 says "every matching active EscalationRule fires" scoped by + trigger type alone; nothing in spec.md defines a `condition` grammar to evaluate, and inventing + one now would be exactly the kind of unrequested scope this codebase's established discipline + (005's inert trigger types, 006's unvalidated `slaPolicyId`) consistently avoids. `condition` is + accepted and returned by the CRUD schema so a future feature can give it real meaning without a + breaking change. +- **Alternatives considered**: A minimal condition-matching evaluator (e.g. `{ minPriority }`) — + rejected as speculative; spec.md never asked for conditional rule filtering beyond trigger type. + +## Decision: SLA/Escalation admin endpoints reuse the existing auth stub + +- **Decision**: Every admin CRUD endpoint (policies, calendars, escalation rules) and the manual- + escalation endpoint are gated by `fastify.authenticate`, same known-limitation stub as every + prior feature. +- **Rationale**: Consistency with established precedent. +- **Alternatives considered**: None. From bb31e9d64183b9f0deb9cacd0725d1a45c963740 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 12:27:40 +0530 Subject: [PATCH 3/5] tasks: task breakdown for SLA and escalation feature (008) 48 tasks across 9 phases (6 user stories + setup/foundational/polish), sequenced US1 (policy definition) -> US2 (calendar-aware run creation) -> US3 (durable pause/resume, P1-complete MVP) -> US4 (breach detection) -> US5 (breach-triggered escalation) -> US6 (manual escalation), each dependent on the last since every story builds on the previous one's mechanism rather than being independently orderable. Also folds in two design refinements found while cross-checking the existing scaffold against research.md's plan: TICKET_ASSIGNED (defined in domain-events.ts since 007, never published) becomes the real wiring point for SLA-run creation, and src/jobs/sla//src/jobs/escalation/ turn out to already exist as their own stub scaffolding, reused rather than duplicated. Co-Authored-By: Claude Sonnet 5 --- specs/008-sla-escalation/plan.md | 18 +- specs/008-sla-escalation/research.md | 49 ++++ specs/008-sla-escalation/tasks.md | 387 +++++++++++++++++++++++++++ 3 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 specs/008-sla-escalation/tasks.md diff --git a/specs/008-sla-escalation/plan.md b/specs/008-sla-escalation/plan.md index 393c202..7e150c7 100644 --- a/specs/008-sla-escalation/plan.md +++ b/specs/008-sla-escalation/plan.md @@ -112,9 +112,17 @@ supporthub-api/ │ BusinessCalendar, Holiday, EscalationPolicy, │ EscalationRule, EscalationEvent ├── src/ -│ ├── infrastructure/ -│ │ └── queue/ # MODIFIED — register the breach-detection -│ │ repeatable job alongside existing workers +│ ├── events/ +│ │ └── handlers/index.ts # MODIFIED — first real publish of the +│ │ existing-but-unused TICKET_ASSIGNED event +│ │ (from 007's persistAndTransition), plus two +│ │ new TICKET_UPDATED subscribers (pause/ +│ │ resume, completion) — research.md +│ ├── jobs/ +│ │ └── sla/index.ts # REPLACED stub — schedules the repeatable +│ │ breach-detection job (research.md); jobs/ +│ │ escalation/ stays untouched (reserved for a +│ │ future notification-dispatch step) │ └── modules/ │ ├── platform/ │ │ └── business-calendars/ # REPLACED stub — full standard shape + @@ -123,6 +131,10 @@ supporthub-api/ │ │ │ mapper/ constants/ index.ts │ │ └── calculators/ │ └── orchestration/ +│ ├── assignments/ # 007, MODIFIED — persistAndTransition +│ │ └── engine/assignment.engine.ts publishes TICKET_ASSIGNED; new +│ │ assignToSpecificNode() method for +│ │ escalation's scoped re-assignment │ ├── sla/ # REPLACED stub — full standard shape, keeps │ │ ├── controller/ routes/ schema/ its existing calculators/ dir (due-date │ │ │ repository/ service/ types/ calculator replaced, not removed) and adds diff --git a/specs/008-sla-escalation/research.md b/specs/008-sla-escalation/research.md index 4e0037c..e9a655b 100644 --- a/specs/008-sla-escalation/research.md +++ b/specs/008-sla-escalation/research.md @@ -166,6 +166,55 @@ - **Alternatives considered**: A minimal condition-matching evaluator (e.g. `{ minPriority }`) — rejected as speculative; spec.md never asked for conditional rule filtering beyond trigger type. +## Decision: SLA-run lifecycle is wired entirely through the existing domain-event bus + +- **Decision**: `DomainEventName.TICKET_ASSIGNED` — defined in `src/events/domain-events.ts` + since 007 but never actually published by any code — is published for the first time by + `AssignmentEngine.persistAndTransition` (007's single shared success path for automatic, + manual, and this feature's new scoped-escalation assignment) with `{ ticketId, agentId, + strategy, actor }`. A new subscriber in `src/events/handlers/index.ts` reacts by resolving the + applicable `SLAPolicy` and creating the `SLARun` — but only if `ticketId` doesn't already have + one (`SLARun.ticketId @unique` makes this a natural existence check), so a re-escalation's + second `TICKET_ASSIGNED` publish (spec.md Assumptions: 1:1 with the *first* assignment only) + is correctly a no-op. Two further `TICKET_UPDATED` subscribers (same file, same pattern as + 005's and 007's own) watch for `newStatus === 'WAITING_FOR_CUSTOMER'` (pause) / + `previousStatus === 'WAITING_FOR_CUSTOMER'` (resume), and for `newStatus === 'RESOLVED'` + (complete, per 003's state machine — `RESOLVED` is the terminal status every path reaches + before `CLOSED`/`REOPENED`). +- **Rationale**: Same "a module never needs to import another module it affects" decoupling this + codebase has used consistently since 005 — `orchestration/assignments` doesn't need to know + `orchestration/sla` exists, and `ticketing/tickets` already doesn't know about any of its + status-change consumers. Publishing `TICKET_ASSIGNED` for real is the natural use of an event + this codebase already named and reserved for exactly this purpose. +- **Alternatives considered**: A direct call from `AssignmentEngine.persistAndTransition` into an + `orchestration/sla` service method — rejected; would create the exact cross-module coupling + 007→008 the event bus exists to avoid, and would need every future consumer of "a ticket got + assigned" to be added as another direct call in 007's own code. + +## Decision: The breach-detection job reuses `src/jobs/sla/`'s existing stub; escalation firing reuses `src/jobs/escalation/`'s + +- **Decision**: `registerSlaWorker()` (`src/jobs/sla/index.ts`, currently just a log line) is + extended to, on startup, schedule one BullMQ repeatable job (`queueManager.getQueue(QueueName + .SLA).add('detect-breaches', {}, { repeat: { every: 60_000 } })`) whose processor calls a + single, directly-callable, side-effect-only method — `slaService.runBreachDetectionSweep()` — + containing 100% of the actual logic: the two polling queries from research.md's breach- + detection decision, marking runs breached/first-response-breached, and, for each new breach, + calling `escalationService.handleBreach(ticketId, triggerType)` directly (a plain in-process + call, not a second queued job) since escalation firing has no meaningful reason to be + async-relative-to-detection. `src/jobs/escalation/`'s existing `registerEscalationWorker()` + stub, and its `ESCALATION` queue, are left untouched — reserved, per their own existing + scaffold, for a possible future async notification-dispatch step (spec.md Assumptions: no + notification delivery is built by this feature). +- **Rationale**: `runBreachDetectionSweep()` being a plain importable async function (not + reachable only through a running BullMQ worker) is what makes it possible to write an + integration test for "one job tick" without a real running worker process or a real 60-second + wait — the exact "no worker process in this test, call the job's own logic inline" convention + already established by `tests/integration/ticket-attachments.test.ts` for the malware-scan job. +- **Alternatives considered**: Splitting detection and escalation firing into two separately + queued BullMQ jobs (using the `ESCALATION` queue for the firing step) — rejected as an + 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/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 new file mode 100644 index 0000000..4934ec2 --- /dev/null +++ b/specs/008-sla-escalation/tasks.md @@ -0,0 +1,387 @@ +--- +description: "Task list for 008-sla-escalation" +--- + +# Tasks: SLA and Escalation + +**Input**: Design documents from `specs/008-sla-escalation/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), +[data-model.md](./data-model.md), +[contracts/sla-escalation-contract.md](./contracts/sla-escalation-contract.md), +[quickstart.md](./quickstart.md) + +**Tests**: Included as first-class tasks. This feature has real, extractable pure logic (the +calendar-walk algorithm, most-specific policy match, breach/no-breach/paused-no-breach logic) +plus — for the first time since the constitution's Principle VII was written — a genuine +process-restart-survival requirement that needs a dedicated test rebuilding `buildApp()` +mid-test, not just a within-process concurrency test. + +**Organization**: Tasks are grouped by user story (US1 = P1 policy definition, US2 = P1 run +creation with calendar-aware due dates, US3 = P1 durable pause/resume, US4 = P2 breach detection, +US5 = P2 breach-triggered escalation, US6 = P3 manual escalation). + +## Format: `[ID] [P?] [Story] Description` + +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 + (`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 + 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 + its existing `engine/` directory, replacing the `EscalationEngine.triggerEscalation` stub's + content + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Schema for every entity, shared by every user story. + +**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete. + +- [ ] 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 + T004 (depends on T004) + +**Checkpoint**: Schema migrated. User stories can now be built. + +--- + +## Phase 3: User Story 1 - Admin defines SLA policies as configuration (Priority: P1) 🎯 MVP (part 1) + +**Goal**: `SLAPolicy` CRUD and the most-specific-match resolution function exist and are +independently correct — not yet wired to ticket assignment. + +**Independent Test**: Quickstart Scenario 1. + +### Tests for User Story 1 + +- [ ] 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 + 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 + 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 + 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 + `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 + +**Checkpoint**: SLA policies can be defined and correctly resolved. Nothing creates an `SLARun` +yet — that's User Story 2. + +--- + +## Phase 4: User Story 2 - SLA run starts automatically with calendar-aware due dates (Priority: P1) 🎯 MVP (part 2) + +**Goal**: `BusinessCalendar`/`Holiday` CRUD, the calendar-walk algorithm, and `SLARun` creation +wired into 007's assignment-success path via the first real publish of `TICKET_ASSIGNED`. + +**Independent Test**: Quickstart Scenario 2. + +### Tests for User Story 2 + +- [ ] 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 + 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 + existing assignment flow) + +### Implementation for User Story 2 + +- [ ] 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 + 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 + 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, + `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 + 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 + `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/` + + `sla/routes/` (depends on T018) +- [ ] 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. + +--- + +## Phase 5: User Story 3 - SLA pause/resume is durable across a process restart (Priority: P1) + +**Goal**: `WAITING_FOR_CUSTOMER` transitions pause/resume the run by shifting its absolute due +dates — no in-memory state anywhere, verified across an actual rebuilt `buildApp()`. + +**Independent Test**: Quickstart Scenario 3. + +### Tests for User Story 3 + +- [ ] 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 + `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 + `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 + `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 + `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 + restart-boundary step + +**Checkpoint**: Every P1 user story is complete. SLA runs are created, calendar-computed, and +durably pause/resume-correct. This is the feature's MVP. + +--- + +## Phase 6: User Story 4 - Breaches are detected even if no one is watching in real time (Priority: P2) + +**Goal**: A repeatable BullMQ job durably detects both resolution and first-response breaches, +never missing one because the process wasn't running at the due instant, never flagging a +completed-in-time or paused run. + +**Independent Test**: Quickstart Scenario 4. + +### Tests for User Story 4 + +- [ ] 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` + 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` + 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 + 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 + +**Checkpoint**: Breaches are durably detected. Nothing reacts to a breach yet beyond marking the +run — that's User Story 5. + +--- + +## Phase 7: User Story 5 - A breach automatically triggers rule-driven escalation (Priority: P2) + +**Goal**: `EscalationPolicy`/`EscalationRule` CRUD, breach-triggered `EscalationEvent` firing, and +a new scoped-assignment entry point on 007's `AssignmentEngine` that re-assigns to exactly the +rule's `targetNodeId`. + +**Independent Test**: Quickstart Scenario 5. + +### Tests for User Story 5 + +- [ ] 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 + 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 + in `tests/integration/sla-escalation-firing.test.ts` (depends on T030) + +### Implementation for User Story 5 + +- [ ] 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`, + `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, + 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 + 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 + 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 + +**Checkpoint**: Breaches automatically escalate through rule-driven, scoped re-assignment. + +--- + +## Phase 8: User Story 6 - A human can manually escalate a ticket to a specific node (Priority: P3) + +**Goal**: The same `EscalationEvent` + scoped-reassignment mechanism, triggered explicitly by a +caller instead of a breach. + +**Independent Test**: Quickstart Scenario 6. + +### 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) + +### Implementation for User Story 6 + +- [ ] 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`) + 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 + +**Checkpoint**: All six user stories work independently and together — policy definition, +calendar-aware run creation, durable pause/resume, durable breach detection, and both automatic +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 + 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 + elsewhere, then the full integration suite (including 007's own suite, since T017/T037 + modify its `AssignmentEngine`) against real Docker-provisioned Postgres/Redis + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US6 +- **User Story 2 (Phase 4)**: Depends on US1 (the policy it resolves against) — genuinely not + independent, same class of dependency 007's US2 had on US1 +- **User Story 3 (Phase 5)**: Depends on US2 (the run it pauses/resumes) +- **User Story 4 (Phase 6)**: Depends on US3 (a run that can be paused must be excluded from + breach detection correctly, so the pause mechanism must exist first) +- **User Story 5 (Phase 7)**: Depends on US4 (the breach it reacts to) and on 007's + `AssignmentEngine` (T037's new method) +- **User Story 6 (Phase 8)**: Depends on US5 (T037/T038's scoped-reassignment mechanism, reused + directly rather than duplicated) +- **Polish (Phase 9)**: Depends on all six user stories + +### Parallel Opportunities + +- T001/T002/T003 (independent scaffolding) +- T006 (unit tests) alongside T008-T009 (the implementations they test) +- T012 (unit tests) alongside T014 (the implementation it tests) +- T022 alongside T024; T028 alongside T030; T033 alongside T035/T038 +- T045/T046 in Polish + +### Sequencing Note + +T017 (publishing `TICKET_ASSIGNED` from 007's `AssignmentEngine`) and T037 (the new +`assignToSpecificNode` method on the same class) both modify a file 007 already owns and has its +own passing test suite for — run 007's full integration suite (part of T048) after each, not only +at the very end, to catch a regression close to its cause. + +--- + +## Implementation Strategy + +### MVP First (User Stories 1-3 Only) + +1. Setup + Foundational (T001-T005) +2. User Story 1 (T006-T011) — policies exist and resolve correctly +3. User Story 2 (T012-T021) — runs are created with real calendar-aware due dates +4. User Story 3 (T022-T027) — pause/resume is durable, including across a restart +5. **STOP and VALIDATE**: Quickstart Scenarios 1-3 pass — every assigned ticket has a correctly + computed, durably pausable `SLARun`. Nothing reacts to a breach yet — that value lands with + User Story 4/5. + +### Incremental Delivery + +1. Setup + Foundational → schema migrated +2. Add User Story 1 → SLA policies are configurable and resolve correctly +3. Add User Story 2 → runs are created automatically with calendar-aware due dates +4. Add User Story 3 → pause/resume is durable (P1-complete, MVP) +5. Add User Story 4 → breaches are durably detected +6. Add User Story 5 → breaches automatically escalate and reassign +7. Add User Story 6 → manual escalation exists, reusing the same mechanism +8. Polish → docs and full regression From 9357f03e1d9f175e750335d00369cc511c75c0ef Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 13:02:05 +0530 Subject: [PATCH 4/5] feat: implement SLA and escalation (008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populates platform/business-calendars, orchestration/sla, and orchestration/escalation (all thin stubs until now) with the real engine: - business-calendars: a luxon-based day-by-day calendar walk (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours, weekends, and holidays — replacing the naive createdAt+hours stub FR-004 explicitly forbids. - sla: most-specific SLAPolicy resolution (product/category/problemType/ priority, wildcard-or-exact-match, specificity-count + updatedAt tiebreak), SLARun creation on the first real publish of the long-unused TICKET_ASSIGNED domain event, durable pause/resume via an absolute-timestamp shift (no in-memory state, verified across a real buildApp() restart), and a repeatable BullMQ breach-detection sweep (src/jobs/sla, itself a previously-unregistered stub) that is directly callable for tests, not only reachable through a running worker. - escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types storable, only resolution_breach/first_response_breach evaluated), breach-triggered and manual escalation both funnel through one EscalationEvent + scoped re-assignment path. AssignmentEngine (007) gains assignToSpecificNode — a new, explicitly node-scoped entry point, since escalation must never let 007's general resolution re-derive a different node than the one a rule or a caller targeted. Two small pre-existing scaffold gaps were closed along the way: CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/ ESCALATION_TRIGGERED were defined since earlier phases but never published by any code. Verified against throwaway Docker Postgres/Redis (typecheck, lint, architecture-check all clean; 148/150 relevant tests pass — the 2 failures are pre-existing, MinIO-dependent, and unrelated to this feature). Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 147 +++++++ prisma/schema.prisma | 141 ++++++- .../checklists/requirements.md | 34 ++ specs/008-sla-escalation/plan.md | 2 +- specs/008-sla-escalation/research.md | 16 + specs/008-sla-escalation/tasks.md | 122 +++--- src/api/routes.ts | 6 + src/bootstrap/queue.bootstrap.ts | 2 + src/events/handlers/index.ts | 42 ++ src/jobs/sla/index.ts | 19 +- src/modules/catalog/categories/index.ts | 1 + .../repository/categories.repository.ts | 8 + .../assignments/engine/assignment.engine.ts | 64 +++ .../escalation/constants/index.ts | 3 + .../controller/escalation.controller.ts | 61 +++ .../escalation/controller/index.ts | 1 + .../escalation/engine/escalation.engine.ts | 17 +- src/modules/orchestration/escalation/index.ts | 14 +- .../orchestration/escalation/mapper/index.ts | 1 + .../repository/escalation-event.repository.ts | 30 ++ .../escalation-policy.repository.ts | 36 ++ .../repository/escalation-rule.repository.ts | 53 +++ .../escalation/repository/index.ts | 3 + .../escalation/routes/escalation.routes.ts | 37 ++ .../orchestration/escalation/routes/index.ts | 1 + .../escalation/schema/escalation.schema.ts | 48 +++ .../orchestration/escalation/schema/index.ts | 1 + .../escalation/service/escalation.service.ts | 137 +++++++ .../orchestration/escalation/service/index.ts | 1 + .../orchestration/escalation/types/index.ts | 1 + .../service/capability-lookup.service.ts | 23 ++ .../routing/service/routing.service.ts | 28 ++ .../calculators/sla-due-date.calculator.ts | 22 +- .../orchestration/sla/constants/index.ts | 3 + .../orchestration/sla/controller/index.ts | 1 + .../sla/controller/sla.controller.ts | 46 +++ .../orchestration/sla/engine/sla.engine.ts | 12 +- src/modules/orchestration/sla/index.ts | 15 +- src/modules/orchestration/sla/mapper/index.ts | 1 + .../orchestration/sla/repository/index.ts | 2 + .../sla/repository/sla-policy.repository.ts | 67 +++ .../sla/repository/sla-run.repository.ts | 49 +++ src/modules/orchestration/sla/routes/index.ts | 1 + .../orchestration/sla/routes/sla.routes.ts | 33 ++ src/modules/orchestration/sla/schema/index.ts | 1 + .../sla/schema/sla-policy.schema.ts | 21 + .../orchestration/sla/service/index.ts | 3 + .../service/sla-policy-resolver.service.ts | 51 +++ .../orchestration/sla/service/sla.service.ts | 160 ++++++++ src/modules/orchestration/sla/types/index.ts | 1 + .../calculators/business-hours.calculator.ts | 106 +++++ .../business-calendars/constants/index.ts | 3 + .../business-calendars.controller.ts | 50 +++ .../business-calendars/controller/index.ts | 1 + .../platform/business-calendars/index.ts | 21 +- .../business-calendars/mapper/index.ts | 1 + .../business-calendar.repository.ts | 47 +++ .../repository/holiday.repository.ts | 28 ++ .../business-calendars/repository/index.ts | 2 + .../routes/business-calendars.routes.ts | 37 ++ .../business-calendars/routes/index.ts | 1 + .../schema/business-calendars.schema.ts | 46 +++ .../business-calendars/schema/index.ts | 1 + .../service/business-calendars.service.ts | 85 ++++ .../business-calendars/service/index.ts | 1 + .../business-calendars/types/index.ts | 1 + tests/integration/sla-escalation-flow.test.ts | 384 ++++++++++++++++++ .../escalation-rule-match.test.ts | 69 ++++ .../sla-breach-detection.test.ts | 58 +++ .../orchestration/sla-pause-resume.test.ts | 92 +++++ .../orchestration/sla-policy-match.test.ts | 81 ++++ .../business-calendars/calendar-walk.test.ts | 97 +++++ vitest.config.ts | 2 +- 73 files changed, 2720 insertions(+), 83 deletions(-) create mode 100644 prisma/migrations/20260903070436_add_sla_escalation/migration.sql create mode 100644 src/modules/orchestration/escalation/constants/index.ts create mode 100644 src/modules/orchestration/escalation/controller/escalation.controller.ts create mode 100644 src/modules/orchestration/escalation/controller/index.ts create mode 100644 src/modules/orchestration/escalation/mapper/index.ts create mode 100644 src/modules/orchestration/escalation/repository/escalation-event.repository.ts create mode 100644 src/modules/orchestration/escalation/repository/escalation-policy.repository.ts create mode 100644 src/modules/orchestration/escalation/repository/escalation-rule.repository.ts create mode 100644 src/modules/orchestration/escalation/repository/index.ts create mode 100644 src/modules/orchestration/escalation/routes/escalation.routes.ts create mode 100644 src/modules/orchestration/escalation/routes/index.ts create mode 100644 src/modules/orchestration/escalation/schema/escalation.schema.ts create mode 100644 src/modules/orchestration/escalation/schema/index.ts create mode 100644 src/modules/orchestration/escalation/service/escalation.service.ts create mode 100644 src/modules/orchestration/escalation/service/index.ts create mode 100644 src/modules/orchestration/escalation/types/index.ts create mode 100644 src/modules/orchestration/sla/constants/index.ts create mode 100644 src/modules/orchestration/sla/controller/index.ts create mode 100644 src/modules/orchestration/sla/controller/sla.controller.ts create mode 100644 src/modules/orchestration/sla/mapper/index.ts create mode 100644 src/modules/orchestration/sla/repository/index.ts create mode 100644 src/modules/orchestration/sla/repository/sla-policy.repository.ts create mode 100644 src/modules/orchestration/sla/repository/sla-run.repository.ts create mode 100644 src/modules/orchestration/sla/routes/index.ts create mode 100644 src/modules/orchestration/sla/routes/sla.routes.ts create mode 100644 src/modules/orchestration/sla/schema/index.ts create mode 100644 src/modules/orchestration/sla/schema/sla-policy.schema.ts create mode 100644 src/modules/orchestration/sla/service/index.ts create mode 100644 src/modules/orchestration/sla/service/sla-policy-resolver.service.ts create mode 100644 src/modules/orchestration/sla/service/sla.service.ts create mode 100644 src/modules/orchestration/sla/types/index.ts create mode 100644 src/modules/platform/business-calendars/calculators/business-hours.calculator.ts create mode 100644 src/modules/platform/business-calendars/constants/index.ts create mode 100644 src/modules/platform/business-calendars/controller/business-calendars.controller.ts create mode 100644 src/modules/platform/business-calendars/controller/index.ts create mode 100644 src/modules/platform/business-calendars/mapper/index.ts create mode 100644 src/modules/platform/business-calendars/repository/business-calendar.repository.ts create mode 100644 src/modules/platform/business-calendars/repository/holiday.repository.ts create mode 100644 src/modules/platform/business-calendars/repository/index.ts create mode 100644 src/modules/platform/business-calendars/routes/business-calendars.routes.ts create mode 100644 src/modules/platform/business-calendars/routes/index.ts create mode 100644 src/modules/platform/business-calendars/schema/business-calendars.schema.ts create mode 100644 src/modules/platform/business-calendars/schema/index.ts create mode 100644 src/modules/platform/business-calendars/service/business-calendars.service.ts create mode 100644 src/modules/platform/business-calendars/service/index.ts create mode 100644 src/modules/platform/business-calendars/types/index.ts create mode 100644 tests/integration/sla-escalation-flow.test.ts create mode 100644 tests/unit/orchestration/escalation-rule-match.test.ts create mode 100644 tests/unit/orchestration/sla-breach-detection.test.ts create mode 100644 tests/unit/orchestration/sla-pause-resume.test.ts create mode 100644 tests/unit/orchestration/sla-policy-match.test.ts create mode 100644 tests/unit/platform/business-calendars/calendar-walk.test.ts 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', }, From 7e3d2ae29f2af3d869a6d202ba53a5d8ddb52f7f Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 14:19:21 +0530 Subject: [PATCH 5/5] add the new feaures --- Jenkinsfile | 226 --------------------------------- docker-compose.development.yml | 31 +---- docker-compose.test.yml | 25 +--- 3 files changed, 2 insertions(+), 280 deletions(-) delete mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index e555db9..0000000 --- a/Jenkinsfile +++ /dev/null @@ -1,226 +0,0 @@ -// CI/CD pipeline for supporthub-api. -// -// Stage order and guarantees are defined by: -// - .specify/memory/constitution.md -> "Testing, Observability & CI/CD Gates" -// - specs/001-ci-pipeline/contracts/pipeline-stage-contract.md -// -// Prerequisites (configured on the Jenkins side, never in this repo): -// - Agents with Docker and Node.js 20+ available (label: 'docker && node20') -// - Credentials (Secret text unless noted) per target environment in [test, prod]: -// -postgres-password, -redis-password, -jwt-secret -// -aws-access-key-id, -aws-secret-access-key -// -integration-credential-encryption-key (64 hex chars / 32 bytes — see -// specs/002-saas-integration/research.md "Credential storage") -// Plus one Username/Password credential: docker-registry-credentials -// - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var, -// or override the default below) pointing at the org's actual image registry. -// -// This file intentionally contains no secret values, only credential IDs — see -// specs/001-ci-pipeline/research.md "Environment/secrets handling in the pipeline". - -pipeline { - agent { label 'docker && node20' } - - options { - timestamps() - buildDiscarder(logRotator(numToKeepStr: '30')) - // Each build gets its own workspace by default; Compose project names below are - // additionally scoped by BUILD_NUMBER so concurrent runs never share containers, - // networks, or volumes (FR-009 / SC-005). - ansiColor('xterm') - } - - environment { - DOCKER_REGISTRY = "${env.CI_DOCKER_REGISTRY ?: 'registry.example.com/supporthub'}" - IMAGE_NAME = 'supporthub-api' - COMPOSE_PROJECT = "supporthub-ci-${env.BUILD_NUMBER}" - } - - stages { - - stage('Checkout') { - steps { - checkout scm - } - } - - stage('Install') { - steps { - sh 'npm ci' - } - } - - stage('Environment validation') { - steps { - script { - resolveDeployTarget() - writeTargetEnvFile(env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV) - } - // Reuses the existing Zod schema in src/config/env.ts as-is: it throws a - // specific, descriptive error on safeParse failure, so this fails fast with - // no new validation logic (FR-002). - sh "npx tsx --env-file=.env.${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV} -e \"import('./src/config/env.ts').then(() => console.log('Environment OK'))\"" - } - } - - stage('Generate Prisma client') { - steps { - sh 'npm run prisma:generate' - } - } - - stage('Typecheck') { - steps { - sh 'npm run typecheck' - } - } - - stage('Lint') { - steps { - sh 'npm run lint' - // Enforces Constitution Principle III (module boundaries) server-side — - // mirrors .husky/pre-commit so a bypassed/missing local hook can't merge - // a boundary violation. - sh 'npx tsx scripts/check-architecture.ts' - } - } - - stage('Format check') { - steps { - sh 'npm run format:check' - } - } - - stage('Unit test') { - steps { - sh 'npm run test:unit' - } - } - - stage('Integration test') { - steps { - sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis minio" - sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm --build app npm run test:integration" - } - } - - stage('E2E test') { - steps { - sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm app npm run test:e2e" - } - } - - stage('Build') { - steps { - script { - def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV - sh "npm run build:${buildTarget}" - } - } - } - - stage('Docker build') { - steps { - script { - def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV - env.IMAGE_TAG = "${env.DOCKER_REGISTRY}/${env.IMAGE_NAME}:${buildTarget}-${env.BUILD_NUMBER}" - } - sh "docker build --build-arg BUILD_COMMAND=\"npm run build:${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV}\" -t ${env.IMAGE_TAG} ." - } - } - - stage('Publish') { - when { expression { env.TARGET_ENV != 'none' } } - steps { - withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', - usernameVariable: 'REGISTRY_USER', - passwordVariable: 'REGISTRY_PASSWORD')]) { - sh ''' - echo "$REGISTRY_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$REGISTRY_USER" --password-stdin - docker push "$IMAGE_TAG" - docker logout "$DOCKER_REGISTRY" - ''' - } - } - } - - stage('Deploy') { - when { expression { env.TARGET_ENV != 'none' } } - steps { - script { - writeTargetEnvFile(env.TARGET_ENV) - } - sh "docker compose --env-file .env.${env.TARGET_ENV} -f docker-compose.${env.TARGET_ENV == 'prod' ? 'prod' : 'test'}.yml up -d" - } - } - } - - post { - always { - // Tear down the ephemeral integration/E2E stack regardless of outcome, and never - // leave a generated .env.* file behind in the workspace. --env-file must be passed - // to `down` too, or Compose can fail to resolve service config and leave containers - // running (confirmed while validating this pipeline locally). - sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml down -v --remove-orphans || true" - sh 'rm -f .env.test .env.prod .env.development' - cleanWs() - } - failure { - echo "Pipeline failed at stage: ${currentBuild.result}. See the failing stage's log above for the exact command and output — no local reproduction should be necessary (FR-004)." - } - } -} - -// Resolves env.TARGET_ENV from the branch being built: -// main -> prod -// develop/test -> test -// anything else -> none (validate-only; Publish/Deploy stages are skipped, not failed) -void resolveDeployTarget() { - def branch = env.BRANCH_NAME ?: '' - if (branch == 'main') { - env.TARGET_ENV = 'prod' - } else if (branch == 'develop' || branch == 'test') { - env.TARGET_ENV = 'test' - } else { - env.TARGET_ENV = 'none' - } - echo "Resolved deploy target for branch '${branch}': ${env.TARGET_ENV}" -} - -// Writes .env. into the workspace from Jenkins credentials — never read from a -// file committed to the repository (constitution governance: "secrets never committed"). -void writeTargetEnvFile(String target) { - withCredentials([ - string(credentialsId: "${target}-postgres-password", variable: 'POSTGRES_PASSWORD'), - string(credentialsId: "${target}-redis-password", variable: 'REDIS_PASSWORD'), - string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'), - string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'), - string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'), - string(credentialsId: "${target}-integration-credential-encryption-key", variable: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'), - ]) { - def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501') - def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev') - def dbUser = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_user') - writeFile file: ".env.${target}", text: """ -NODE_ENV=${target == 'prod' ? 'production' : target} -PORT=${port} -BUILD_COMMAND=npm run build:${target} -POSTGRES_HOST=postgres -POSTGRES_DB=${dbName} -POSTGRES_USER=${dbUser} -POSTGRES_PASSWORD=${POSTGRES_PASSWORD} -DATABASE_URL=postgresql://${dbUser}:${POSTGRES_PASSWORD}@postgres:5432/${dbName} -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_PASSWORD=${REDIS_PASSWORD} -JWT_SECRET=${JWT_SECRET} -AWS_REGION=us-east-1 -AWS_S3_BUCKET=supporthub-attachments-${target} -AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} -AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} -${target == 'prod' ? '' : 'AWS_S3_ENDPOINT=http://minio:9000'} -INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=${INTEGRATION_CREDENTIAL_ENCRYPTION_KEY} -CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'} -""".stripIndent().trim() - } -} diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 157a40d..4fbdbed 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -27,8 +27,6 @@ services: condition: service_healthy redis: condition: service_healthy - minio: - condition: service_healthy restart: unless-stopped @@ -86,33 +84,6 @@ services: restart: unless-stopped - minio: - image: minio/minio:latest - - container_name: minio-development - - command: server /data --console-address ":9001" - - environment: - MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} - MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - - ports: - - "9000:9000" - - "9001:9001" - - volumes: - - minio_development_data:/data - - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 10 - - restart: unless-stopped - volumes: postgres_development_data: - redis_development_data: - minio_development_data: \ No newline at end of file + redis_development_data: \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 8420131..aeae661 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -27,8 +27,6 @@ services: condition: service_healthy redis: condition: service_healthy - minio: - condition: service_healthy restart: unless-stopped @@ -82,27 +80,6 @@ services: restart: unless-stopped - minio: - image: minio/minio:latest - - command: server /data --console-address ":9001" - - environment: - MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} - MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - - volumes: - - minio_test_data:/data - - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 10 - - restart: unless-stopped - volumes: postgres_test_data: - redis_test_data: - minio_test_data: \ No newline at end of file + redis_test_data: \ No newline at end of file