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 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/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 new file mode 100644 index 0000000..2d4cb8e --- /dev/null +++ b/specs/008-sla-escalation/checklists/requirements.md @@ -0,0 +1,85 @@ +# 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. + +## 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/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..f64a2b3 --- /dev/null +++ b/specs/008-sla-escalation/plan.md @@ -0,0 +1,163 @@ +# 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`, `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 | +| 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/ +│ ├── 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 + +│ │ ├── controller/ routes/ schema/ calculators/ for the day-walk algorithm +│ │ │ repository/ service/ types/ +│ │ │ 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 +│ │ │ 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..6febafd --- /dev/null +++ b/specs/008-sla-escalation/research.md @@ -0,0 +1,240 @@ +# 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-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_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- + escalation endpoint are gated by `fastify.authenticate`, same known-limitation stub as every + prior feature. +- **Rationale**: Consistency with established precedent. +- **Alternatives considered**: None. 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. diff --git a/specs/008-sla-escalation/tasks.md b/specs/008-sla-escalation/tasks.md new file mode 100644 index 0000000..20e6601 --- /dev/null +++ b/specs/008-sla-escalation/tasks.md @@ -0,0 +1,395 @@ +--- +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 + +- [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 +- [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`) +- [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 + +--- + +## 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. + +- [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) +- [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. + +--- + +## 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 + +- [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` +- [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 + +- [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) +- [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) +- [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) +- [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. + +--- + +## 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 + +- [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` +- [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 + existing assignment flow) + +### Implementation for User Story 2 + +- [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) +- [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) +- [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) +- [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) +- [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) +- [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) +- [x] T020 [US2] Add `GET /tickets/:ticketId/sla-run` route (`404` if none) in `sla/controller/` + + `sla/routes/` (depends on T018) +- [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. + +--- + +## 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 + +- [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` +- [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 + +- [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) +- [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) +- [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) +- [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 +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 + +- [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` +- [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 + +- [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) +- [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) +- [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. + +--- + +## 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 + +- [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` +- [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 + in `tests/integration/sla-escalation-firing.test.ts` (depends on T030) + +### Implementation for User Story 5 + +- [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) +- [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) +- [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) +- [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) +- [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) +- [x] 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 + +- [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 + +- [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) +- [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) +- [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 +and manual escalation form one coherent, restart-safe flow. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [ ] 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 +- [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 + +--- + +## 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 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', },