Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da11dbc961 | ||
|
|
d78bf52182 | ||
|
|
20e5493798 | ||
|
|
8400a8db84 | ||
|
|
cd8e408d7e | ||
|
|
c7aac460b7 | ||
|
|
93d6fe8b94 | ||
|
|
794e16f349 | ||
|
|
0e3543bb01 | ||
|
|
58d0a98134 | ||
|
|
1e332143bd | ||
|
|
2fb5b7ac73 | ||
|
|
015ef62b71 | ||
|
|
9f52d51003 | ||
|
|
7106753ed3 | ||
|
|
d65683641a | ||
|
|
814d9d7b17 | ||
|
|
4a159725c2 | ||
|
|
c4a2faa6e3 | ||
|
|
ea50e3596a | ||
|
|
de5915a8c1 | ||
|
|
f75589d9ce | ||
|
|
acd3843aaf | ||
|
|
0135e4ca05 | ||
|
|
5a0fe9f847 | ||
|
|
09ba56d3af | ||
|
|
79bc2ef25b | ||
|
|
3bdccc901f | ||
|
|
65175a85b5 | ||
|
|
52f1fa3db0 | ||
|
|
b016e77b70 | ||
|
|
700daf4104 | ||
|
|
249e7cd0ce | ||
|
|
2034966d6d | ||
|
|
7948182988 | ||
|
|
3bd068b031 | ||
|
|
43158ff0c4 | ||
|
|
2b00b6d6a1 | ||
|
|
49db40d7c1 | ||
|
|
fb9606b6aa | ||
|
|
d574af087a | ||
|
|
23fadebb5c | ||
|
|
d58bc98c7f | ||
|
|
40687f68fa | ||
|
|
8327fafac2 | ||
|
|
3b4c220a45 | ||
|
|
a49389dc2c | ||
|
|
051ee88974 | ||
|
|
177488caf8 | ||
|
|
4e0c90e2cf | ||
|
|
5607fdfcd5 | ||
|
|
ca14d45ff1 | ||
|
|
a5650b1089 | ||
|
|
7e92a1679b | ||
|
|
d9bd970910 | ||
|
|
16daf8d32d | ||
|
|
aaa51ef475 | ||
|
|
9ce34d8ca4 | ||
|
|
14c6793460 | ||
|
|
d902f6b26d | ||
|
|
6a7c6a493e | ||
|
|
6beef9584b | ||
|
|
7e3d2ae29f | ||
|
|
9357f03e1d | ||
|
|
bb31e9d641 | ||
|
|
199bd4eb4e | ||
|
|
1ad9007e79 | ||
|
|
71520e2423 | ||
|
|
10f59a7d5b | ||
|
|
f468dcb6f8 | ||
|
|
fc438d552b | ||
|
|
97c954c6fa | ||
|
|
b44f3d1dd2 | ||
|
|
8ba8cbe4e1 |
@@ -0,0 +1,31 @@
|
||||
NODE_ENV=development
|
||||
PORT=4501
|
||||
|
||||
# Nest build
|
||||
BUILD_COMMAND=npm run build:development
|
||||
|
||||
# Database
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_DB=support_dev
|
||||
POSTGRES_USER=support_user
|
||||
POSTGRES_PASSWORD=z1F3tKF1JNDBQmMq95Up
|
||||
# DATABASE_URL=postgresql://support_user:z1F3tKF1JNDBQmMq95Up@postgres:5432/support_dev
|
||||
|
||||
DATABASE_URL=postgresql://support_user:SupportDev123@localhost:5432/support_dev
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=redis
|
||||
# REDIS_PORT=6379
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=D7FJ7QDKo5gF9KQAO1GL
|
||||
|
||||
# Security & CORS
|
||||
# JWT_SECRET=super-secret-development-jwt-key-32-chars-long
|
||||
# CORS_ORIGINS=https://support-dev.maskantech.in
|
||||
|
||||
# Security & CORS
|
||||
JWT_SECRET=super-secret-development-jwt-key-32-chars-long
|
||||
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=c2e444fe8cc19eb7465e2f8a05f7384628de7a879fcc812766e093c9182fcd58
|
||||
CORS_ORIGINS=https://support-dev.maskantech.in
|
||||
@@ -43,3 +43,6 @@ docker/minio/data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Load-test run reports — measurement artifacts, not fixtures (016-load-concurrency-testing)
|
||||
tests/load/reports/
|
||||
|
||||
Vendored
-226
@@ -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 <env> in [test, prod]:
|
||||
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
|
||||
// <env>-aws-access-key-id, <env>-aws-secret-access-key
|
||||
// <env>-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.<target> 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()
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,22 @@
|
||||
### Stop
|
||||
- docker compose -f docker-compose.prod.yml down
|
||||
|
||||
### list containers
|
||||
### List Containers
|
||||
- docker compose --env-file .env.development -f docker-compose.development.yml ps
|
||||
|
||||
### logs
|
||||
### Logs
|
||||
- docker compose --env-file .env.development -f docker-compose.development.yml logs -f
|
||||
|
||||
### Database Migrations
|
||||
- **Local (using .env.development):**
|
||||
- Create/apply new migration: `npx prisma migrate dev --name <name>`
|
||||
- Push schema directly (prototype/sync): `npx prisma db push`
|
||||
- Deploy pending migrations: `npm run prisma:deploy`
|
||||
- **Inside Docker Container:**
|
||||
- `docker exec -it support-api-development npx prisma migrate deploy`
|
||||
|
||||
### Database Seeding
|
||||
- **Local:**
|
||||
- `npm run prisma:seed` (or `npx tsx --env-file=.env.development prisma/seed/index.ts`)
|
||||
- **Inside Docker Container:**
|
||||
- `docker exec -it support-api-development npm run prisma:seed`
|
||||
|
||||
@@ -27,8 +27,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -43,6 +41,9 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
ports:
|
||||
- "5434:5432"
|
||||
|
||||
volumes:
|
||||
- postgres_development_data:/var/lib/postgresql
|
||||
|
||||
@@ -68,6 +69,9 @@ services:
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD}
|
||||
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
- redis_development_data:/data
|
||||
|
||||
@@ -86,33 +90,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:
|
||||
redis_development_data:
|
||||
+7
-24
@@ -27,8 +27,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -41,6 +39,9 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
- postgres_test_data:/var/lib/postgresql
|
||||
|
||||
@@ -64,6 +65,9 @@ services:
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD}
|
||||
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
- redis_test_data:/data
|
||||
|
||||
@@ -82,27 +86,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:
|
||||
redis_test_data:
|
||||
Generated
+906
-35
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -54,22 +54,33 @@
|
||||
"@fastify/swagger": "^8.14.0",
|
||||
"@fastify/swagger-ui": "^3.0.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.11.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
||||
"@opentelemetry/resources": "^2.11.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.7.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.26.2",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"luxon": "^3.7.2",
|
||||
"pino": "^8.20.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
"prom-client": "^15.1.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/autocannon": "^7.12.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/luxon": "^3.7.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"autocannon": "^8.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.0.11",
|
||||
@@ -77,7 +88,7 @@
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.12.1",
|
||||
"tsc-alias": "^1.9.2",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.0"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,105 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "investigations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"problemId" TEXT NOT NULL,
|
||||
"investigator" TEXT NOT NULL,
|
||||
"findings" JSONB NOT NULL,
|
||||
"evidence" JSONB,
|
||||
"internalNotes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "investigations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "root_causes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"problemId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "root_causes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "solutions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"problemId" TEXT NOT NULL,
|
||||
"proposed" TEXT NOT NULL,
|
||||
"approved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "solutions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "solution_implementations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"solutionId" TEXT NOT NULL,
|
||||
"notes" TEXT,
|
||||
"implementedBy" TEXT NOT NULL,
|
||||
"implementedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "solution_implementations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "solution_verifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"solutionId" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"result" TEXT NOT NULL,
|
||||
"evidence" JSONB,
|
||||
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "solution_verifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "resolutions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"resolvedBy" TEXT NOT NULL,
|
||||
"resolvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "resolutions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "investigations_problemId_createdAt_idx" ON "investigations"("problemId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "root_causes_problemId_createdAt_idx" ON "root_causes"("problemId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "solutions_problemId_createdAt_idx" ON "solutions"("problemId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "solution_implementations_solutionId_key" ON "solution_implementations"("solutionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "solution_verifications_solutionId_key" ON "solution_verifications"("solutionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "resolutions_ticketId_key" ON "resolutions"("ticketId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "solutions" ADD CONSTRAINT "solutions_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "solution_implementations" ADD CONSTRAINT "solution_implementations_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "solution_verifications" ADD CONSTRAINT "solution_verifications_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "resolutions" ADD CONSTRAINT "resolutions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "agents" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- The default above exists only to satisfy the NOT NULL constraint against this (empty)
|
||||
-- table at migration time — application code always provides a real bcryptjs hash on every
|
||||
-- User row it creates (specs/010-identity-auth/data-model.md), so the default itself is
|
||||
-- dropped immediately below to keep schema.prisma and the live database in agreement (no
|
||||
-- default declared in the Prisma schema).
|
||||
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP DEFAULT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agents_userId_key" ON "agents"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agents" ADD CONSTRAINT "agents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "error_code_lookups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"errorCodeId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "error_code_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "error_code_lookups_productId_createdAt_idx" ON "error_code_lookups"("productId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sla_runs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 016-load-concurrency-testing research.md §1: at most one current assignment per ticket,
|
||||
-- enforced at the database level (a partial unique index, since Prisma's schema DSL cannot
|
||||
-- express a WHERE-predicated unique constraint directly).
|
||||
CREATE UNIQUE INDEX "assignments_one_current_per_ticket" ON "assignments"("ticketId") WHERE "isCurrent" = true;
|
||||
|
||||
-- 016-load-concurrency-testing research.md §3: a given escalation rule may fire at most once
|
||||
-- per ticket over that ticket's lifetime (SLARun.ticketId is already @unique — no reopen-cycle
|
||||
-- support, so a rule-triggered breach genuinely cannot recur for the same ticket). Manual
|
||||
-- escalations (rule_id IS NULL) are excluded and remain repeatable.
|
||||
CREATE UNIQUE INDEX "escalation_events_ticket_rule_unique" ON "escalation_events"("ticketId", "ruleId") WHERE "ruleId" IS NOT NULL;
|
||||
+263
-10
@@ -14,12 +14,17 @@ enum UserRole {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String
|
||||
role UserRole @default(CUSTOMER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String
|
||||
role UserRole @default(CUSTOMER)
|
||||
passwordHash String // bcryptjs hash — never the plaintext password; see
|
||||
// specs/010-identity-auth/data-model.md
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
agent Agent?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -40,9 +45,12 @@ model Product {
|
||||
tickets Ticket[]
|
||||
knowledgeEntries KnowledgeEntry[]
|
||||
errorCodes ErrorCode[]
|
||||
errorCodeLookups ErrorCodeLookup[]
|
||||
knownIssues KnownIssue[]
|
||||
runbooks Runbook[]
|
||||
aiConfidencePolicies AIConfidencePolicy[]
|
||||
slaPolicies SLAPolicy[]
|
||||
escalationPolicies EscalationPolicy[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
@@ -90,9 +98,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")
|
||||
}
|
||||
@@ -114,6 +123,10 @@ model Problem {
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
tickets Ticket[]
|
||||
|
||||
investigations Investigation[]
|
||||
rootCauses RootCause[]
|
||||
solutions Solution[]
|
||||
|
||||
@@map("problems")
|
||||
}
|
||||
|
||||
@@ -145,6 +158,9 @@ model Ticket {
|
||||
aiSessions AISupportSession[]
|
||||
assignments Assignment[]
|
||||
assignmentHistory AssignmentHistory[]
|
||||
slaRun SLARun?
|
||||
escalationEvents EscalationEvent[]
|
||||
resolution Resolution?
|
||||
|
||||
@@unique([productId, idempotencyKey])
|
||||
@@index([productId, status])
|
||||
@@ -224,13 +240,31 @@ model ErrorCode {
|
||||
productId String
|
||||
description String
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
knownIssues KnownIssue[]
|
||||
lookups ErrorCodeLookup[]
|
||||
|
||||
@@unique([productId, code])
|
||||
@@map("error_codes")
|
||||
}
|
||||
|
||||
// 015-reporting-dashboards research.md §6: a durable, append-only audit row recording that a
|
||||
// known-error-code lookup happened — 014-full-observability's own equivalent
|
||||
// (supporthub_known_error_lookups_total) is a process-lifetime Prometheus counter, unusable for
|
||||
// a historical "top errors" report. productId is denormalized from errorCode.productId so the
|
||||
// Product dashboard's range query never needs to join back through ErrorCode just to filter.
|
||||
model ErrorCodeLookup {
|
||||
id String @id @default(cuid())
|
||||
errorCodeId String
|
||||
errorCode ErrorCode @relation(fields: [errorCodeId], references: [id])
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([productId, createdAt])
|
||||
@@map("error_code_lookups")
|
||||
}
|
||||
|
||||
model KnownIssue {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
@@ -407,6 +441,11 @@ model Agent {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Nullable link to the login identity this routing/skills profile belongs to — schema
|
||||
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
skills AgentSkill[]
|
||||
availability AgentAvailability?
|
||||
assignments Assignment[]
|
||||
@@ -465,6 +504,8 @@ model HierarchyNode {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
escalationRules EscalationRule[]
|
||||
|
||||
@@index([parentId, order])
|
||||
@@index([active])
|
||||
@@map("hierarchy_nodes")
|
||||
@@ -485,6 +526,7 @@ model Assignment {
|
||||
unassignedAt DateTime?
|
||||
|
||||
@@index([ticketId, isCurrent])
|
||||
@@index([agentId, isCurrent])
|
||||
@@map("assignments")
|
||||
}
|
||||
|
||||
@@ -502,3 +544,214 @@ 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?
|
||||
|
||||
// 016-load-concurrency-testing: optimistic-concurrency counter, identical convention to
|
||||
// Ticket.version (003-ticketing) — guards pause/resume/complete/the breach sweep against
|
||||
// racing each other and silently clobbering this run's state (research.md §2).
|
||||
version Int @default(0)
|
||||
|
||||
@@index([status, resolutionDueAt])
|
||||
@@index([status, firstResponseDueAt])
|
||||
@@map("sla_runs")
|
||||
}
|
||||
|
||||
model BusinessCalendar {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
timezone String // IANA zone name, e.g. "America/New_York"
|
||||
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
|
||||
|
||||
holidays Holiday[]
|
||||
policies SLAPolicy[]
|
||||
|
||||
@@map("business_calendars")
|
||||
}
|
||||
|
||||
model Holiday {
|
||||
id String @id @default(cuid())
|
||||
calendarId String
|
||||
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||
date DateTime // compared by calendar date only, in the calendar's own timezone
|
||||
description String?
|
||||
|
||||
@@index([calendarId, date])
|
||||
@@map("holidays")
|
||||
}
|
||||
|
||||
model EscalationPolicy {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
active Boolean @default(true)
|
||||
|
||||
rules EscalationRule[]
|
||||
|
||||
@@index([productId, active])
|
||||
@@map("escalation_policies")
|
||||
}
|
||||
|
||||
model EscalationRule {
|
||||
id String @id @default(cuid())
|
||||
policyId String
|
||||
policy EscalationPolicy @relation(fields: [policyId], references: [id])
|
||||
|
||||
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
|
||||
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
|
||||
// (research.md)
|
||||
condition Json // stored, not evaluated, by this feature (research.md)
|
||||
|
||||
targetNodeId String
|
||||
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
|
||||
|
||||
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
|
||||
active Boolean @default(true)
|
||||
|
||||
@@index([policyId, triggerType, active])
|
||||
@@map("escalation_rules")
|
||||
}
|
||||
|
||||
model EscalationEvent {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
|
||||
ruleId String? // null for a manual escalation or a breach with no matching rule
|
||||
fromNodeId String?
|
||||
toNodeId String?
|
||||
|
||||
reason String
|
||||
triggeredBy String // system | <agentId> | <adminId>
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([ticketId, createdAt])
|
||||
@@map("escalation_events")
|
||||
}
|
||||
|
||||
model Investigation {
|
||||
id String @id @default(cuid())
|
||||
problemId String
|
||||
problem Problem @relation(fields: [problemId], references: [id])
|
||||
investigator String
|
||||
findings Json
|
||||
evidence Json?
|
||||
internalNotes String? // never exposed on a customer-facing read — see
|
||||
// specs/009-problem-resolution/spec.md FR-003
|
||||
status String @default("open") // open | complete
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([problemId, createdAt])
|
||||
@@map("investigations")
|
||||
}
|
||||
|
||||
model RootCause {
|
||||
id String @id @default(cuid())
|
||||
problemId String
|
||||
problem Problem @relation(fields: [problemId], references: [id])
|
||||
type String // technical | configuration | external_dependency | business |
|
||||
// contributing_factor
|
||||
description String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([problemId, createdAt])
|
||||
@@map("root_causes")
|
||||
}
|
||||
|
||||
model Solution {
|
||||
id String @id @default(cuid())
|
||||
problemId String
|
||||
problem Problem @relation(fields: [problemId], references: [id])
|
||||
proposed String
|
||||
approved Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
implementation SolutionImplementation?
|
||||
verification SolutionVerification?
|
||||
|
||||
@@index([problemId, createdAt])
|
||||
@@map("solutions")
|
||||
}
|
||||
|
||||
model SolutionImplementation {
|
||||
id String @id @default(cuid())
|
||||
solutionId String @unique
|
||||
solution Solution @relation(fields: [solutionId], references: [id])
|
||||
notes String?
|
||||
implementedBy String
|
||||
implementedAt DateTime @default(now())
|
||||
|
||||
@@map("solution_implementations")
|
||||
}
|
||||
|
||||
model SolutionVerification {
|
||||
id String @id @default(cuid())
|
||||
solutionId String @unique
|
||||
solution Solution @relation(fields: [solutionId], references: [id])
|
||||
method String // automated | technical_test | customer_confirmation | agent_confirmation
|
||||
result String // success | failed
|
||||
evidence Json?
|
||||
verifiedAt DateTime @default(now())
|
||||
|
||||
@@map("solution_verifications")
|
||||
}
|
||||
|
||||
model Resolution {
|
||||
id String @id @default(cuid())
|
||||
ticketId String @unique
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
outcome String
|
||||
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
|
||||
resolvedAt DateTime @default(now())
|
||||
|
||||
@@map("resolutions")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export async function seedCategories(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(' -> Seeding baseline product categories...');
|
||||
console.log(' Seeding baseline product categories...');
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { externalProductId: 'CORE_PLATFORM' },
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaClient, UserRole } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(' -> Seeding demo environment data...');
|
||||
|
||||
// Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is
|
||||
// never a real login identity (customer identity is exclusively SaaS-delegated, see
|
||||
// specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy
|
||||
// the column's NOT NULL constraint; this account can never authenticate via /auth/login.
|
||||
await prisma.user.upsert({
|
||||
where: { email: 'john.doe@example.com' },
|
||||
update: {},
|
||||
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||
email: 'john.doe@example.com',
|
||||
name: 'John Doe (Demo Customer)',
|
||||
role: UserRole.CUSTOMER,
|
||||
passwordHash: await bcrypt.hash(randomUUID(), 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { PrismaClient, UserRole } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) —
|
||||
// never used for a real deployment, which provisions its own first admin out of band.
|
||||
const DEV_ADMIN_PASSWORD = 'ChangeMe123!';
|
||||
const DEV_AGENT_PASSWORD = 'ChangeMe123!';
|
||||
|
||||
export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
email: 'admin@supporthub.internal',
|
||||
name: 'System Admin',
|
||||
role: UserRole.ADMIN,
|
||||
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||
email: 'agent@supporthub.internal',
|
||||
name: 'Default Support Agent',
|
||||
role: UserRole.AGENT,
|
||||
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 \| <agentId> \| <adminId>` — 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.
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
# Specification Quality Checklist: Problem Resolution
|
||||
|
||||
**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 9 per `docs/10-implementation-roadmap.md`: Investigation → Root Cause →
|
||||
Solution → Solution Implementation → Solution Verification → Resolution, plus customer
|
||||
confirmation and reopen — the full doc 04 §3-9 workflow narrative, matching doc 06's "Domain:
|
||||
Problem Resolution" schema exactly (no new fields invented beyond what's already documented).
|
||||
- `src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}`
|
||||
are the five real target stub directories for this feature (each currently a one-file stub
|
||||
returning a hardcoded placeholder). `src/modules/problem-management/problems` was found to be a
|
||||
**dead, unwired duplicate scaffold** for `Problem` — the real, actively-used `Problem` model and
|
||||
repository already live in `ticketing/tickets` since 003 — this feature does not touch
|
||||
`problem-management/problems`, matching this session's established discipline of only replacing
|
||||
stubs a documented phase's roadmap item actually calls for.
|
||||
- This feature explicitly closes a loop 008-sla-escalation's own spec.md left open in its Edge
|
||||
Cases: "reopening... may need its own SLA-run-restart decision" — resolved here as "no new SLA
|
||||
run on reopen" (FR-018), keeping 008's already-shipped 1:1-with-first-assignment boundary
|
||||
unchanged rather than reopening (no pun intended) that feature's own scope.
|
||||
- Verification-failure escalation deliberately reuses 003/007's existing `HUMAN_ESCALATION`
|
||||
transition rather than inventing a new escalation-rule trigger type in 008's system — flagged
|
||||
explicitly in Assumptions as a scope decision, not an oversight.
|
||||
- All items pass; no revision iterations were needed.
|
||||
|
||||
## Implementation Notes (added during /speckit-implement)
|
||||
|
||||
- `fastify.authenticateProductIntegration` (002) turned out to unconditionally require a full
|
||||
ticket-creation-shaped body (`source`/`problem` included) — reusing it as planned for
|
||||
confirm-resolution/reopen made every call fail validation before token verification ran. Fixed
|
||||
by extracting the shared verification logic (everything after the body's own shape is known)
|
||||
into `verifyIntegrationIdentity` in `product-integration-auth.plugin.ts`, and adding a new,
|
||||
narrower `identityOnlyRequestSchema` (`{productId, tenantId, userId}`) plus a new
|
||||
`authenticateProductIntegrationIdentity` decorator built on the same shared function — purely
|
||||
additive, `POST /v1/support/requests`'s own behavior is unchanged.
|
||||
- Two pre-existing scaffold gaps were closed for this feature's FK validation needs:
|
||||
`TicketsRepository` gained `findPendingCustomerConfirmationOlderThan` (the auto-close sweep's
|
||||
own query), and `ticketsRepository`/`TicketsRepository` are now exported from
|
||||
`ticketing/tickets`'s public `index.ts` (same "extend an existing module's public surface"
|
||||
precedent as `problemsRepository` before it).
|
||||
- Running this feature's own integration suite alongside 008's surfaced a real test-data-hygiene
|
||||
bug in 008's already-committed test file: its second hierarchy node used `productScope: []`
|
||||
(a wildcard matching *every* product, per `HierarchyNode`'s own documented scope-matching rule)
|
||||
purely to have a valid, different target node for its own scoped-escalation test — but since
|
||||
every test file's tickets share one live Postgres database, that wildcard node (and, similarly,
|
||||
008's intentionally-global `SLAPolicy` test fixture) silently affected *other* files' tickets
|
||||
running in the same suite, including this feature's own. Fixed by scoping that node to its own
|
||||
test's product (it never needed to be global) and by deactivating the global `SLAPolicy`
|
||||
fixture immediately after the one scenario that needs it, rather than leaving it live for the
|
||||
rest of the file's run — both fixes are to `tests/integration/sla-escalation-flow.test.ts`
|
||||
only, no production code changed. Full regression (`tests/unit` + `tests/integration` together,
|
||||
172 tests) is clean except the 2 pre-existing MinIO-dependent attachment failures.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Contract: Problem Resolution
|
||||
|
||||
Agent-facing write routes are gated by `fastify.authenticate` (known limitation inherited from
|
||||
002-008). Customer-facing routes are gated by `fastify.authenticateProductIntegration` +
|
||||
`fastify.checkIntegrationRateLimit` (002's inbound trust boundary, research.md) and additionally
|
||||
verify the caller's token identifies the same tenant/user as the ticket's own recorded
|
||||
`externalTenantId`/`externalUserId` — a `403` if they don't match.
|
||||
|
||||
## Investigation
|
||||
|
||||
- `POST /admin/problems/:problemId/investigations` — body `{ investigator, findings, evidence?,
|
||||
internalNotes?, status? }` (`status` defaults to `open`). `404` if `problemId` doesn't exist.
|
||||
- `GET /admin/problems/:problemId/investigations` — every investigation for the problem, newest
|
||||
first, including `internalNotes` (agent-facing).
|
||||
- `GET /problems/:problemId/investigations` — customer/public-safe variant: same list, with
|
||||
`internalNotes` always omitted (FR-003).
|
||||
|
||||
## Root Cause
|
||||
|
||||
- `POST /admin/problems/:problemId/root-causes` — body `{ type, description }`. `400` if `type`
|
||||
isn't one of the five validated values. `409` if no investigation exists yet for the problem.
|
||||
|
||||
## Solution
|
||||
|
||||
- `POST /admin/problems/:problemId/solutions` — body `{ proposed }`. `409` if no root cause
|
||||
exists yet for the problem.
|
||||
- `PATCH /admin/solutions/:solutionId/approve` — sets `approved: true`.
|
||||
- `POST /admin/solutions/:solutionId/implementation` — body `{ notes?, implementedBy }`. `409` if
|
||||
the solution isn't approved, or already has an implementation.
|
||||
- `POST /admin/solutions/:solutionId/verification` — body `{ method, result, evidence? }`. `400`
|
||||
if `method` isn't one of the four validated values. `409` if the solution has no implementation
|
||||
yet, or already has a verification.
|
||||
|
||||
## Resolution
|
||||
|
||||
- `POST /admin/tickets/:ticketId/resolution` — body `{ outcome, resolvedBy }`. `409` if the
|
||||
ticket's problem has no solution with a successful verification. Transitions the ticket to
|
||||
`RESOLUTION_PENDING_CUSTOMER` on success.
|
||||
- `POST /v1/support/tickets/:ticketId/confirm-resolution` — customer-facing (trust boundary
|
||||
above). `409` if the ticket isn't in `RESOLUTION_PENDING_CUSTOMER`. Transitions to `RESOLVED`.
|
||||
|
||||
## Reopen
|
||||
|
||||
- `POST /v1/support/tickets/:ticketId/reopen` — customer-facing. `409` if the ticket isn't
|
||||
`RESOLVED` or `CLOSED`.
|
||||
- `POST /admin/tickets/:ticketId/reopen` — agent-facing, same precondition.
|
||||
|
||||
Both reopen routes transition `RESOLVED|CLOSED → REOPENED → IN_PROGRESS` (research.md's two-hop
|
||||
decision) and touch nothing else — no new `SLARun`, no mutation of any prior investigation/root-
|
||||
cause/solution/verification/resolution record (FR-018, SC-005).
|
||||
|
||||
## Guarantees (callable contract)
|
||||
|
||||
1. **Every investigation/root-cause/solution/implementation/verification/resolution record,
|
||||
once created, is retrievable exactly as given and is never silently overwritten by a later
|
||||
action in the same problem's lifecycle** (SC-001).
|
||||
2. **`internalNotes` never appears in a customer-facing investigation read**, verified by a
|
||||
direct comparison against the agent-facing read of the same record (SC-002).
|
||||
3. **A `Resolution` can never be recorded without a successfully verified solution already on
|
||||
file for the ticket's problem** (SC-003).
|
||||
4. **A ticket in `RESOLUTION_PENDING_CUSTOMER` with no explicit confirmation reaches `RESOLVED`
|
||||
within one auto-close job cycle of its configured waiting period elapsing** (SC-004).
|
||||
5. **Reopening a ticket leaves every prior problem-resolution record and its `SLARun` (008)
|
||||
untouched** (SC-005).
|
||||
6. **A verification failure choosing escalation moves the ticket to `HUMAN_ESCALATION` through
|
||||
003's existing state machine, and 007's orchestration re-runs automatically from that
|
||||
transition alone** — no new escalation mechanism is introduced by this feature.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Data Model: Problem Resolution
|
||||
|
||||
Every model below matches `docs/06-database-schema.md` "Domain: Problem Resolution" field-for-
|
||||
field — no new columns invented (research.md explains the two places this was deliberately
|
||||
considered and rejected: `Resolution.solutionId`, `Investigation.isCurrent`).
|
||||
|
||||
## Investigation
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `problemId` | `String` | FK to `Problem.id` (the existing `ticketing/tickets` one) |
|
||||
| `investigator` | `String` | agentId — same non-FK free-text convention as `TicketMessage.authorRef` |
|
||||
| `findings` | `Json` | structured, not free text (doc 04 §4) |
|
||||
| `evidence` | `Json?` | |
|
||||
| `internalNotes` | `String?` | never exposed on any customer-facing read (FR-003) |
|
||||
| `status` | `String` | `open \| complete` |
|
||||
| `createdAt` | `DateTime @default(now())` | ordering field for "most recent investigation" (research.md — no `isCurrent` flag) |
|
||||
|
||||
## RootCause
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `problemId` | `String` | FK to `Problem.id` |
|
||||
| `type` | `String` | `technical \| configuration \| external_dependency \| business \| contributing_factor` — validated, not free text (FR-005) |
|
||||
| `description` | `String` | |
|
||||
| `createdAt` | `DateTime @default(now())` | |
|
||||
|
||||
## Solution
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `problemId` | `String` | FK to `Problem.id` |
|
||||
| `proposed` | `String` | |
|
||||
| `approved` | `Boolean @default(false)` | explicit approval action (FR-007) |
|
||||
| `createdAt` | `DateTime @default(now())` | |
|
||||
| `implementation` | `SolutionImplementation?` | inverse of the 1:1 below |
|
||||
| `verification` | `SolutionVerification?` | inverse of the 1:1 below |
|
||||
|
||||
## SolutionImplementation
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `solutionId` | `String @unique` | 1:1 with `Solution` — a second implementation attempt is rejected (FR-007 Edge Cases), not overwritten |
|
||||
| `notes` | `String?` | |
|
||||
| `implementedBy` | `String` | agentId |
|
||||
| `implementedAt` | `DateTime @default(now())` | |
|
||||
|
||||
## SolutionVerification
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `solutionId` | `String @unique` | 1:1 with `Solution` — at most one verification per solution (data-model note in Edge Cases) |
|
||||
| `method` | `String` | `automated \| technical_test \| customer_confirmation \| agent_confirmation` — validated (FR-011) |
|
||||
| `result` | `String` | `success \| failed` |
|
||||
| `evidence` | `Json?` | |
|
||||
| `verifiedAt` | `DateTime @default(now())` | |
|
||||
|
||||
## Resolution
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `ticketId` | `String @unique` | one resolution per ticket |
|
||||
| `outcome` | `String` | |
|
||||
| `resolvedBy` | `String` | `"ai"` or agentId |
|
||||
| `resolvedAt` | `DateTime @default(now())` | |
|
||||
|
||||
No `solutionId` FK here (research.md) — the "a successfully verified solution exists for this
|
||||
ticket's problem" precondition (FR-014) is enforced by the service layer at write time via a
|
||||
join through `Ticket.problemId → Solution.problemId → Solution.verification.result`, not stored.
|
||||
|
||||
## Relations added to existing models
|
||||
|
||||
- `Problem.investigations Investigation[]`, `Problem.rootCauses RootCause[]`,
|
||||
`Problem.solutions Solution[]` (all on the existing `ticketing/tickets`-owned `Problem` model)
|
||||
- `Ticket.resolution Resolution?` (inverse of `Resolution.ticketId @unique`)
|
||||
|
||||
## Validation chain (service layer, not DB constraints — matches 003's own state-machine convention)
|
||||
|
||||
1. `RootCause` create → `Problem` must have at least one `Investigation` (FR-006).
|
||||
2. `Solution` create → `Problem` must have at least one `RootCause` (FR-009).
|
||||
3. `SolutionImplementation` create → the `Solution` must have `approved: true` (FR-008), and must
|
||||
not already have an implementation (unique constraint surfaces this as a conflict).
|
||||
4. `SolutionVerification` create → the `Solution` must already have a `SolutionImplementation`
|
||||
(verification is of something implemented, doc 04 §7).
|
||||
5. `Resolution` create → the `Ticket`'s `Problem` must have at least one `Solution` whose
|
||||
`verification.result === 'success'` (FR-014).
|
||||
|
||||
## Out of scope for this data model (per spec.md Assumptions)
|
||||
|
||||
- No new `EscalationRule.triggerType` value for verification failure (research.md — reuses the
|
||||
plain `HUMAN_ESCALATION` status transition instead).
|
||||
- No `ResolutionPolicy`/scoped auto-close configuration entity — one system-wide config value
|
||||
(research.md).
|
||||
@@ -0,0 +1,134 @@
|
||||
# Implementation Plan: Problem Resolution
|
||||
|
||||
**Branch**: `009-problem-resolution` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/009-problem-resolution/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Populate the five real `problem-management/{investigation,root-causes,solutions,resolutions,
|
||||
verification}` stubs (each currently a one-file placeholder — `getInvestigationStatus` always
|
||||
`PENDING`, `getResolutions` always `[]`, etc.) with the real doc-04-workflow engine: a strict
|
||||
existence chain from investigation through root cause, solution, implementation, and
|
||||
verification; a `Resolution` record gated on a successfully verified solution, moving the ticket
|
||||
to `RESOLUTION_PENDING_CUSTOMER`; explicit customer confirmation (reusing 002's inbound trust
|
||||
boundary) or a durable auto-close sweep (reusing the unregistered `CLEANUP` queue stub) into
|
||||
`RESOLVED`; and a reopen path (customer or agent) that re-enters `IN_PROGRESS` through 003's
|
||||
existing `REOPENED` state without touching any prior record or 008's `SLARun`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+.
|
||||
|
||||
**Primary Dependencies**: Prisma (new models), Zod, BullMQ (reused `CLEANUP` queue). No new
|
||||
runtime dependency.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma (new `Investigation`, `RootCause`, `Solution`,
|
||||
`SolutionImplementation`, `SolutionVerification`, `Resolution` models). Reuses
|
||||
`src/infrastructure/queue` for the auto-close sweep, same as 008's breach-detection job.
|
||||
|
||||
**Testing**: Vitest — unit tests for the existence-chain validation logic and the auto-close
|
||||
due-window predicate; integration tests for the full sequential workflow (investigation through
|
||||
resolution), the customer-confirmation and auto-close paths, and reopen leaving prior records and
|
||||
an `SLARun` untouched.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Populates
|
||||
`src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}/`.
|
||||
Adds two new customer-facing routes under `/v1/support/tickets/:ticketId/...` alongside the
|
||||
existing `POST /v1/support/requests` (002).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Constraints**: MUST reject out-of-order writes (root cause before investigation, etc. — FR-006/
|
||||
FR-008/FR-009); MUST NOT expose `internalNotes` on any customer-facing read (FR-003); MUST gate
|
||||
`Resolution` on a real successful verification (FR-014); MUST auto-close durably, not via an
|
||||
in-memory timer (FR-016, Constitution Principle VII); MUST NOT create a new `SLARun` on reopen
|
||||
(FR-018).
|
||||
|
||||
**Scale/Scope**: Five populated modules, one new BullMQ repeatable job (reusing an existing
|
||||
queue), two new customer-facing routes reusing 002's trust boundary, one new agent-facing reopen
|
||||
route. Explicitly excludes: a rendered customer confirmation UI (010's territory), a new
|
||||
escalation-rule trigger type for verification failure (reuses 003/007's existing transition
|
||||
instead), per-scope auto-close policy (one system-wide config value).
|
||||
|
||||
## 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 | Customer-facing routes authenticate via 002's product-integration token, never a SupportHub-native customer login — and additionally verify the token's tenant/user matches the ticket's own recorded values. | PASS |
|
||||
| II. Configuration Over Hardcoding | The auto-close waiting period is env-configured (research.md), never a hardcoded number; validated-value sets (root-cause type, verification method) are Zod-enforced closed lists matching doc 04's own documented values, not ad hoc. | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | Five modules follow the standard shape; each references `ticketing/tickets`'s `Problem` (one-directional, already established), and the verification-failure-escalation path calls `ticketsService.updateStatus` directly rather than reaching into 008's `EscalationService` — no new module dependency edge into 008 at all. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | `Resolution.resolvedBy` accepts `"ai"` per doc 06's own shape, but this feature adds no AI-driven decision logic of its own — every gate (approval, verification result, escalate-vs-reinvestigate) is an explicit human/deterministic action. | PASS |
|
||||
| V. Evidence-Based Verification | This principle's own domain — `SolutionVerification.evidence`/`Investigation.evidence` are exactly the durable evidence records Principle V requires before a resolution is trusted. | PASS |
|
||||
| VI. Durable Audit & History | Every investigation attempt is its own preserved row (never overwritten); reopen produces two real, separately-audited status transitions rather than one collapsed hop. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Auto-close is a repeatable BullMQ job querying durable DB state (`Ticket.status`/`updatedAt`), never an in-memory timer — same discipline 008's breach-detection sweep already established. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | This principle's own domain — every investigation/root-cause/solution record is scoped to `Problem`, never `Ticket`, while `Resolution` (necessarily ticket-scoped, since a shared `Problem` could span multiple tickets) is the one exception doc 06 itself defines. | PASS |
|
||||
| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure only, no new dependency. | 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 VIII
|
||||
explicitly: `Resolution.ticketId` (not `problemId`) is the one place in this whole feature where
|
||||
a record is ticket-scoped rather than problem-scoped — a deliberate, doc-06-defined exception
|
||||
(a shared `Problem` can have multiple tickets, each needing its own outcome), not an
|
||||
inconsistency with the rest of this feature's problem-scoped chain.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/009-problem-resolution/
|
||||
├── 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/
|
||||
├── prisma/
|
||||
│ └── schema.prisma # MODIFIED — add Investigation, RootCause,
|
||||
│ Solution, SolutionImplementation,
|
||||
│ SolutionVerification, Resolution
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── problem-resolution.ts # NEW — autoCloseWaitingHours
|
||||
│ ├── jobs/
|
||||
│ │ └── cleanup/index.ts # REPLACED stub — schedules the repeatable
|
||||
│ │ auto-close sweep (research.md)
|
||||
│ └── modules/
|
||||
│ ├── ticketing/tickets/ # MODIFIED — reopen calls updateStatus twice
|
||||
│ └── problem-management/
|
||||
│ ├── problems/ # UNTOUCHED — dead duplicate scaffold
|
||||
│ │ (research.md) — not this feature's Problem
|
||||
│ ├── investigation/ # REPLACED stub — full standard shape
|
||||
│ ├── root-causes/ # REPLACED stub — full standard shape
|
||||
│ ├── solutions/ # REPLACED stub — full standard shape
|
||||
│ ├── verification/ # REPLACED stub — full standard shape
|
||||
│ └── resolutions/ # REPLACED stub — full standard shape,
|
||||
│ including the auto-close sweep + the two
|
||||
│ new customer-facing routes
|
||||
└── tests/
|
||||
├── unit/problem-management/ # existence-chain validation, auto-close
|
||||
│ due-window predicate
|
||||
└── integration/ # full sequential workflow, customer
|
||||
confirmation, auto-close, reopen
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project. Every module gets the full standard shape (each has its
|
||||
own real CRUD/read surface, unlike 007's internal-only `routing`) — matching 008's precedent for
|
||||
a multi-module feature where every module has genuine callers beyond another module in the same
|
||||
feature.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,72 @@
|
||||
# Quickstart: Validating Problem Resolution
|
||||
|
||||
Prerequisites: migrations applied; a ticket created per 003-ticketing's own quickstart (this
|
||||
feature works against its `problemId`).
|
||||
|
||||
## Scenario 1 — structured investigation, preserved across attempts (User Story 1)
|
||||
|
||||
1. `POST /admin/problems/:problemId/investigations` with findings/evidence/internalNotes.
|
||||
**Expected**: `201`, retrievable via `GET /admin/problems/:problemId/investigations` with
|
||||
every field intact.
|
||||
2. `GET /problems/:problemId/investigations` (customer-safe variant). **Expected**: same rows,
|
||||
`internalNotes` absent from every one.
|
||||
3. Record a second investigation for the same problem. **Expected**: both rows remain, in order —
|
||||
the first is never overwritten.
|
||||
|
||||
## Scenario 2 — root cause requires an investigation on file (User Story 2)
|
||||
|
||||
1. `POST /admin/problems/:problemId/root-causes` for a problem with no investigation.
|
||||
**Expected**: `409`.
|
||||
2. Repeat after Scenario 1's investigation exists. **Expected**: `201`, `type` one of the five
|
||||
validated values.
|
||||
3. Repeat with an invalid `type`. **Expected**: `400`.
|
||||
|
||||
## Scenario 3 — solution proposed, approved, implemented as distinct states (User Story 3)
|
||||
|
||||
1. `POST /admin/problems/:problemId/solutions` before any root cause exists. **Expected**: `409`.
|
||||
2. Repeat after Scenario 2's root cause exists. **Expected**: `201`, `approved: false`.
|
||||
3. `POST /admin/solutions/:solutionId/implementation` before approval. **Expected**: `409`.
|
||||
4. `PATCH /admin/solutions/:solutionId/approve`, then repeat step 3. **Expected**: `201`.
|
||||
5. Repeat step 3 again (a second implementation). **Expected**: `409`.
|
||||
|
||||
## Scenario 4 — verification, and what happens on failure (User Story 4)
|
||||
|
||||
1. `POST /admin/solutions/:solutionId/verification` with `result: success`. **Expected**: `201`.
|
||||
2. On a different solution (Scenario 3 repeated for a fresh problem), verify with
|
||||
`result: failed`. **Expected**: `201`, but no `Resolution` can be recorded referencing it
|
||||
(Scenario 5, step 1).
|
||||
3. On the failed-verification path, request a fresh investigation. **Expected**: a new
|
||||
`Investigation` row for the same problem, the original untouched.
|
||||
4. On the failed-verification path, request escalation instead. **Expected**: the ticket
|
||||
transitions to `HUMAN_ESCALATION`, and (007) is automatically assigned from that transition
|
||||
alone — no separate escalation call needed.
|
||||
|
||||
## Scenario 5 — resolution, customer confirmation, and auto-close (User Story 5)
|
||||
|
||||
1. `POST /admin/tickets/:ticketId/resolution` for a ticket whose problem has no successfully
|
||||
verified solution. **Expected**: `409`.
|
||||
2. Repeat once Scenario 4 step 1's successful verification exists. **Expected**: `201`, ticket
|
||||
status becomes `RESOLUTION_PENDING_CUSTOMER`.
|
||||
3. `POST /v1/support/tickets/:ticketId/confirm-resolution` with the customer's own token.
|
||||
**Expected**: `200`, ticket status becomes `RESOLVED`.
|
||||
4. Repeat steps 1-2 for a second ticket; instead of confirming, directly age the ticket's
|
||||
`updatedAt` past the configured waiting period and run the auto-close sweep.
|
||||
**Expected**: ticket status becomes `RESOLVED` without any explicit confirmation call.
|
||||
|
||||
## Scenario 6 — reopen (User Story 6)
|
||||
|
||||
1. `POST /v1/support/tickets/:ticketId/reopen` on the `RESOLVED` ticket from Scenario 5.
|
||||
**Expected**: `200`, ticket status becomes `IN_PROGRESS` (via `REOPENED`).
|
||||
2. `GET /tickets/:ticketId/resolution` (or the admin equivalent). **Expected**: the original
|
||||
`Resolution` record is still present, unchanged.
|
||||
3. If the ticket has an `SLARun` (008) from its original assignment, **Expected**: it is
|
||||
unchanged — no new run created, its status exactly what it was before the reopen.
|
||||
4. `POST /admin/tickets/:ticketId/reopen` on a `CLOSED` ticket, as an agent. **Expected**: same
|
||||
`REOPENED → IN_PROGRESS` result, this time attributed to the agent, not `"customer"`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All six scenarios pass, together demonstrating every functional requirement and success
|
||||
criterion in `spec.md` — including SC-004's auto-close job cycle and SC-005's "reopen touches
|
||||
nothing else" guarantee, both of which need direct-DB-state manipulation (not just waiting) to
|
||||
verify without a multi-hour real-time test run.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Phase 0 Research: Problem Resolution
|
||||
|
||||
## Decision: Module placement — five real stubs; `problem-management/problems` is dead scaffold, left untouched
|
||||
|
||||
- **Decision**: `problem-management/{investigation,root-causes,solutions,resolutions,verification}`
|
||||
(each a one-file, hardcoded-placeholder stub) are populated directly. `problem-management/
|
||||
problems` — a second, never-wired `ProblemsRepository.findAll()` returning `[]` — is left
|
||||
exactly as-is; it is not this feature's `Problem` (that one has lived in, and been used since,
|
||||
`ticketing/tickets/repository/problems.repository.ts`, created by 003-ticketing).
|
||||
- **Rationale**: Every real caller of `Problem` (003's ticket creation, 005's AI diagnosis, 007's
|
||||
routing context, this feature's own investigation/root-cause/solution FKs) already resolves it
|
||||
through `ticketing/tickets`'s repository. `problem-management/problems` was never imported by
|
||||
anything (confirmed by search) — a leftover from the original pre-spec-driven scaffold, the same
|
||||
class of dead placeholder this codebase's discipline is to leave alone unless a documented
|
||||
phase's roadmap item actually names it. Phase 9's own roadmap line names Investigation/
|
||||
RootCause/Solution/.../Resolution, not a second Problem implementation.
|
||||
- **Alternatives considered**: Migrating `Problem` into `problem-management/problems` and
|
||||
re-pointing every existing caller — rejected as an unrequested, high-blast-radius refactor of
|
||||
working code three prior features already depend on, for a rename with no functional benefit.
|
||||
|
||||
## Decision: Investigation is version-row-per-attempt, matching 004/007's established pattern
|
||||
|
||||
- **Decision**: Every investigation (the first one, and any created after a failed verification,
|
||||
FR-013) is its own `Investigation` row for the same `problemId` — never an update to a prior
|
||||
row. "Which investigation is current" for a problem is simply the most recent by `createdAt`.
|
||||
- **Rationale**: Doc 06's `Investigation` model has no version/current-row field at all (unlike
|
||||
`KnowledgeEntry.isCurrentVersion` or `Assignment.isCurrent`) — the simplest reading consistent
|
||||
with "each investigation attempt is real, preserved history" (spec.md US1) is an unbounded,
|
||||
append-only set of rows per problem, ordered by `createdAt`, with no additional schema needed.
|
||||
- **Alternatives considered**: Adding an `isCurrent` boolean to `Investigation` (mirroring 007's
|
||||
refinement of `Assignment`) — rejected as unrequested schema embellishment; nothing in spec.md
|
||||
requires querying "the current investigation" faster than an `orderBy: createdAt desc, take: 1`
|
||||
already provides, and doc 06 doesn't define the field.
|
||||
|
||||
## Decision: A strict existence chain — investigation → root cause → solution → implementation → verification
|
||||
|
||||
- **Decision**: Each write validates its own prerequisite exists for the same `problemId`
|
||||
(root cause requires an investigation; solution requires a root cause) or the same `solutionId`
|
||||
(implementation requires an approved solution; verification requires an implementation) —
|
||||
resolve-or-reject, the same "don't invent a default, don't skip a step" discipline this
|
||||
codebase has used for every other FK-shaped precondition since 002.
|
||||
- **Rationale**: Doc 04 §4-8 describes a strictly sequential workflow ("Investigation → Root
|
||||
Cause → Solution → Verification → Resolution") — the acceptance scenarios (spec.md US2-US4)
|
||||
explicitly test that skipping a step is rejected, not silently tolerated.
|
||||
- **Alternatives considered**: Allowing any order and only validating at Resolution time —
|
||||
rejected; doc 04's own workflow diagram is sequential by design, and rejecting out-of-order
|
||||
writes early gives a caller a much clearer error than a late rejection at the final step.
|
||||
|
||||
## Decision: `Resolution` has no stored FK back to `Solution` — matches doc 06's shape exactly
|
||||
|
||||
- **Decision**: `Resolution` is validated at write time (a successfully verified solution must
|
||||
exist for the ticket's `problemId`) but the `Resolution` row itself stores no `solutionId` —
|
||||
doc 06's own `Resolution` model has no such field (`id, ticketId @unique, outcome, resolvedBy,
|
||||
resolvedAt` only).
|
||||
- **Rationale**: Not a gap to fill — the existence check is enforced by the service layer at
|
||||
write time (the same "validate at the boundary, don't over-model the schema" approach 002/003
|
||||
already use for non-FK cross-references like `TicketMessage.authorRef`), and doc 06 is
|
||||
explicit about what `Resolution` stores. Inventing a FK doc 06 doesn't define would be scope
|
||||
creep, not correctness.
|
||||
- **Alternatives considered**: Adding `solutionId` to `Resolution` as an additive refinement
|
||||
(this codebase's own established pattern for filling real gaps, e.g. 008's
|
||||
`firstResponseBreachedAt`) — considered and rejected specifically here, since unlike 008's gap
|
||||
(a genuinely missing idempotency guard with no other way to express it), the existence check
|
||||
this feature needs is fully satisfiable without a stored reference — a real refinement changes
|
||||
*behavior*; this one would only change provenance-tracing convenience nothing in spec.md asks
|
||||
for.
|
||||
|
||||
## Decision: Verification-failure escalation reuses 003/007's `HUMAN_ESCALATION` transition directly
|
||||
|
||||
- **Decision**: When an agent chooses escalation on a failed verification (FR-013), this feature
|
||||
calls `ticketsService.updateStatus(ticketId, 'HUMAN_ESCALATION', ...)` — the same transition
|
||||
001-caliber tickets already support — and does nothing else. 007's existing `TICKET_UPDATED`
|
||||
subscriber (`src/events/handlers/index.ts`) picks this up and runs orchestration automatically,
|
||||
exactly as it does for every other route into `HUMAN_ESCALATION`.
|
||||
- **Rationale**: "Solution verification failed" is not one of doc 05 §6's ten escalation-rule
|
||||
trigger types 008 already modeled (`first_response_breach | resolution_breach | inactivity |
|
||||
priority_increase | customer_escalation | repeated_reopen | manual | product_defect |
|
||||
dependency_timeout | critical_incident`) — inventing an eleventh type, a new `EscalationEvent`,
|
||||
and a new call into 008's `EscalationService` for one internal flow this feature owns would be
|
||||
real, unrequested coupling across a module boundary 008 was deliberately built not to need.
|
||||
Reusing the plain status transition is exactly the mechanism 007 already exists to react to.
|
||||
- **Alternatives considered**: Adding `solution_verification_failed` as an eleventh
|
||||
`EscalationRule.triggerType` and calling 008's `EscalationService.handleBreach`-equivalent —
|
||||
rejected; 008 is already shipped and committed with a closed, deliberately-bounded set of two
|
||||
real trigger types (spec.md 008 Assumptions) — retroactively expanding it from within a later
|
||||
feature, for a flow that doesn't need the rule-matching machinery at all (there's exactly one
|
||||
outcome: HUMAN_ESCALATION, not "evaluate every matching rule"), is unjustified complexity.
|
||||
|
||||
## Decision: Customer-facing confirm-resolution and reopen reuse 002's trust boundary via a new, narrower `authenticateProductIntegrationIdentity` decorator; agent reopen uses `fastify.authenticate`
|
||||
|
||||
- **Decision**: Two new customer-reachable routes, `POST /v1/support/tickets/:ticketId/confirm-
|
||||
resolution` and `POST /v1/support/tickets/:ticketId/reopen`, are gated by a new
|
||||
`fastify.authenticateProductIntegrationIdentity` + the existing `fastify.
|
||||
checkIntegrationRateLimit` preHandler pair, then additionally verify the caller's
|
||||
`externalTenantId`/`externalUserId` (from `request.reqContext`) matches the ticket's own
|
||||
recorded values before allowing the action. A third route,
|
||||
`POST /admin/tickets/:ticketId/reopen`, is gated by `fastify.authenticate` for the
|
||||
agent-initiated reopen path FR-017 also requires. Confirm-resolution has no agent-initiated
|
||||
equivalent (spec.md US5 only ever has the customer confirming explicitly; an agent's own path
|
||||
to close things out is the existing auto-close job, not a manual override this feature adds).
|
||||
- **Implementation note (found during /speckit-implement, not anticipated at planning time)**:
|
||||
`fastify.authenticateProductIntegration` (002) unconditionally validates `request.body` against
|
||||
the full `inboundRequestSchema` — which requires `source`/`problem`, ticket-*creation*-specific
|
||||
fields neither new route has any reason to send. Reusing it as originally planned made every
|
||||
call to these two routes fail Zod validation before token verification ever ran. Fixed by
|
||||
extracting steps 2-10 of `authenticateProductIntegration`'s logic (everything after the body's
|
||||
own shape is known — token verification, replay/revocation/scope checks, `reqContext`
|
||||
population) into a shared `verifyIntegrationIdentity` function in
|
||||
`product-integration-auth.plugin.ts`, and adding a new `identityOnlyRequestSchema`
|
||||
(`{productId, tenantId, userId}` only) plus a new `authenticateProductIntegrationIdentity`
|
||||
decorator that parses that narrower shape and calls the same shared function. The original
|
||||
`authenticateProductIntegration` (and `POST /v1/support/requests`) is unchanged in behavior —
|
||||
purely additive.
|
||||
- **Rationale**: `inbound-request.routes.ts`'s own comment ("Acting further on the ticket...
|
||||
belongs to later features that don't exist yet") names exactly this need — 002's trust boundary
|
||||
was already built to be extended, just not with a body shape that happened to fit an action on
|
||||
an *existing* ticket. Requiring the caller's own token to match the ticket's tenant/user
|
||||
prevents one customer from confirming or reopening another tenant's ticket.
|
||||
- **Alternatives considered**: A single unauthenticated or `fastify.authenticate`-gated endpoint
|
||||
for both actor types — rejected; a customer is never an authenticated SupportHub principal
|
||||
(Constitution Principle I — SaaS is the sole identity authority for its own end users), so reusing
|
||||
the internal-agent auth mechanism for a customer-initiated action would be a security regression,
|
||||
not a simplification. Sending a dummy `source`/`problem` value to satisfy the existing schema —
|
||||
rejected as a hack that would misrepresent the request and pollute `validatedInboundBody` for a
|
||||
handler that was never meant to receive it.
|
||||
|
||||
## Decision: Auto-close is a repeatable BullMQ job on the existing, unclaimed `CLEANUP` queue
|
||||
|
||||
- **Decision**: `src/jobs/cleanup/index.ts` (currently a log-only stub registered on
|
||||
`QueueName.CLEANUP`, never wired into `queue.bootstrap.ts`) is extended the same way 008
|
||||
extended `src/jobs/sla/index.ts` — a repeatable job (every 5 minutes; less time-sensitive than
|
||||
008's breach detection, since this only ever fires after a multi-hour/day waiting period) whose
|
||||
processor calls a single, directly-callable `ResolutionsService.runAutoCloseSweep()` — querying
|
||||
every ticket with `status: 'RESOLUTION_PENDING_CUSTOMER'` whose most recent status-change
|
||||
(`Ticket.updatedAt`) is older than the configured waiting period, transitioning each to
|
||||
`RESOLVED`.
|
||||
- **Rationale**: `CLEANUP` is exactly this kind of periodic housekeeping sweep, and — like
|
||||
`SLA`/`ESCALATION` before this feature — was defined and left completely unregistered since the
|
||||
original scaffold. Reusing it needs no new `QueueName` value. A directly-callable sweep method
|
||||
(not only reachable through a running worker) is what let 008's breach-detection tests avoid a
|
||||
real wait; the same shape applies here.
|
||||
- **Alternatives considered**: A per-ticket delayed job scheduled at the moment `Resolution` is
|
||||
recorded — rejected for the same reason 008 rejected the equivalent per-run design: a
|
||||
reopened-then-re-resolved ticket, or a resolution recorded twice in error, would each need
|
||||
their own cancel/reschedule bookkeeping a polling sweep avoids entirely.
|
||||
|
||||
## Decision: The auto-close waiting period is one system-wide config value, not a per-scope policy
|
||||
|
||||
- **Decision**: `env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS` (default `72`, i.e. 3 days), exposed via
|
||||
a new `src/config/problem-resolution.ts` — `problemResolutionConfig.autoCloseWaitingHours` —
|
||||
mirroring `orchestrationConfig.defaultStrategy`'s exact shape.
|
||||
- **Rationale**: Doc 04 §9 describes "a configured waiting period" in the singular, system-wide
|
||||
sense — not a per-product/category policy table the way 008's `SLAPolicy` is; doc 06 defines no
|
||||
entity for a scoped auto-close policy. A single env-configured default (Constitution Principle
|
||||
II — never hardcoded, but not over-modeled into a policy table nothing asks for) is the
|
||||
proportionate reading.
|
||||
- **Alternatives considered**: A `ResolutionPolicy` table scoped like `SLAPolicy` — rejected as
|
||||
speculative; nothing in doc 04/06 describes per-context auto-close variation, unlike SLA's
|
||||
explicit product/category/priority scoping in doc 06's own `SLAPolicy` shape.
|
||||
|
||||
## Decision: The reopen transition is two real, separately-audited status updates
|
||||
|
||||
- **Decision**: Reopening calls `ticketsService.updateStatus(ticketId, 'REOPENED', ...)` followed
|
||||
immediately by `ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ...)` — two real
|
||||
transitions through 003's existing state machine (both already valid edges:
|
||||
`RESOLVED|CLOSED → REOPENED` and `REOPENED → IN_PROGRESS`), each producing its own
|
||||
`SYSTEM_EVENT` ticket message and `TICKET_UPDATED` publish, rather than a single hop straight
|
||||
to `IN_PROGRESS` that would skip recording the reopen milestone itself.
|
||||
- **Rationale**: Doc 04 §9's own phrasing — "reopen... should re-enter the appropriate lifecycle
|
||||
stage" — matches the state machine's own two-hop shape exactly; both hops are independently
|
||||
meaningful audit events (Constitution Principle VI), not one compound action worth collapsing.
|
||||
- **Alternatives considered**: A single, direct `RESOLVED|CLOSED → IN_PROGRESS` transition
|
||||
(bypassing `REOPENED` as a status value entirely) — rejected; 003's state machine doesn't even
|
||||
define that edge (only `REOPENED → IN_PROGRESS`), and skipping the `REOPENED` status would
|
||||
erase a real lifecycle milestone doc 04 explicitly names.
|
||||
|
||||
## Decision: 008's SLA run is explicitly left untouched by reopen — no new decision needed here
|
||||
|
||||
- **Decision**: Reopening a ticket does not create, restart, or modify its existing `SLARun`
|
||||
(008) in any way — the run (if one exists) simply remains in whatever terminal state it was
|
||||
already in (`completed` or `breached`).
|
||||
- **Rationale**: 008's own spec.md already closed this decision from its side ("SLA runs are 1:1
|
||||
with a ticket's first successful assignment only... out of scope for this feature to define a
|
||||
new run automatically") — this feature's job is only to confirm that boundary still holds, not
|
||||
to re-litigate it. FR-018/SC-005 make this an explicit, tested guarantee rather than an
|
||||
accidental side effect of simply not writing any `SLARun`-touching code.
|
||||
- **Alternatives considered**: Restarting the SLA run on reopen — explicitly out of scope per
|
||||
008's own spec; would require this feature to modify 008's already-shipped module, which
|
||||
nothing in Phase 9's roadmap line asks for.
|
||||
@@ -0,0 +1,315 @@
|
||||
# Feature Specification: Problem Resolution
|
||||
|
||||
**Feature Branch**: `009-problem-resolution`
|
||||
|
||||
**Created**: 2026-09-03
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Phase 9 of docs/10-implementation-roadmap.md: Investigation/
|
||||
RootCause/Solution/SolutionImplementation/SolutionVerification/Resolution models and workflows,
|
||||
customer confirmation + reopen flow. Per docs/04-ticketing-and-problem-management.md §3-9 and
|
||||
docs/06-database-schema.md 'Domain: Problem Resolution'."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent records structured investigation findings (Priority: P1)
|
||||
|
||||
An agent investigating a problem records findings, evidence, and internal notes as a structured
|
||||
record — not a free-text blob buried in a message — with its own status (`open`/`complete`). A
|
||||
problem can have more than one investigation attempt over its lifetime, each preserved, not
|
||||
overwritten.
|
||||
|
||||
**Why this priority**: Everything downstream (root cause, solution, verification) reads from or
|
||||
references an investigation; nothing else in this feature can start without one existing first.
|
||||
|
||||
**Independent Test**: Record an investigation with findings and evidence for a problem; confirm
|
||||
it's retrievable exactly as given, with its own investigator and timestamp.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent records an investigation with findings, **When** it's saved, **Then** it's
|
||||
retrievable with `investigator`, `findings`, `evidence`, `internalNotes`, and `status` exactly
|
||||
as given.
|
||||
2. **Given** a problem already has a completed investigation, **When** a new investigation is
|
||||
started for the same problem (e.g., after a failed verification, User Story 4), **Then** the
|
||||
prior investigation's record is preserved unchanged — a new investigation is its own row, never
|
||||
an overwrite of the earlier one.
|
||||
3. **Given** `internalNotes` on an investigation, **When** any customer-facing view is composed,
|
||||
**Then** that data is never included — internal notes are agent/admin-only, same "never shown
|
||||
to customers" discipline as ticketing's `INTERNAL_NOTE` message type (004 §10).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent records a root cause, separate from the investigation (Priority: P1)
|
||||
|
||||
Once findings point to a cause, the agent records a root cause as its own record — distinct from
|
||||
the investigation that surfaced it — typed as technical, configuration, external-dependency,
|
||||
business, or a contributing factor.
|
||||
|
||||
**Why this priority**: A solution (User Story 3) is a response to a specific, recorded cause —
|
||||
without one, "solving" a problem has nothing to be checked against.
|
||||
|
||||
**Independent Test**: Record a root cause of a given type for a problem with an investigation
|
||||
already on file; confirm it's retrievable and distinct from the investigation record.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a problem with an investigation on file, **When** an agent records a root cause with
|
||||
a type and description, **Then** it's retrievable as its own record, never merged into the
|
||||
investigation's own fields.
|
||||
2. **Given** a root cause type outside the five documented values, **When** it's submitted,
|
||||
**Then** it's rejected — the type is a closed, validated set, not free text.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An agent proposes, approves, and implements a solution, each as its own state (Priority: P1)
|
||||
|
||||
A solution moves through distinct states — proposed, approved, implemented — never collapsed into
|
||||
one mutable blob. Implementation is its own record: who implemented it, when, and any notes,
|
||||
kept separate from the proposal itself.
|
||||
|
||||
**Why this priority**: Verification (User Story 4) and resolution (User Story 5) both need a
|
||||
concrete, dated implementation record to verify and resolve against.
|
||||
|
||||
**Independent Test**: Propose a solution, approve it, then record its implementation; confirm all
|
||||
three states are independently visible on the same solution record/its implementation relation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a root cause on file, **When** an agent proposes a solution, **Then** it's stored
|
||||
with `approved: false` by default.
|
||||
2. **Given** a proposed solution, **When** it's approved, **Then** `approved` becomes `true` —
|
||||
approval is a distinct, explicit action, never implied by implementation happening.
|
||||
3. **Given** an approved solution, **When** an agent records its implementation (notes,
|
||||
implementer, timestamp), **Then** a `SolutionImplementation` record is created, one-to-one
|
||||
with the solution — attempting a second implementation record for the same solution is
|
||||
rejected, not silently overwritten.
|
||||
4. **Given** a solution that has not been approved, **When** an implementation is attempted,
|
||||
**Then** it's rejected — implementation without approval is never allowed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - A solution is verified; failure re-opens investigation or escalates (Priority: P2)
|
||||
|
||||
After implementation, the solution is verified by one of several methods (automated check,
|
||||
technical test, customer confirmation, agent confirmation). A successful verification clears the
|
||||
way to resolution (User Story 5). A failed verification either re-opens investigation (a fresh
|
||||
investigation record for the same problem) or escalates the ticket — an agent's explicit choice,
|
||||
not an automatic guess.
|
||||
|
||||
**Why this priority**: Depends on User Story 3 (something implemented to verify). Recording a
|
||||
resolution without ever having verified anything would misrepresent what was actually confirmed.
|
||||
|
||||
**Independent Test**: Verify an implemented solution as failed; confirm no `Resolution` can be
|
||||
recorded from it, and that either a fresh investigation exists or the ticket has been escalated,
|
||||
per the agent's chosen path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an implemented solution, **When** it's verified with `result: success`, **Then** a
|
||||
`SolutionVerification` record is created (method, result, evidence, timestamp), one-to-one
|
||||
with the solution.
|
||||
2. **Given** an implemented solution, **When** it's verified with `result: failed`, **Then** no
|
||||
resolution can reference this solution's verification as successful — a failed verification is
|
||||
a real, recorded outcome, not silently discarded.
|
||||
3. **Given** a failed verification and the agent chooses re-investigation, **When** that choice is
|
||||
made, **Then** a new `Investigation` record is created for the same problem (User Story 1's own
|
||||
"each attempt is its own row" rule).
|
||||
4. **Given** a failed verification and the agent chooses escalation instead, **When** that choice
|
||||
is made, **Then** the ticket transitions to `HUMAN_ESCALATION` through 003-ticketing's existing
|
||||
state machine — 007's orchestration re-runs automatically from that transition alone, exactly
|
||||
as it already does for any other route into `HUMAN_ESCALATION`; this feature does not invent a
|
||||
second escalation mechanism alongside 008's.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - A resolution is recorded, with configurable customer confirmation or auto-close (Priority: P1)
|
||||
|
||||
Once a solution is verified successful, a `Resolution` record captures the final outcome for the
|
||||
ticket. Depending on configuration, the ticket either waits for explicit customer confirmation
|
||||
before closing, or auto-closes after a configured waiting period with no response.
|
||||
|
||||
**Why this priority**: This is the feature's actual deliverable from the customer's point of
|
||||
view — everything before this is agent-facing work product.
|
||||
|
||||
**Independent Test**: Record a resolution for a ticket with a successfully verified solution;
|
||||
confirm the ticket reaches `RESOLUTION_PENDING_CUSTOMER`, then either an explicit confirmation or
|
||||
the configured waiting period elapsing moves it to `RESOLVED`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a solution with a successful verification, **When** an agent records a resolution,
|
||||
**Then** a `Resolution` record is created (`outcome`, `resolvedBy`) and the ticket transitions
|
||||
to `RESOLUTION_PENDING_CUSTOMER`.
|
||||
2. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER`, **When** the customer explicitly confirms,
|
||||
**Then** the ticket transitions to `RESOLVED`.
|
||||
3. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER` with no customer response, **When** the
|
||||
configured auto-close waiting period elapses, **Then** the ticket transitions to `RESOLVED`
|
||||
automatically — durably, via a background job, never an in-memory timer (Constitution
|
||||
Principle VII, same discipline 008's breach-detection job already established).
|
||||
4. **Given** a `Resolution` is attempted without a successfully verified solution on file,
|
||||
**When** it's attempted, **Then** it's rejected — a resolution must be backed by real,
|
||||
recorded verification, never asserted on its own.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - A resolved or closed ticket can be reopened (Priority: P2)
|
||||
|
||||
A customer or agent can reopen a `RESOLVED` or `CLOSED` ticket, which re-enters the appropriate
|
||||
point in the lifecycle rather than starting over from `NEW`.
|
||||
|
||||
**Why this priority**: Depends on User Story 5 (a ticket has to have reached a closeable state
|
||||
before reopening it means anything). Closes the loop 008 explicitly left open ("reopening... may
|
||||
need its own SLA-run-restart decision").
|
||||
|
||||
**Independent Test**: Reopen a `RESOLVED` ticket; confirm it transitions to `REOPENED` and then
|
||||
into an active lifecycle state, and that the prior resolution record remains on file, unaltered.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a `RESOLVED` or `CLOSED` ticket, **When** the customer or an agent reopens it,
|
||||
**Then** the ticket transitions to `REOPENED` and then to `IN_PROGRESS` (003's existing
|
||||
`REOPENED → IN_PROGRESS` transition) — never back to `NEW`.
|
||||
2. **Given** a ticket is reopened, **When** the prior `Resolution` record is checked, **Then** it
|
||||
remains on file exactly as it was — reopening never deletes or mutates history.
|
||||
3. **Given** a ticket already has an SLA run (008) from its original assignment, **When** it's
|
||||
reopened, **Then** no new SLA run is created and the existing one is left exactly as it was
|
||||
(008's own Assumptions: "SLA runs are 1:1 with a ticket's first successful assignment only") —
|
||||
this feature does not retroactively expand that boundary.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if an agent tries to record a root cause before any investigation exists for the
|
||||
problem? Rejected — a root cause without a preceding investigation has nothing to be grounded
|
||||
in (FR-006).
|
||||
- What happens if a solution is proposed for a problem with no root cause on file? Rejected, same
|
||||
reasoning as above (FR-009).
|
||||
- What happens if two verification attempts are recorded for the same solution? Rejected — like
|
||||
`SolutionImplementation`, `SolutionVerification` is one-to-one with its solution (doc 06's own
|
||||
`@unique` on `solutionId`); a second verification attempt on an already-verified solution is out
|
||||
of scope for this feature (re-verification of a previously-verified solution is not a flow doc
|
||||
04 describes).
|
||||
- What happens to a ticket's messages/attachments/assignment history when it's reopened? Nothing
|
||||
— reopening only affects `Ticket.status`; every other record (007's `Assignment`, 008's
|
||||
`SLARun`, this feature's own `Investigation`/`RootCause`/`Solution`/`Resolution` records) is
|
||||
untouched by the reopen transition itself.
|
||||
- What happens if the configured auto-close waiting period is set to zero or is unconfigured?
|
||||
Zero is a valid configuration (auto-close as soon as the sweep next runs); unconfigured falls
|
||||
back to a system default (Constitution Principle II — configuration over hardcoding, but a
|
||||
default value must exist so the sweep job always has something to compare against).
|
||||
- What happens if a ticket is reopened more than once? Each reopen is its own `REOPENED →
|
||||
IN_PROGRESS` transition — no cap on how many times a ticket can be reopened is introduced by
|
||||
this feature (counting reopens toward an escalation trigger remains 008's already-documented,
|
||||
deliberately deferred `repeated_reopen` trigger type — this feature does not wire it up).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let an agent record an investigation (investigator, findings,
|
||||
evidence, internal notes, status) for a problem.
|
||||
- **FR-002**: Each investigation MUST be its own durable record — a new investigation for the
|
||||
same problem (e.g., after a failed verification) MUST NOT overwrite a prior one.
|
||||
- **FR-003**: Internal notes on an investigation MUST NEVER be exposed through any
|
||||
customer-facing read path.
|
||||
- **FR-004**: The system MUST let an agent record a root cause (type, description) for a
|
||||
problem, as a record distinct from any investigation.
|
||||
- **FR-005**: A root cause's type MUST be validated against the five documented values
|
||||
(technical, configuration, external_dependency, business, contributing_factor) — never
|
||||
free text.
|
||||
- **FR-006**: Recording a root cause for a problem with no investigation on file MUST be
|
||||
rejected.
|
||||
- **FR-007**: The system MUST let an agent propose a solution for a problem (`approved: false`
|
||||
by default), approve it explicitly, and record its implementation (notes, implementer,
|
||||
timestamp) as a separate, one-to-one record.
|
||||
- **FR-008**: Recording an implementation for a solution that has not been approved MUST be
|
||||
rejected.
|
||||
- **FR-009**: Proposing a solution for a problem with no root cause on file MUST be rejected.
|
||||
- **FR-010**: The system MUST let an agent record a verification (method, result, evidence) for
|
||||
an implemented solution, as a one-to-one record.
|
||||
- **FR-011**: A verification's method MUST be validated against the four documented values
|
||||
(automated, technical_test, customer_confirmation, agent_confirmation).
|
||||
- **FR-012**: A failed verification MUST NOT permit a `Resolution` to be recorded against that
|
||||
solution.
|
||||
- **FR-013**: On a failed verification, the system MUST support either starting a fresh
|
||||
investigation for the same problem (FR-002) or transitioning the ticket to `HUMAN_ESCALATION`
|
||||
(003's existing state machine, triggering 007's existing orchestration subscriber
|
||||
automatically) — the choice between the two is the recording agent's, not automatic.
|
||||
- **FR-014**: The system MUST let an agent record a `Resolution` (outcome, resolvedBy) for a
|
||||
ticket, only when a successfully verified solution exists for its problem — this transitions
|
||||
the ticket to `RESOLUTION_PENDING_CUSTOMER`.
|
||||
- **FR-015**: The system MUST let a customer explicitly confirm a pending resolution, transitioning
|
||||
the ticket to `RESOLVED`.
|
||||
- **FR-016**: The system MUST auto-transition a ticket from `RESOLUTION_PENDING_CUSTOMER` to
|
||||
`RESOLVED` after a configured waiting period with no explicit customer confirmation — detected
|
||||
by a durable background job, never an in-memory timer (Constitution Principle VII).
|
||||
- **FR-017**: The system MUST let a customer or agent reopen a `RESOLVED` or `CLOSED` ticket,
|
||||
transitioning it to `REOPENED` and then `IN_PROGRESS` — never back to `NEW`, and never
|
||||
mutating any prior investigation/root-cause/solution/verification/resolution record.
|
||||
- **FR-018**: Reopening a ticket MUST NOT create a new SLA run (008's existing 1:1-with-first-
|
||||
assignment boundary is unchanged by this feature).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Investigation**: A structured, per-attempt record of what an agent found while investigating
|
||||
a problem — findings, evidence, internal notes — never free text buried in a message; a problem
|
||||
can have more than one, each preserved.
|
||||
- **Root Cause**: Why the problem happened, typed and recorded separately from what was found
|
||||
(the investigation).
|
||||
- **Solution**: What's proposed to fix the root cause, moving through proposed → approved states
|
||||
explicitly.
|
||||
- **Solution Implementation**: The one-to-one record of a solution actually being carried out —
|
||||
who, when, and any notes — distinct from the proposal.
|
||||
- **Solution Verification**: The one-to-one record of whether the implementation actually worked,
|
||||
by which method.
|
||||
- **Resolution**: The final, ticket-level outcome — distinct from the solution (what was done)
|
||||
and the verification (whether it worked).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of investigation/root-cause/solution/implementation/verification/resolution
|
||||
records, once created, remain retrievable exactly as given — no field silently dropped or
|
||||
overwritten by a later action in the same problem's lifecycle.
|
||||
- **SC-002**: 100% of internal-notes fields are absent from every customer-facing response,
|
||||
verified by a direct comparison of the agent-facing and customer-facing read paths for the same
|
||||
investigation.
|
||||
- **SC-003**: 100% of resolutions recorded without a successfully verified solution on file are
|
||||
rejected.
|
||||
- **SC-004**: 100% of tickets reaching `RESOLUTION_PENDING_CUSTOMER` with no explicit customer
|
||||
confirmation reach `RESOLVED` within one auto-close job cycle of their configured waiting
|
||||
period elapsing.
|
||||
- **SC-005**: 100% of reopened tickets leave every prior investigation/root-cause/solution/
|
||||
verification/resolution record and SLA run untouched.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **This feature does not build a customer-facing confirmation UI** — "explicit customer
|
||||
confirmation" (FR-015) is an API action a caller (a future customer portal, or 008/010's own
|
||||
future UI work) can invoke; this feature's own scope is the backend transition and the
|
||||
auto-close fallback, not a rendered confirmation page (010 — Agent/Admin UI — is a separate,
|
||||
later roadmap phase).
|
||||
- **Verification-failure escalation reuses 003-ticketing's existing `HUMAN_ESCALATION` state
|
||||
transition and 007's already-automatic orchestration subscriber directly** — it does not create
|
||||
a new `EscalationEvent` through 008's rule-based mechanism, since "solution verification failed"
|
||||
is not one of doc 05 §6's ten escalation trigger types 008 modeled; inventing an eleventh type
|
||||
for a single feature's own internal flow was judged unnecessary scope, not an oversight.
|
||||
Re-escalation through the plain ticket-status transition is exactly what 007 was already built
|
||||
to react to — no new coupling is introduced.
|
||||
- **The auto-close waiting period is a single, system-wide configuration value** (Principle II —
|
||||
configuration over hardcoding), not scoped per product/category the way 008's SLA policies are;
|
||||
doc 04 §9 describes it as "a configured waiting period," not a per-context policy table, and
|
||||
nothing in doc 06's schema defines a per-scope auto-close entity to resolve against.
|
||||
- **`repeated_reopen` (008's already-inert escalation trigger type) is still not wired up by this
|
||||
feature** — reopening increments no counter and triggers no escalation rule; this remains
|
||||
future work exactly as 008's own Assumptions already documented, not something this feature
|
||||
silently expands into.
|
||||
- **A second verification attempt on an already-verified solution is out of scope** — doc 06's
|
||||
`SolutionVerification.solutionId` is `@unique`, meaning at most one verification record per
|
||||
solution; if a first verification fails and the agent chooses re-investigation (FR-013), any
|
||||
new solution that comes out of that fresh investigation cycle gets its own new `Solution` row
|
||||
(User Story 3) with its own verification slot — never a second write to the original one.
|
||||
@@ -0,0 +1,355 @@
|
||||
---
|
||||
description: "Task list for 009-problem-resolution"
|
||||
---
|
||||
|
||||
# Tasks: Problem Resolution
|
||||
|
||||
**Input**: Design documents from `specs/009-problem-resolution/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/problem-resolution-contract.md](./contracts/problem-resolution-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Tests**: Included as first-class tasks. This feature's pure logic is the existence-chain
|
||||
validation (each step's precondition) and the auto-close due-window predicate; the rest is
|
||||
sequential-workflow wiring best proven end-to-end against real Postgres.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 investigation, US2 = P1 root cause,
|
||||
US3 = P1 solution states, US4 = P2 verification, US5 = P1 resolution/confirmation/auto-close,
|
||||
US6 = P2 reopen).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Populate `src/modules/problem-management/investigation/` with the full standard
|
||||
shape (`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
|
||||
`constants/`, `index.ts`), replacing the `InvestigationService.getInvestigationStatus` stub
|
||||
- [x] T002 [P] Populate `src/modules/problem-management/root-causes/` the same way, replacing the
|
||||
`RootCausesService.getRootCause` stub
|
||||
- [x] T003 [P] Populate `src/modules/problem-management/solutions/` the same way, replacing the
|
||||
`SolutionsService.getSolutions` stub
|
||||
- [x] T004 [P] Populate `src/modules/problem-management/verification/` the same way, replacing
|
||||
the `VerificationService.verifySolution` stub
|
||||
- [x] T005 [P] Populate `src/modules/problem-management/resolutions/` the same way, replacing the
|
||||
`ResolutionsService.getResolutions` stub — this module additionally gets the auto-close
|
||||
sweep and the two new customer-facing routes (later tasks)
|
||||
- [x] T006 [P] Add `src/config/problem-resolution.ts` (`problemResolutionConfig
|
||||
.autoCloseWaitingHours`, reading a new `RESOLUTION_AUTO_CLOSE_WAITING_HOURS` env var,
|
||||
default `72`) and register it in `src/config/index.ts`'s re-export list
|
||||
|
||||
---
|
||||
|
||||
## 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] T007 Add `Investigation`, `RootCause`, `Solution`, `SolutionImplementation`,
|
||||
`SolutionVerification`, `Resolution` models to `prisma/schema.prisma` per data-model.md,
|
||||
plus `Problem.investigations`/`Problem.rootCauses`/`Problem.solutions` and
|
||||
`Ticket.resolution` back-relations (depends on T001-T005)
|
||||
- [x] T008 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
|
||||
T007 (depends on T007)
|
||||
|
||||
**Checkpoint**: Schema migrated. User stories can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Structured investigation, preserved across attempts (Priority: P1) 🎯 MVP (part 1)
|
||||
|
||||
**Goal**: Investigation CRUD with the version-row-per-attempt guarantee and internal-notes
|
||||
exclusion from customer-facing reads.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T009 [US1] Integration test covering Quickstart Scenario 1 (create, retrieve with every
|
||||
field intact, customer-safe variant omits `internalNotes`, a second investigation preserves
|
||||
the first) against a real Postgres in `tests/integration/problem-resolution-flow.test.ts`
|
||||
(depends on T008)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T010 [US1] Add `InvestigationRepository` (`create`, `findAllForProblem` ordered newest
|
||||
first, `findMostRecentForProblem`) in `investigation/repository/` (depends on T008)
|
||||
- [x] T011 [US1] Add Zod create schema (`investigator`, `findings`, `evidence?`,
|
||||
`internalNotes?`, `status?`) in `investigation/schema/`
|
||||
- [x] T012 [US1] Add `InvestigationService.record`/`listForProblem` (agent-facing, includes
|
||||
`internalNotes`) and `listForProblemCustomerSafe` (strips `internalNotes`, FR-003) in
|
||||
`investigation/service/` (depends on T010, T011)
|
||||
- [x] T013 [US1] Add `POST/GET /admin/problems/:problemId/investigations` (gated by
|
||||
`fastify.authenticate`) and `GET /problems/:problemId/investigations` (ungated, customer-
|
||||
safe) routes in `investigation/controller/` + `routes/`, registered from `src/api/routes.ts`
|
||||
(depends on T012)
|
||||
- [x] T014 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: Investigations can be recorded and read correctly, with the customer-safe
|
||||
redaction guarantee in place.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Root cause requires an investigation on file (Priority: P1) 🎯 MVP (part 2)
|
||||
|
||||
**Goal**: RootCause CRUD gated on an existing investigation, with a validated type enum.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (rejected with no investigation,
|
||||
accepted after one exists, rejected with an invalid type) — implemented as the "Scenario 2"
|
||||
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T009, T014)
|
||||
- [x] T016 [P] [US2] Unit test for the type-validation Zod schema (five valid values, everything
|
||||
else rejected) in `tests/unit/problem-management/root-cause-schema.test.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T017 [US2] Add `RootCauseRepository` (`create`, `findAllForProblem`) in
|
||||
`root-causes/repository/` (depends on T008)
|
||||
- [x] T018 [US2] Add Zod create schema (`type` as a 5-value enum, `description`) in
|
||||
`root-causes/schema/`
|
||||
- [x] T019 [US2] Add `RootCausesService.record`: resolve-or-`409` on the problem having at least
|
||||
one investigation (T010's `findMostRecentForProblem`, via `investigation`'s public
|
||||
`index.ts`) — in `root-causes/service/` (depends on T012, T017, T018)
|
||||
- [x] T020 [US2] Add `POST /admin/problems/:problemId/root-causes` route (gated by
|
||||
`fastify.authenticate`) in `root-causes/controller/` + `routes/`, registered from
|
||||
`src/api/routes.ts` (depends on T019)
|
||||
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: Root causes are correctly gated on investigation existing first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Solution proposed, approved, implemented as distinct states (Priority: P1) 🎯 MVP (part 3)
|
||||
|
||||
**Goal**: Solution CRUD gated on root cause existing; approval as an explicit action;
|
||||
implementation gated on approval, one-to-one.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (rejected with no root cause,
|
||||
created with `approved: false`, implementation rejected before approval, accepted after,
|
||||
a second implementation rejected) — "Scenario 3" case in
|
||||
`tests/integration/problem-resolution-flow.test.ts` (depends on T015, T021)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T023 [US3] Add `SolutionRepository` (`create`, `findById`, `approve`,
|
||||
`findMostRecentForProblem`) and `SolutionImplementationRepository` (`create`, `findBySolutionId`)
|
||||
in `solutions/repository/` (depends on T008)
|
||||
- [x] T024 [US3] Add Zod schemas (`proposed`; implementation's `notes?`, `implementedBy`) in
|
||||
`solutions/schema/`
|
||||
- [x] T025 [US3] Add `SolutionsService.propose`: resolve-or-`409` on the problem having at least
|
||||
one root cause (T017's repository, via `root-causes`'s public `index.ts`) — `approve` —
|
||||
`recordImplementation`: resolve-or-`409` on `approved: true` and no existing implementation
|
||||
— in `solutions/service/` (depends on T019, T023, T024)
|
||||
- [x] T026 [US3] Add `POST /admin/problems/:problemId/solutions`,
|
||||
`PATCH /admin/solutions/:solutionId/approve`,
|
||||
`POST /admin/solutions/:solutionId/implementation` routes (gated by `fastify.authenticate`)
|
||||
in `solutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
|
||||
T025)
|
||||
- [x] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 5 steps pass
|
||||
|
||||
**Checkpoint**: All three P1 record-keeping user stories are complete — the full investigation
|
||||
through implementation chain is enforced and correct. This is the feature's structural MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Verification, and failure re-investigates or escalates (Priority: P2)
|
||||
|
||||
**Goal**: SolutionVerification CRUD gated on implementation existing, one-to-one; a failed
|
||||
verification supports either a fresh investigation or the existing `HUMAN_ESCALATION` transition.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T028 [US4] Integration test covering Quickstart Scenario 4 (successful verification;
|
||||
failed verification recorded but unusable for resolution; failure + reinvestigate creates a
|
||||
fresh Investigation row; failure + escalate transitions the ticket to `HUMAN_ESCALATION`
|
||||
and 007 auto-assigns) — "Scenario 4" case in
|
||||
`tests/integration/problem-resolution-flow.test.ts` (depends on T022)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T029 [US4] Add `SolutionVerificationRepository` (`create`, `findBySolutionId`) in
|
||||
`verification/repository/` (depends on T008)
|
||||
- [x] T030 [US4] Add Zod schema (`method` as a 4-value enum, `result`, `evidence?`) in
|
||||
`verification/schema/`
|
||||
- [x] T031 [US4] Add `VerificationService.record`: resolve-or-`409` on the solution having an
|
||||
implementation (T023's repository) and no existing verification — in `verification/
|
||||
service/` (depends on T023, T029, T030)
|
||||
- [x] T032 [US4] Add `POST /admin/solutions/:solutionId/verification` route (gated by
|
||||
`fastify.authenticate`) in `verification/controller/` + `routes/`, registered from
|
||||
`src/api/routes.ts` (depends on T031)
|
||||
- [x] T033 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass (steps 3-4 call
|
||||
T012's `InvestigationService.record` and `ticketsService.updateStatus` directly — no new
|
||||
production code beyond what US1/003/007 already provide, per research.md's decision to
|
||||
reuse the existing transition rather than add new escalation machinery)
|
||||
|
||||
**Checkpoint**: Verification is correctly gated and its failure path reuses existing mechanisms
|
||||
rather than inventing new ones.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Resolution, customer confirmation, and durable auto-close (Priority: P1)
|
||||
|
||||
**Goal**: Resolution gated on a successful verification; explicit customer confirmation via
|
||||
002's trust boundary; a durable, directly-callable auto-close sweep as the fallback.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 5.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [x] T034 [P] [US5] Unit test for the auto-close due-window predicate (a pending ticket older
|
||||
than the configured waiting period is due; a pending ticket younger than it is not; a
|
||||
non-pending ticket is never selected) in
|
||||
`tests/unit/problem-management/auto-close-sweep.test.ts`
|
||||
- [x] T035 [US5] Integration test covering Quickstart Scenario 5 (resolution rejected without a
|
||||
successful verification; accepted after, ticket reaches `RESOLUTION_PENDING_CUSTOMER`;
|
||||
customer confirmation via the trust-boundary route reaches `RESOLVED`; a second ticket aged
|
||||
past the configured window reaches `RESOLVED` via a direct call to the sweep) — "Scenario 5"
|
||||
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T028)
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [x] T036 [US5] Add `ResolutionRepository` (`create`, `findByTicketId`) in
|
||||
`resolutions/repository/` (depends on T008)
|
||||
- [x] T037 [US5] Add Zod schema (`outcome`, `resolvedBy`) in `resolutions/schema/`
|
||||
- [x] T038 [US5] Add `ResolutionsService.record(ticketId, outcome, resolvedBy)`: resolves the
|
||||
ticket's `problemId`, resolve-or-`409` on a `Solution` with `verification.result: 'success'`
|
||||
existing for it (T023/T029's repositories), creates the `Resolution`, and transitions the
|
||||
ticket to `RESOLUTION_PENDING_CUSTOMER` via `ticketsService.updateStatus` — in
|
||||
`resolutions/service/resolutions.service.ts` (depends on T023, T029, T036, T037)
|
||||
- [x] T039 [US5] Add `ResolutionsService.confirmByCustomer(ticketId)` /
|
||||
`runAutoCloseSweep()`: the former transitions `RESOLUTION_PENDING_CUSTOMER → RESOLVED`
|
||||
directly; the latter queries every `RESOLUTION_PENDING_CUSTOMER` ticket whose `updatedAt` is
|
||||
older than `problemResolutionConfig.autoCloseWaitingHours` and transitions each the same way
|
||||
— a single, directly-callable, side-effect-only method (research.md — no worker process
|
||||
needed to invoke it in tests) — in `resolutions/service/resolutions.service.ts` (depends on
|
||||
T006, T038)
|
||||
- [x] T040 [US5] Add `POST /admin/tickets/:ticketId/resolution` (gated by `fastify.authenticate`)
|
||||
and `POST /v1/support/tickets/:ticketId/confirm-resolution` (gated by
|
||||
`fastify.authenticateProductIntegration` + `fastify.checkIntegrationRateLimit`, verifying
|
||||
the token's tenant/user matches the ticket's own — research.md) routes in
|
||||
`resolutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
|
||||
T038, T039)
|
||||
- [x] T041 [US5] Replace `registerCleanupWorker()`'s stub body in `src/jobs/cleanup/index.ts`:
|
||||
schedule a repeatable job (every 5 minutes) on `QueueName.CLEANUP` whose processor calls
|
||||
T039's `runAutoCloseSweep` — and register it from `src/bootstrap/queue.bootstrap.ts`
|
||||
(depends on T039)
|
||||
- [x] T042 [US5] Run Quickstart Scenario 5 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: Every P1 user story is complete. The full investigation-to-resolution chain
|
||||
works, gated correctly at every step, with both an explicit and a durable-fallback path to
|
||||
`RESOLVED`. This is the feature's MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 6 - Reopen (Priority: P2)
|
||||
|
||||
**Goal**: A resolved or closed ticket can be reopened by the customer or an agent, re-entering
|
||||
`IN_PROGRESS` through two real, audited transitions, touching nothing else.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 6.
|
||||
|
||||
### Tests for User Story 6
|
||||
|
||||
- [x] T043 [US6] Integration test covering Quickstart Scenario 6 (customer reopen reaches
|
||||
`IN_PROGRESS` via `REOPENED`; the prior `Resolution` and any `SLARun` are unchanged; agent
|
||||
reopen of a `CLOSED` ticket produces the same result attributed to the agent) — "Scenario 6"
|
||||
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T035)
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [x] T044 [US6] Add `TicketsService.reopen(ticketId, actor)` (007/003's existing
|
||||
`ticketing/tickets` module): resolve-or-`409` if status isn't `RESOLVED`/`CLOSED`, then two
|
||||
sequential `updateStatus` calls (`REOPENED`, then `IN_PROGRESS`) — in `ticketing/tickets/
|
||||
service/tickets.service.ts` (depends on T008 — no new schema, reuses 003's own state
|
||||
machine and repository)
|
||||
- [x] T045 [US6] Add `POST /v1/support/tickets/:ticketId/reopen` (customer, trust boundary) and
|
||||
`POST /admin/tickets/:ticketId/reopen` (agent, `fastify.authenticate`) routes in
|
||||
`ticketing/tickets/controller/` + `routes/` (depends on T044)
|
||||
- [x] T046 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: All six user stories work independently and together — the full doc 04 workflow,
|
||||
from first investigation through resolution, confirmation, auto-close, and reopen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T047 [P] Update `specs/009-problem-resolution/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T048 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T049 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
|
||||
elsewhere, then the full integration suite (including 003's and 007's own suites, since
|
||||
T044 modifies `ticketing/tickets`) 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 investigation it's gated on)
|
||||
- **User Story 3 (Phase 5)**: Depends on US2 (the root cause it's gated on)
|
||||
- **User Story 4 (Phase 6)**: Depends on US3 (the implementation it's gated on)
|
||||
- **User Story 5 (Phase 7)**: Depends on US4 (the successful verification it's gated on)
|
||||
- **User Story 6 (Phase 8)**: Depends on US5 (a ticket has to reach `RESOLVED`/`CLOSED` before
|
||||
reopening it means anything)
|
||||
- **Polish (Phase 9)**: Depends on all six user stories
|
||||
|
||||
This feature's user stories are more strictly sequential than 007's or 008's — doc 04's own
|
||||
workflow is a straight chain (investigation → root cause → solution → verification →
|
||||
resolution → reopen), not a set of independently orderable capabilities, so each phase's
|
||||
dependency here is real, not just priority-driven sequencing.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T006 (independent scaffolding)
|
||||
- T016 (unit test) alongside T017-T018 (the schema it tests)
|
||||
- T034 (unit test) alongside T039 (the sweep it tests)
|
||||
- T047 in Polish
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1-3, then 5)
|
||||
|
||||
1. Setup + Foundational (T001-T008)
|
||||
2. User Story 1 (T009-T014) → investigations recorded and readable
|
||||
3. User Story 2 (T015-T021) → root causes correctly gated
|
||||
4. User Story 3 (T022-T027) → solutions proposed/approved/implemented correctly
|
||||
5. **User Story 4 is P2** — skippable for a first MVP cut if verification's own gating isn't
|
||||
needed yet, but User Story 5 (Resolution) depends on it structurally (a successful
|
||||
verification is Resolution's own precondition), so in practice build order is 1→2→3→4→5
|
||||
regardless of priority label — same "dependency order isn't always priority order" note 006
|
||||
and 007's own tasks.md already made.
|
||||
6. User Story 5 (T034-T042) → resolution, confirmation, and auto-close all work
|
||||
7. **STOP and VALIDATE**: Quickstart Scenarios 1-5 pass.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → schema migrated
|
||||
2. Add User Story 1 → investigations exist
|
||||
3. Add User Story 2 → root causes correctly gated
|
||||
4. Add User Story 3 → solutions move through real states
|
||||
5. Add User Story 4 → verification gated, failure path reuses existing mechanisms
|
||||
6. Add User Story 5 → resolution + confirmation + auto-close (P1-complete, MVP)
|
||||
7. Add User Story 6 → reopen, closing the loop 008 left open
|
||||
8. Polish → full regression
|
||||
@@ -0,0 +1,68 @@
|
||||
# Specification Quality Checklist: Identity and Authentication
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**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
|
||||
|
||||
- This feature was not on the original 11-phase roadmap — it surfaced as a genuine blocking gap
|
||||
while planning supporthub-web's `001-agent-admin-ui`: `fastify.authenticate` has been a
|
||||
complete no-op stub since 002, and `identity/auth`'s login endpoint has never taken a
|
||||
password. Numbered 010 in supporthub-api's own sequence since it's a real, immediately-needed
|
||||
backend prerequisite, not deferred hardening.
|
||||
- `User`/`UserRole` (with two seeded-but-passwordless demo accounts,
|
||||
`admin@supporthub.internal`/`agent@supporthub.internal`) and the `AuthUser`/`JwtPayload`
|
||||
types in `src/common/types` were all found already scaffolded, unwired, and clearly intended
|
||||
for exactly this feature since the original pre-speckit scaffold — this is a "finish the
|
||||
originally-intended wiring" feature, not a new design invented from nothing.
|
||||
- Scope is deliberately narrow: real login + real route gating + role checks + a self-identity
|
||||
endpoint + admin-created accounts + logout. Password reset, MFA, rate-limiting, and
|
||||
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
|
||||
hardening pass" as the more appropriate later home for those.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: making `fastify.authenticate` genuinely reject invalid/missing
|
||||
tokens (FR-004) had a far larger blast radius than this feature's own tasks.md anticipated.
|
||||
Dozens of routes across features 002-009 were already declared with `fastify.authenticate` as
|
||||
a preHandler — safe to write against a no-op stub, but every one of those pre-existing
|
||||
integration tests had been calling them with no `Authorization` header. Making the check real
|
||||
broke ~18 integration test files suite-wide, requiring a `tests/helpers/auth.ts` (`loginAs`/
|
||||
`authHeader`) and a file-by-file pass adding real bearer tokens, well beyond the mechanical
|
||||
`requireRole('ADMIN')` rollout research.md had scoped for. A related, recurring bug: several
|
||||
files already declared a local `const token = issueIntegrationToken(...)` for the unrelated
|
||||
002 customer-trust-boundary flow, and naming the new admin/agent token variable `token` in the
|
||||
same scope produced a `ReferenceError: Cannot access 'token' before initialization` — a genuine
|
||||
temporal-dead-zone collision, not a tooling bug — fixed by using a non-colliding name
|
||||
(`authToken`/`adminToken`/`agentToken`) per file.
|
||||
- A second, subtler implementation-time finding: once admin-setup calls in test `beforeAll`
|
||||
blocks started actually succeeding (previously they silently 401'd against the no-op stub),
|
||||
wildcard/global SLA policies created by one integration test file could genuinely match tickets
|
||||
created by another file running against the same shared throwaway Postgres, leaving orphaned
|
||||
`sla_run` rows that RESTRICT-violated the FK on cleanup. Fixed by widening the affected files'
|
||||
`afterAll` cleanup to delete `sla_run` rows by `ticketId` *and* by `policyId`, not just one or
|
||||
the other.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contract: Identity and Authentication
|
||||
|
||||
## Login
|
||||
|
||||
- `POST /auth/login` — body `{ email, password }`. `401` on any failure (wrong password, no
|
||||
such account, or a deactivated account) with an identical response body/status in every case
|
||||
(FR-002/SC-003) — never a distinguishable "no such user" vs "wrong password." `200` with
|
||||
`{ token, user: { id, email, name, role } }` on success.
|
||||
|
||||
## Self-identity
|
||||
|
||||
- `GET /auth/me` — gated by `fastify.authenticate`. `401` if the token is missing/invalid/
|
||||
expired/revoked. `401` if the account behind a structurally-valid token no longer exists or
|
||||
is deactivated (re-validated against current state, not the token's own claims alone). `200`
|
||||
with `{ id, email, name, role }` on success.
|
||||
|
||||
## Account creation (admin-only)
|
||||
|
||||
- `POST /admin/users` — gated by `fastify.authenticate` + `requireRole('ADMIN')`. Body
|
||||
`{ email, name, role, password }` (`role` one of `ADMIN`/`AGENT`). `403` for a valid non-admin
|
||||
session. `409` if `email` is already in use. `201` with the created `{ id, email, name, role
|
||||
}` (never the password or its hash) on success.
|
||||
|
||||
## Logout
|
||||
|
||||
- `POST /auth/logout` — gated by `fastify.authenticate`. Revokes the calling token's own `jti`
|
||||
(Redis denylist, TTL = remaining lifetime) so it's rejected on any further use even before its
|
||||
natural expiry. `200` on success.
|
||||
|
||||
## Guarantees (callable contract)
|
||||
|
||||
1. **Every route already gated by `fastify.authenticate` across 002-009 continues to accept a
|
||||
valid session and now genuinely rejects a missing/invalid/expired/revoked one** — the gate
|
||||
itself changes from a no-op to a real check; which routes carry the gate is unchanged
|
||||
(FR-006, SC-001).
|
||||
2. **A route additionally gated by `requireRole('ADMIN')` rejects a structurally valid session
|
||||
whose role isn't `ADMIN`, with a response distinguishable from "no valid session at all"**
|
||||
(403 vs 401) (FR-005, SC-002).
|
||||
3. **A login failure never reveals whether the submitted email corresponds to an existing
|
||||
account** — verified by comparing the exact response for a wrong password against a wholly
|
||||
nonexistent email (FR-002, SC-003).
|
||||
4. **No password is ever stored, logged, or returned anywhere in plaintext** — only
|
||||
`passwordHash` is persisted, and no response body (login, self-identity, account creation)
|
||||
ever includes it (FR-003, SC-004).
|
||||
5. **An admin-created account can log in immediately with the password it was created with, no
|
||||
manual step in between** (FR-008, SC-005).
|
||||
6. **A token revoked via logout is rejected on any further use, even before its natural expiry**
|
||||
(FR-009).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Data Model: Identity and Authentication
|
||||
|
||||
## User (modified — two additive columns)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(uuid())` | unchanged |
|
||||
| `email` | `String @unique` | unchanged |
|
||||
| `name` | `String` | unchanged |
|
||||
| `role` | `UserRole @default(CUSTOMER)` | unchanged enum (`ADMIN \| AGENT \| CUSTOMER`) — this feature never assigns `CUSTOMER` (research.md/spec.md Assumptions); every row this feature creates or updates is `ADMIN` or `AGENT` |
|
||||
| **`passwordHash`** | **`String`** | **new** — bcryptjs hash, never the plaintext password; `NOT NULL` since every account this feature manages must be able to log in (FR-010 requires both seeded demo accounts to get a real one) |
|
||||
| **`active`** | **`Boolean @default(true)`** | **new** — mirrors `Agent.active`'s existing convention exactly; a deactivated account's session is rejected on re-validation (Edge Cases/User Story 3), without a hard delete |
|
||||
| `createdAt` / `updatedAt` | `DateTime` | unchanged |
|
||||
|
||||
## Agent (modified — one additive, nullable column)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| **`userId`** | **`String? @unique`** | **new** — nullable FK to `User.id`, the schema capability to identify which login identity a routing/skills profile belongs to (research.md). No endpoint in this feature sets it; a follow-up in `identity/agents` (006) is expected to. |
|
||||
|
||||
No new Prisma model for "Session" — a session is a signed JWT the server never persists
|
||||
(research.md's short-lived-JWT-plus-revocation-denylist decision); the denylist itself lives in
|
||||
Redis (`auth:revoked:<jti>`, TTL = remaining token lifetime), not Postgres.
|
||||
|
||||
## JwtPayload (existing type, `src/common/types/auth.types.ts` — one additive field)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `sub` | `string` | the `User.id` |
|
||||
| `email` | `string` | unchanged |
|
||||
| `role` | `string` | unchanged — `User.role` at issuance time |
|
||||
| `actorType` | `ActorType` | unchanged — always `ActorType.USER` for these sessions (research.md) |
|
||||
| **`jti`** | **`string`** | **new** — random UUID per issued token, the revocation-denylist key |
|
||||
| `iat` / `exp` | `number` | unchanged, standard JWT claims |
|
||||
|
||||
## AuthUser (existing type, unchanged)
|
||||
|
||||
`{ id, email, role, actorType }` — what `fastify.authenticate` sets on `request.user` after
|
||||
verifying the token; the same fields returned by login (FR-001) and the self-identity endpoint
|
||||
(FR-007), minus `jti`/`iat`/`exp` (those are token bookkeeping, not identity).
|
||||
|
||||
## Validation rules
|
||||
|
||||
- Login: `email` a valid email string, `password` non-empty. The response for "no such user"
|
||||
and "wrong password" MUST be byte-for-byte identical (FR-002/SC-003) — achieved by always
|
||||
running the bcrypt comparison against either the found user's hash or a fixed dummy hash when
|
||||
no user is found, so the response timing and shape never differ by branch.
|
||||
- Account creation (User Story 4): `email` valid and not already in use, `name` non-empty,
|
||||
`role` one of `ADMIN`/`AGENT` (never `CUSTOMER`, research.md), `password` non-empty (hashed
|
||||
before storage, never persisted or logged in plaintext).
|
||||
|
||||
## State / lifecycle
|
||||
|
||||
- `User.active` (new column, above) is the deactivation flag. The self-identity endpoint (User
|
||||
Story 3) re-fetches the `User` row by `sub` on every call and rejects if it no longer exists
|
||||
or `active: false` — this is the feature's only server-side re-validation path; `fastify
|
||||
.authenticate` itself does not re-fetch on every request (that would defeat the point of a
|
||||
stateless JWT check), so a deactivated account's *other* already-issued-token requests remain
|
||||
valid until that token's natural expiry or an explicit logout, exactly as spec.md's Edge Cases
|
||||
already scopes it ("a short token lifetime bounds the rest").
|
||||
@@ -0,0 +1,136 @@
|
||||
# Implementation Plan: Identity and Authentication
|
||||
|
||||
**Branch**: `010-identity-auth` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/010-identity-auth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Finishes the original, never-wired scaffold: `identity/auth`'s email-only login stub becomes a
|
||||
real bcryptjs-verified, JWT-issuing login; `fastify.authenticate` (currently a complete no-op)
|
||||
becomes a real signature/expiry/revocation check populating `request.user` and the already-
|
||||
shared `request.reqContext.actorId`/`actorType` fields every module since 007 already reads; a
|
||||
new `requireRole(...roles)` preHandler factory adds role-based gating on top. `User` gains
|
||||
`passwordHash` and `active` columns. Logout revokes a token's `jti` via the same Redis-denylist
|
||||
shape 002's replay protection already established.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+.
|
||||
|
||||
**Primary Dependencies**: `jsonwebtoken` (new — JWT sign/verify), `bcryptjs` (new — password
|
||||
hashing, pure JS to avoid native-build friction on Windows dev environments). Reuses existing
|
||||
`ioredis` for the revocation denylist.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma (`User.passwordHash`, `User.active`). Redis for the
|
||||
revocation denylist (`auth:revoked:<jti>`, TTL = remaining token lifetime) — same shape as
|
||||
002's `hasSeenJti`/`markJtiSeen`.
|
||||
|
||||
**Testing**: Vitest — unit tests for password verification's identical-response-on-failure
|
||||
behavior and the `requireRole` preHandler's role-matching logic; integration tests against real
|
||||
Postgres/Redis for the full login → gated-route → logout flow, and specifically re-verifying at
|
||||
least one already-shipped admin route per module (002-009) now genuinely rejects an invalid
|
||||
session.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `src/plugins/auth.plugin.ts`,
|
||||
populates `src/modules/identity/auth/`, adds `POST /admin/users` (a new small surface, placed
|
||||
alongside `identity/agents`'s own admin routes since account management is an identity concern,
|
||||
not `identity/auth`'s own — `identity/auth` owns login/logout/self-identity, not account CRUD).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Token verification (signature + expiry + Redis denylist check) must stay
|
||||
a single Redis round trip, not a Postgres query, on every gated request — only the self-identity
|
||||
endpoint (User Story 3) re-fetches from Postgres, by design (research.md).
|
||||
|
||||
**Constraints**: MUST NOT reveal account existence via login's failure response (FR-002); MUST
|
||||
NOT ever store or return a plaintext password (FR-003); MUST NOT change which existing routes
|
||||
are gated, only make the gate real (FR-006); MUST re-validate against current account state on
|
||||
the self-identity endpoint specifically, not on every request (data-model.md).
|
||||
|
||||
**Scale/Scope**: One modified plugin (`auth.plugin.ts`), one populated module
|
||||
(`identity/auth`), one new small admin-account-creation surface, two new dependencies, two new
|
||||
`User` columns, one seed-script update. Explicitly excludes: password reset, MFA, login-specific
|
||||
rate-limiting, and retroactively adding `requireRole('ADMIN')` to every existing admin route
|
||||
beyond a representative sample (research.md — tracked as this feature's own Polish-phase
|
||||
mechanical task, not a redesign of any other feature's access model).
|
||||
|
||||
## 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 | This feature authenticates SupportHub's own staff (`User`/`Agent`), explicitly never `CUSTOMER`-role accounts (research.md/spec.md Assumptions) — customer identity remains exclusively SaaS-delegated via 002's own trust boundary, untouched by this feature. Matches the constitution's own carve-out: "SupportHub is the sole authority only for its own domain: ... support org structure." | PASS |
|
||||
| II. Configuration Over Hardcoding | Token lifetime and any future role list are read from a config value (research.md's 4-hour default), never a magic number duplicated at each call site. | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | `identity/auth` keeps its standard shape; `requireRole` is exported from `identity/auth`'s own public `index.ts` for other modules' routes to compose with, the same way `fastify.authenticate` itself is already a cross-cutting plugin-level primitive, not a module import. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involvement in this feature. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
|
||||
| VI. Durable Audit & History | This feature is what finally makes 007-009's own audit fields (`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) accurate for real agent/admin actions instead of always falling back to `'unknown'` (research.md) — directly strengthens, not just satisfies, this principle. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Token verification and revocation are stateless/Redis-TTL-based, not an in-memory timer; two concurrent login attempts for the same account are independently evaluated with no shared mutable state (spec.md Edge Cases). | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature doesn't touch tickets or problems. | PASS — N/A |
|
||||
| Technology & Platform Constraints | Two new, narrowly-scoped dependencies (`jsonwebtoken`, `bcryptjs`), both justified in research.md; reuses existing Redis infrastructure, no new infrastructure category introduced. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
All gates above remain PASS after Phase 1 design. Principle VI is worth restating post-design:
|
||||
this feature has no user-facing "audit" screen of its own, but its real effect is retroactively
|
||||
correcting the audit trail of every feature since 007 that could only ever record `'unknown'`
|
||||
as the acting agent/admin — a materially more accurate audit history the moment this ships.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/010-identity-auth/
|
||||
├── 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/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # MODIFIED — User.passwordHash, User.active
|
||||
│ └── seed/roles.seed.ts # MODIFIED — seeded accounts get real password hashes
|
||||
├── src/
|
||||
│ ├── plugins/
|
||||
│ │ └── auth.plugin.ts # REPLACED stub — real JWT verify + revocation
|
||||
│ │ check, populates request.user + reqContext
|
||||
│ ├── infrastructure/
|
||||
│ │ └── cache/ # MODIFIED — revocation denylist helpers
|
||||
│ │ alongside the existing jti-replay helpers
|
||||
│ └── modules/
|
||||
│ └── identity/
|
||||
│ ├── auth/ # REPLACED stub — full real login/logout/
|
||||
│ │ ├── controller/ routes/ schema/ self-identity, requireRole exported from
|
||||
│ │ │ repository/ service/ types/ its own public index.ts
|
||||
│ │ │ mapper/ constants/ index.ts
|
||||
│ │ └── (no engine/ — no real decision logic beyond password/token checks)
|
||||
│ └── agents/ # MODIFIED — new POST /admin/users route
|
||||
│ └── (existing module, account-creation surface added alongside its own
|
||||
│ existing agent-roster admin routes)
|
||||
└── tests/
|
||||
├── unit/identity/ # password-failure-response-parity,
|
||||
│ requireRole matching logic
|
||||
└── integration/ # full login/gating/logout flow, spot-checks
|
||||
across 002-009's own existing admin routes
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project. `POST /admin/users` (account creation) lives under
|
||||
`identity/agents` rather than `identity/auth`, since `identity/auth` owns authentication
|
||||
mechanics (login/logout/self-identity) while account/roster management is already that
|
||||
module's own established concern — mirrors 006's own precedent of `identity/agents` owning
|
||||
agent-roster CRUD.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Quickstart: Validating Identity and Authentication
|
||||
|
||||
Prerequisites: migrations applied; `npm run prisma:seed` run so the two demo accounts exist
|
||||
with their new real passwords (documented in the seed script itself, local/dev use only).
|
||||
|
||||
## Scenario 1 — login (User Story 1)
|
||||
|
||||
1. `POST /auth/login` with the seeded admin's correct email/password. **Expected**: `200`, a
|
||||
token, and `{id, email, name, role: 'ADMIN'}`.
|
||||
2. Repeat with the correct email but a wrong password. **Expected**: `401`.
|
||||
3. Repeat with an email that doesn't exist at all. **Expected**: the exact same `401` body/
|
||||
status as step 2 — diff the two responses to confirm they're indistinguishable.
|
||||
|
||||
## Scenario 2 — route gating and role enforcement (User Story 2)
|
||||
|
||||
1. Call an existing admin route (e.g. `POST /admin/teams`) with no `Authorization` header.
|
||||
**Expected**: `401`.
|
||||
2. Repeat with a malformed token (`Bearer not-a-real-token`). **Expected**: `401`.
|
||||
3. Log in as the seeded agent (role `AGENT`); call an admin-only route gated by
|
||||
`requireRole('ADMIN')`. **Expected**: `403`.
|
||||
4. Log in as the seeded admin; repeat step 3's call. **Expected**: `200`/`201` (whatever that
|
||||
route normally returns on success).
|
||||
|
||||
## Scenario 3 — self-identity (User Story 3)
|
||||
|
||||
1. Log in; call `GET /auth/me` with the resulting token. **Expected**: `200`, identity matches
|
||||
the login response exactly.
|
||||
2. Directly deactivate that account (`active: false`) via a direct DB update (simulating an
|
||||
admin action no UI exists for yet); repeat the same `GET /auth/me` call with the same,
|
||||
still-unexpired token. **Expected**: `401` — re-validated against current account state, not
|
||||
the token's own claims.
|
||||
|
||||
## Scenario 4 — admin creates an account (User Story 4)
|
||||
|
||||
1. Log in as admin; `POST /admin/users` with a new email/name/role `AGENT`/password.
|
||||
**Expected**: `201`, response never includes the password or its hash.
|
||||
2. Log in as a non-admin (the seeded agent); repeat step 1. **Expected**: `403`.
|
||||
3. Immediately log in as the newly-created account with the password from step 1. **Expected**:
|
||||
`200` — no manual step needed in between.
|
||||
4. Repeat step 1 with an email already in use. **Expected**: `409`.
|
||||
|
||||
## Scenario 5 — logout (User Story 5)
|
||||
|
||||
1. Log in; call `POST /auth/logout` with the resulting token. **Expected**: `200`.
|
||||
2. Immediately reuse that same token on any gated route. **Expected**: `401` — rejected even
|
||||
though it hasn't naturally expired.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All five scenarios pass, and Scenario 2 is additionally verified against at least one
|
||||
already-shipped admin route from each of 002-009 (not just a route this feature itself adds),
|
||||
proving the real gate actually protects what the no-op stub never did.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Phase 0 Research: Identity and Authentication
|
||||
|
||||
## Decision: Finish the existing scaffold's own intended design, not a new one
|
||||
|
||||
- **Decision**: `User`/`UserRole`, the two seeded-but-passwordless demo accounts, and
|
||||
`AuthUser`/`JwtPayload` in `src/common/types` are the real target — this feature adds a
|
||||
`passwordHash` column, replaces `identity/auth`'s email-only stub with real password
|
||||
verification and JWT issuance, and makes `fastify.authenticate` actually verify that JWT.
|
||||
- **Rationale**: Every shape needed (the JWT payload's exact fields, the user/role model, even
|
||||
the bootstrap accounts) was already scaffolded before this session's spec-driven rebuild
|
||||
began — this is the same "give an existing, unwired scaffold its first real implementation"
|
||||
pattern every other phase in this codebase has followed, not a new design decision.
|
||||
- **Alternatives considered**: A separate, purpose-built `Session`/`Credential` model instead of
|
||||
extending `User` — rejected; `User` already has exactly the fields a staff account needs
|
||||
(email, name, role), and doc 06 never defined a competing entity for this.
|
||||
|
||||
## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one
|
||||
|
||||
- **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`,
|
||||
no-default, required environment variable already defined in `src/config/env.ts` and already
|
||||
set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven
|
||||
rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS`
|
||||
(a non-secret, defaultable number).
|
||||
- **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/
|
||||
`JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and
|
||||
has simply never been read by any code until now.
|
||||
- **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected
|
||||
once `JWT_SECRET` was found; would create two secrets doing the identical job.
|
||||
|
||||
## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing
|
||||
|
||||
- **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept
|
||||
consistent with `auth.plugin.ts`'s existing manual-decorator style rather than introducing the
|
||||
`@fastify/jwt` plugin ecosystem) and `bcryptjs` (pure JavaScript, no native compilation step —
|
||||
`bcrypt`/`argon2`'s native bindings are a real source of friction on this team's Windows dev
|
||||
environment, confirmed earlier this session when Docker/Prisma tooling already needed
|
||||
workarounds for the same class of platform friction).
|
||||
- **Rationale**: Both are the standard, widely-used choice for their job; `bcryptjs`
|
||||
specifically avoids re-litigating the native-module build problems this session has already
|
||||
hit more than once on Windows.
|
||||
- **Alternatives considered**: `@fastify/jwt` — rejected only for consistency with this
|
||||
codebase's existing hand-rolled decorator style, not a correctness concern. `argon2` —
|
||||
rejected for the same native-build-friction reason as `bcrypt`; `bcryptjs` is a well-
|
||||
established, secure-enough choice for this scale (JWT `Vitest`-verified via existing
|
||||
precedent, not a cryptographic novelty).
|
||||
|
||||
## Decision: Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism
|
||||
|
||||
- **Decision**: `JwtPayload` gains a `jti` (JWT ID, a random UUID per issued token). Logout adds
|
||||
that `jti` to a Redis key (`auth:revoked:<jti>`) with a TTL equal to the token's own remaining
|
||||
lifetime. `fastify.authenticate` checks this key (in addition to verifying the signature and
|
||||
expiry) before accepting a token.
|
||||
- **Rationale**: This is the exact same shape as 002's `hasSeenJti`/`markJtiSeen` replay-
|
||||
protection mechanism (`src/infrastructure/cache`) — reused directly rather than inventing a
|
||||
second Redis-backed token-tracking pattern. A TTL equal to remaining lifetime means the
|
||||
denylist entry is automatically cleaned up and never grows unbounded.
|
||||
- **Alternatives considered**: A full server-side session table (every issued token recorded in
|
||||
Postgres, checked on every request) — rejected as unnecessary weight; spec.md's own
|
||||
Assumptions explicitly chose "short-lived JWT + revocation-on-logout-only" over full session
|
||||
tracking, and Redis is already the right tool for this exact shape of check (fast, TTL-native).
|
||||
|
||||
## Decision: A 4-hour token lifetime
|
||||
|
||||
- **Decision**: Issued JWTs expire 4 hours after issuance (`exp` claim).
|
||||
- **Rationale**: Long enough that a working agent isn't repeatedly forced to re-authenticate
|
||||
mid-shift, short enough that a leaked/forgotten token's exposure window is bounded in hours,
|
||||
not days — a reasonable default for an internal staff tool with no remember-me/refresh-token
|
||||
flow in this feature's scope (Assumptions: no MFA/hardening pass yet either).
|
||||
- **Alternatives considered**: A refresh-token pair (short-lived access token + long-lived
|
||||
refresh token) — rejected as more mechanism than this feature's scope calls for; nothing in
|
||||
spec.md's user stories requires silent re-authentication, and it can be added later without
|
||||
breaking the token shape this feature establishes.
|
||||
|
||||
## Decision: `fastify.authenticate` also populates the existing, already-shared `reqContext.actorId`/`actorType`
|
||||
|
||||
- **Decision**: On a valid token, `fastify.authenticate` sets `request.user` (the full
|
||||
`AuthUser`) AND `request.reqContext.actorId = user.id`, `request.reqContext.actorType =
|
||||
ActorType.USER` — the same two `RequestContext` fields `authenticateProductIntegration`
|
||||
already populates for customer-originated requests (002).
|
||||
- **Rationale**: Every module from 007 onward already reads `request.reqContext?.actorId ??
|
||||
'unknown'` in its controllers (`actorFrom(request)` helpers in assignments, tickets,
|
||||
escalation, resolutions) expecting exactly this to eventually be populated by a real staff
|
||||
auth mechanism — this was a forward-compatible convention already in place, not something
|
||||
this feature needs to change call sites for. Every one of those audit trails (
|
||||
`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) becomes accurate for real
|
||||
agent/admin actions the moment this feature ships, with no changes to 007-009's own code.
|
||||
- **Alternatives considered**: A separate `request.user`-only convention, leaving `reqContext
|
||||
.actorId` customer-only — rejected; would require touching every existing `actorFrom` call
|
||||
site across four already-shipped features for no benefit, when the field was clearly designed
|
||||
to be auth-mechanism-agnostic from the start.
|
||||
|
||||
## Decision: Role-gating via a `requireRole(...roles)` preHandler factory, not a fixed decorator
|
||||
|
||||
- **Decision**: A new exported function, `requireRole(...allowedRoles: string[])`, returns a
|
||||
Fastify preHandler that checks `request.user?.role` against the given list, throwing
|
||||
`AuthorizationError` (403) if it doesn't match — used as
|
||||
`{ preHandler: [fastify.authenticate, requireRole('ADMIN')] }`. Not a fixed
|
||||
`fastify.requireAdmin` decorator, even though `ADMIN` is the only role checked today.
|
||||
- **Rationale**: A factory function generalizes to any future role/permission check (e.g. a
|
||||
hypothetical `SENIOR_AGENT`) without a new decorator per role; `fastify.authenticate` and
|
||||
`requireRole` compose as two separate preHandlers, matching this codebase's existing
|
||||
`[fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit]` two-step
|
||||
preHandler-array convention exactly.
|
||||
- **Alternatives considered**: A single combined `fastify.authenticateAdmin` decorator —
|
||||
rejected; would duplicate `fastify.authenticate`'s own token-verification logic for every new
|
||||
role instead of composing with it.
|
||||
|
||||
## Decision: `Agent.userId` is added as a nullable link, but linking is not this feature's own workflow
|
||||
|
||||
- **Decision**: `Agent` gains `userId String? @unique`, a nullable FK to `User.id` — the schema
|
||||
capability to say "this login identity's routing/skills profile is that `Agent` row" — but
|
||||
this feature does not add an endpoint or admin screen to set it. No seed data links the
|
||||
demo agent account to an `Agent` row either (none is seeded for it today).
|
||||
- **Rationale**: While investigating account creation (User Story 4), a real gap surfaced: a
|
||||
`User` (the thing that logs in) and an `Agent` (the thing 006/007 route tickets to) have never
|
||||
been connected — an `AGENT`-role `User` today has no way to be identified as a *specific*
|
||||
`Agent` for "tickets assigned to me"-style queries supporthub-web's own agent dashboard will
|
||||
need. Adding the column now is cheap and unblocks that later without a schema change at that
|
||||
point; building the actual linking workflow (which almost certainly belongs in 006's
|
||||
`identity/agents` admin screens, alongside team/skill assignment, not this identity/auth
|
||||
feature) is a real, separate piece of scope this feature doesn't need to solve today.
|
||||
- **Alternatives considered**: Building the full link-an-account-to-an-agent workflow as part of
|
||||
this feature — rejected as scope creep; this feature's own job is proving a `User` *can*
|
||||
authenticate and be authorized, not completing every downstream consumer of that identity.
|
||||
Making `Agent.userId` required — rejected; an `Agent` created via 006's existing screens has
|
||||
no `User` account requirement today and shouldn't suddenly need one just because this feature
|
||||
exists.
|
||||
|
||||
## Decision: Which existing routes get gated is unchanged — only the gate itself becomes real
|
||||
|
||||
- **Decision**: This feature does not add `fastify.authenticate` to any route that doesn't
|
||||
already have it, and does not add `requireRole('ADMIN')` to every existing admin route as
|
||||
part of this feature's own implementation — SC-002 is satisfied by demonstrating the
|
||||
mechanism works on a representative sample (one action per module), with the mechanical work
|
||||
of adding `requireRole('ADMIN')` to every remaining `/admin/*` route across 002-009 tracked as
|
||||
this feature's own Polish-phase task, not a scope expansion into re-designing any other
|
||||
feature's authorization model.
|
||||
- **Rationale**: FR-006 is explicit: "this feature does not change which routes are gated, only
|
||||
makes the gate real." Deciding which of the many already-shipped admin routes should be
|
||||
admin-only vs. any-authenticated-agent is a real per-route judgment call (e.g., should an
|
||||
agent be able to create a hierarchy node? almost certainly not; should an agent read one?
|
||||
probably yes) — this feature makes that judgment call possible to enforce, and applies it
|
||||
everywhere in its own Polish phase, but doesn't silently redesign any other feature's own
|
||||
intended access model beyond what's obviously admin-only (write/config endpoints) vs.
|
||||
read/agent-usable.
|
||||
- **Alternatives considered**: Leaving every existing route exactly as `fastify.authenticate`-
|
||||
only (no `requireRole`) and treating role-based gating as entirely out of scope — rejected;
|
||||
spec.md's own User Story 2/FR-005 explicitly requires admin-only enforcement to exist
|
||||
somewhere concrete, not just as an available-but-unused mechanism.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Feature Specification: Identity and Authentication
|
||||
|
||||
**Feature Branch**: `010-identity-auth`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "supporthub-web's admin/agent role-gating (its own Phase 1,
|
||||
001-agent-admin-ui) has no real backend to build on: `fastify.authenticate` is a complete
|
||||
no-op stub, and the existing `identity/auth` scaffold's login endpoint accepts an email alone
|
||||
with no password and returns the raw user record, never a session. Build minimal, real
|
||||
agent/admin authentication in supporthub-api first, as a prerequisite for the frontend feature."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent or admin logs in and receives a session (Priority: P1)
|
||||
|
||||
A user with a SupportHub-issued account (never a SaaS-delegated identity — this is SupportHub's
|
||||
own staff, per Constitution Principle I's "support org structure" being SupportHub's own
|
||||
authority) logs in with their email and password and receives a session token that authorizes
|
||||
their subsequent requests.
|
||||
|
||||
**Why this priority**: Every other story in this feature, and the entire admin/agent-facing
|
||||
half of supporthub-web, has nothing to build on without this.
|
||||
|
||||
**Independent Test**: Log in with a seeded account's correct credentials; confirm a session
|
||||
token is returned and a subsequent authenticated request using it succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user account with a set password, **When** they submit the correct email and
|
||||
password, **Then** they receive a session token and their own `id`/`email`/`name`/`role`.
|
||||
2. **Given** a user account, **When** they submit an incorrect password, **Then** the request
|
||||
is rejected with no session token issued — the rejection message MUST NOT reveal whether the
|
||||
email itself was valid (never "wrong password" vs "no such user" as distinguishable
|
||||
responses).
|
||||
3. **Given** no account exists for a submitted email, **When** login is attempted, **Then** it
|
||||
is rejected with the same indistinguishable-from-wrong-password response as Scenario 2.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1)
|
||||
|
||||
Every existing `/admin/*` route (and any other route already gated by the `fastify.authenticate`
|
||||
stub across features 002-009) actually rejects a request with no valid session, and every route
|
||||
that should be admin-only actually rejects a valid session whose role isn't `ADMIN`.
|
||||
|
||||
**Why this priority**: This is the entire point of the feature — without it, User Story 1
|
||||
issues a token that nothing on the backend actually checks, which is no better than the current
|
||||
no-op stub.
|
||||
|
||||
**Independent Test**: Call an existing admin route (e.g. creating a team) with no
|
||||
`Authorization` header, with an expired/malformed token, with a valid agent-role token, and
|
||||
with a valid admin-role token; confirm exactly the last one succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a request with no `Authorization` header, **When** it hits a route gated by
|
||||
`fastify.authenticate`, **Then** it's rejected as unauthorized.
|
||||
2. **Given** a request with a malformed, expired, or tampered token, **When** it hits a gated
|
||||
route, **Then** it's rejected as unauthorized — never silently treated as anonymous/no-op the
|
||||
way the current stub does.
|
||||
3. **Given** a valid session for a user whose role is `AGENT`, **When** it hits a route that
|
||||
requires the `ADMIN` role specifically, **Then** it's rejected as forbidden, distinct from
|
||||
the unauthorized case above.
|
||||
4. **Given** a valid session for a user whose role is `ADMIN`, **When** it hits any route gated
|
||||
by either `fastify.authenticate` or an admin-only requirement, **Then** it succeeds.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An authenticated user can identify themselves (Priority: P2)
|
||||
|
||||
A logged-in user can ask "who am I" and get back their own identity and role, without needing
|
||||
to decode their own session token client-side.
|
||||
|
||||
**Why this priority**: Depends on User Story 1. supporthub-web's role-gating (rendering the
|
||||
admin portal only for admins) needs a reliable way to know the current session's role after
|
||||
the token is already held — decoding a JWT's claims client-side is a reasonable fallback, but a
|
||||
real endpoint is what lets that identity be revalidated against current server-side state (e.g.
|
||||
a deactivated account) rather than trusting a possibly-stale token's own claims forever.
|
||||
|
||||
**Independent Test**: Log in, then call the "who am I" endpoint with the resulting session;
|
||||
confirm it returns the same identity and role as the login response, and that it's rejected
|
||||
under the same conditions as User Story 2.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid session, **When** the identity endpoint is called, **Then** it returns the
|
||||
current `id`/`email`/`name`/`role` for that session.
|
||||
2. **Given** a session for an account that has since been deactivated, **When** the identity
|
||||
endpoint (or any gated route) is called, **Then** it's rejected — a session's validity is
|
||||
re-checked against current account state, not just the token's own unexpired signature.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
|
||||
|
||||
An admin creates a new user account (agent or admin role) with an initial password, since there
|
||||
is no public self-signup for SupportHub's own staff accounts.
|
||||
|
||||
**Why this priority**: Depends on User Story 2 (admin-only gating). Without this, the only way
|
||||
to add a second real account is a direct database write — fine for the one seeded bootstrap
|
||||
admin, not for onboarding a real team.
|
||||
|
||||
**Independent Test**: As an admin, create a new agent account with a password; confirm the new
|
||||
account can immediately log in (User Story 1) with those credentials.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated admin, **When** they create a new account with an email,
|
||||
name, role, and initial password, **Then** it's created and can log in immediately.
|
||||
2. **Given** a non-admin session, **When** they attempt to create an account, **Then** it's
|
||||
rejected as forbidden (User Story 2's own guarantee, exercised here specifically).
|
||||
3. **Given** an email already in use by an existing account, **When** account creation is
|
||||
attempted, **Then** it's rejected — never a second account silently sharing one email.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - A user logs out (Priority: P3)
|
||||
|
||||
A logged-in user can end their own session explicitly, rather than only ever waiting for it to
|
||||
expire.
|
||||
|
||||
**Why this priority**: Lowest priority — a short-lived token that simply expires already
|
||||
bounds the exposure of a lost/leftover session; an explicit logout is a UX nicety layered on
|
||||
top, not a security-critical gap the way User Stories 1-2 are.
|
||||
|
||||
**Independent Test**: Log in, log out, then attempt to use the same token again; confirm it's
|
||||
now rejected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a valid session, **When** the user logs out, **Then** that specific token is
|
||||
rejected on any subsequent use, even though it hasn't yet expired.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens to a session already issued to a user whose password is changed or whose account
|
||||
is deactivated? Out of scope for this feature to build a full revocation-on-every-write
|
||||
mechanism (Assumptions) — User Story 3's re-check-on-identity-call is the only server-side
|
||||
re-validation this feature guarantees; a short token lifetime (Assumptions) bounds the rest.
|
||||
- What happens if two login attempts for the same account happen concurrently with different
|
||||
passwords (e.g. a credential-stuffing attempt racing a real login)? Each is evaluated
|
||||
independently against the stored password hash — no shared mutable state between them, so no
|
||||
new concurrency concern is introduced.
|
||||
- What happens to the two demo accounts the seed script already creates
|
||||
(`admin@supporthub.internal`, `agent@supporthub.internal`) which currently have no password?
|
||||
This feature MUST give them real, seeded passwords (documented for local/dev use only) so the
|
||||
existing seed script keeps producing an immediately-usable bootstrap admin — never account
|
||||
IDs that exist but can never actually log in.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let a user log in with email and password, returning a session
|
||||
token and their own identity (`id`/`email`/`name`/`role`) on success.
|
||||
- **FR-002**: A login attempt with an incorrect password or an unrecognized email MUST be
|
||||
rejected with an indistinguishable response — the system MUST NOT reveal whether a submitted
|
||||
email corresponds to an existing account.
|
||||
- **FR-003**: Passwords MUST be stored only as a salted hash, never in plaintext or in any
|
||||
reversible form.
|
||||
- **FR-004**: `fastify.authenticate` MUST reject a request with a missing, malformed, expired,
|
||||
or otherwise invalid session token — it MUST NOT pass a request through as anonymous/no-op
|
||||
the way the current stub does.
|
||||
- **FR-005**: The system MUST provide a way to require a specific role (at minimum, `ADMIN`)
|
||||
on a route, distinct from and layered on top of `fastify.authenticate`'s own valid-session
|
||||
check, returning a distinguishable forbidden (not unauthorized) response when the role
|
||||
requirement fails.
|
||||
- **FR-006**: Every existing route currently gated by `fastify.authenticate` (across
|
||||
002-009's own admin/read surfaces) MUST continue to work for a valid session and MUST now
|
||||
actually reject an invalid one — this feature does not change which routes are gated, only
|
||||
makes the gate real.
|
||||
- **FR-007**: The system MUST provide an endpoint that returns the current session's own
|
||||
identity and role, re-validated against current account state (not solely the token's own
|
||||
claims).
|
||||
- **FR-008**: The system MUST let an authenticated admin create a new account (email, name,
|
||||
role, initial password), rejecting a duplicate email.
|
||||
- **FR-009**: The system MUST let a user invalidate their own current session token before its
|
||||
natural expiry.
|
||||
- **FR-010**: The two existing seeded demo accounts MUST be given real, working passwords as
|
||||
part of this feature, documented as local/development credentials.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **User**: A SupportHub staff identity — email, name, role (`ADMIN`/`AGENT`), and (new in this
|
||||
feature) a securely hashed password. Distinct from `Agent` (the routing/skills/team-membership
|
||||
profile an `AGENT`-role `User` has) and from a SaaS-delegated customer identity, which this
|
||||
feature does not touch.
|
||||
- **Session**: The short-lived, server-issued proof that a `User` authenticated successfully,
|
||||
carrying their `id`, `email`, and `role`; revocable before its natural expiry (User Story 5).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of requests to a `fastify.authenticate`-gated route with no valid session are
|
||||
rejected, verified across every module's existing admin routes (002-009), not just this
|
||||
feature's own new endpoints.
|
||||
- **SC-002**: 100% of admin-only actions are rejected for a valid non-admin session, verified for
|
||||
at least one action from each module that has one.
|
||||
- **SC-003**: 0% of login rejections reveal whether the submitted email corresponds to an
|
||||
existing account, verified by comparing the exact response for both cases.
|
||||
- **SC-004**: 100% of passwords are stored only as a hash — verified by inspecting the stored
|
||||
representation directly, never as a value that could be reversed to the original password.
|
||||
- **SC-005**: An admin can create a working new account and have it log in successfully within
|
||||
the same test run, with no manual database step.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **No password-reset/forgot-password flow is built in this feature** — an admin can create a
|
||||
new account (User Story 4), but resetting an existing one's forgotten password is out of
|
||||
scope; the smallest viable fix today is an admin recreating the account or a direct
|
||||
operational fix, not a self-service flow.
|
||||
- **Session tokens are short-lived JWTs with a fixed expiry, not a server-side session store per
|
||||
token** — logout (User Story 5) is implemented via a revocation check (a denylist of
|
||||
logged-out-early tokens), not full server-side session tracking; this keeps token validation
|
||||
fast (no DB round trip on every request) while still making explicit logout meaningfully
|
||||
different from "wait for expiry." The exact expiry duration and revocation mechanism are
|
||||
research.md decisions, not fixed here.
|
||||
- **No account self-registration** — every account is created either by the seed script (the
|
||||
two bootstrap demo accounts) or by an existing admin (User Story 4); there is no public
|
||||
sign-up endpoint, consistent with these being SupportHub's own staff accounts, never a
|
||||
SaaS-delegated customer identity.
|
||||
- **This feature does not add a password-strength policy, MFA, or rate-limiting specifically
|
||||
for login attempts beyond what 002's existing generic rate-limit infrastructure might already
|
||||
cover incidentally** — those are real hardening concerns explicitly named in
|
||||
`docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), not this
|
||||
feature's job to anticipate.
|
||||
- **The `CUSTOMER` value already defined on `UserRole` is never assigned by this feature** — no
|
||||
code path in this feature creates a `User` with `role: CUSTOMER`; per Constitution Principle
|
||||
I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never
|
||||
a local `User` row. The enum value's continued existence is a pre-existing scaffold detail
|
||||
this feature doesn't need to remove to stay correct.
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
description: "Task list for 010-identity-auth"
|
||||
---
|
||||
|
||||
# Tasks: Identity and Authentication
|
||||
|
||||
**Input**: Design documents from `specs/010-identity-auth/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/identity-auth-contract.md](./contracts/identity-auth-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Tests**: Included as first-class tasks. Pure logic worth a unit test: the identical-failure-
|
||||
response behavior (FR-002/SC-003) and the `requireRole` matching logic. Everything else is
|
||||
best proven end-to-end against a real Postgres/Redis, including a specific pass re-verifying
|
||||
existing 002-009 admin routes now actually reject an invalid session.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 login, US2 = P1 route/role gating,
|
||||
US3 = P2 self-identity, US4 = P2 admin-created accounts, US5 = P3 logout).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`,
|
||||
`@types/bcryptjs`) to `package.json`
|
||||
- [x] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and
|
||||
`AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`,
|
||||
exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`,
|
||||
`authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape
|
||||
- [x] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its
|
||||
existing files, replacing the email-only `AuthService.validateCredentials`/
|
||||
`AuthRepository.findByEmail`-only stub content
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Schema for the entities every user story needs.
|
||||
|
||||
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
|
||||
|
||||
- [x] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean
|
||||
@default(true)`) to `prisma/schema.prisma`, plus `Agent.userId` (`String? @unique`, FK to
|
||||
`User.id` — research.md's additive, not-yet-consumed link) (depends on T001-T003)
|
||||
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`)
|
||||
for T004 (depends on T004)
|
||||
- [x] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both
|
||||
seeded accounts (`admin@supporthub.internal`, `agent@supporthub.internal`), documenting
|
||||
the plaintext dev password in a comment directly above the hash call (local/dev use only,
|
||||
per spec.md Edge Cases) (depends on T005)
|
||||
|
||||
**Checkpoint**: Schema migrated, demo accounts have real passwords. User stories can now be
|
||||
built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - An agent or admin logs in and receives a session (Priority: P1) 🎯 MVP (part 1)
|
||||
|
||||
**Goal**: Real password verification and JWT issuance, with an identical failure response
|
||||
regardless of which reason login failed.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and
|
||||
given no user found at all, the login-failure path produces byte-identical response
|
||||
shape/status in the non-matching and no-user cases — in
|
||||
`tests/unit/identity/login-failure-parity.test.ts`
|
||||
- [x] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a
|
||||
token + identity; wrong password and nonexistent email produce the same `401`) against a
|
||||
real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken`
|
||||
(jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per
|
||||
data-model.md) in `identity/auth/mapper/` (depends on T002)
|
||||
- [x] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in
|
||||
`identity/auth/repository/` (depends on T005)
|
||||
- [x] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against
|
||||
either the found hash or a fixed dummy hash when not found (FR-002's timing/shape
|
||||
parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for
|
||||
every failure branch — in `identity/auth/service/` (depends on T009, T010)
|
||||
- [x] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the
|
||||
email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered
|
||||
from `src/api/routes.ts` (depends on T011)
|
||||
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: Login works and never leaks account existence through its failure response.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1) 🎯 MVP (part 2)
|
||||
|
||||
**Goal**: `fastify.authenticate` actually verifies; `requireRole` enforces role on top of it;
|
||||
every existing gated route is re-verified.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong
|
||||
role throws `AuthorizationError`, no `request.user` at all throws) in
|
||||
`tests/unit/identity/require-role.test.ts`
|
||||
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token,
|
||||
wrong-role token, correct-role token) against a real Postgres/Redis in
|
||||
`tests/integration/identity-auth-flow.test.ts` (depends on T008)
|
||||
- [x] T016 [US2] Integration test spot-checking at least one existing admin route per module
|
||||
(002's product-integration admin route, 004's knowledge admin route, 006's team-creation
|
||||
route, 007's manual-assignment route, 008's SLA-policy route, 009's investigation route)
|
||||
now rejects a missing/invalid session — in `tests/integration/identity-auth-flow.test.ts`
|
||||
(depends on T015)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in
|
||||
`src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same
|
||||
Redis-key-with-TTL shape, research.md) (depends on T002)
|
||||
- [x] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and
|
||||
expiry, check T017's revocation denylist, and on success set `request.user` (the full
|
||||
`AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on
|
||||
any failure, never pass through as anonymous (depends on T009, T017)
|
||||
- [x] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks
|
||||
`request.user?.role`, throws `AuthorizationError` if it doesn't match) in
|
||||
`identity/auth/service/` (or a dedicated `identity/auth/guards/` file), exported from
|
||||
`identity/auth`'s public `index.ts` (depends on T018)
|
||||
- [x] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across
|
||||
002-009 that doesn't already distinguish agent-vs-admin access (product-integration
|
||||
admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only
|
||||
routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`-
|
||||
only (research.md's own scoping: this is a mechanical pass applying an existing judgment,
|
||||
not a new design) (depends on T019)
|
||||
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: Every P1 user story is complete — a session is real, and it's actually checked
|
||||
everywhere it's supposed to be. This is the feature's MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - An authenticated user can identify themselves (Priority: P2)
|
||||
|
||||
**Goal**: A self-identity endpoint that re-validates against current account state.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login;
|
||||
deactivating the account rejects a still-unexpired token's use of this endpoint
|
||||
specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws
|
||||
`AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/
|
||||
service/` (depends on T010)
|
||||
- [x] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/
|
||||
controller/` + `routes/` (depends on T023)
|
||||
- [x] T025 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
|
||||
|
||||
**Checkpoint**: A session can be introspected and is re-validated against live account state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
|
||||
|
||||
**Goal**: Admin-only account creation, immediately usable to log in.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and
|
||||
it logs in immediately; non-admin rejected; duplicate email rejected) — in
|
||||
`tests/integration/identity-auth-flow.test.ts` (depends on T021)
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on
|
||||
duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md
|
||||
— account creation lives alongside `identity/agents`'s own roster CRUD, not
|
||||
`identity/auth`) (depends on T009)
|
||||
- [x] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` +
|
||||
`requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from
|
||||
`src/api/routes.ts` — response never includes the password or hash (depends on T019, T027)
|
||||
- [x] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass
|
||||
|
||||
**Checkpoint**: New staff accounts can be provisioned without a manual database write.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - A user logs out (Priority: P3)
|
||||
|
||||
**Goal**: Explicit, immediate session revocation.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 5.
|
||||
|
||||
### Tests for User Story 5
|
||||
|
||||
- [x] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same
|
||||
token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts`
|
||||
(depends on T021)
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [x] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken`
|
||||
— in `identity/auth/service/` (depends on T017)
|
||||
- [x] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in
|
||||
`identity/auth/controller/` + `routes/` (depends on T031)
|
||||
- [x] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
|
||||
|
||||
**Checkpoint**: All five user stories work independently and together — real login, real
|
||||
gating, self-identity, admin-provisioned accounts, and logout form one coherent auth system.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing
|
||||
broke elsewhere, then the full integration suite (including 002-009's own suites, since
|
||||
T020 adds `requireRole` to their existing routes) 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-US5
|
||||
- **User Story 2 (Phase 4)**: Depends on US1 (a real token to verify)
|
||||
- **User Story 3 (Phase 5)**: Depends on US2 (the gate US3's own route sits behind)
|
||||
- **User Story 4 (Phase 6)**: Depends on US2 (`requireRole('ADMIN')`)
|
||||
- **User Story 5 (Phase 7)**: Depends on US2 (the gate logout's own route sits behind) and
|
||||
US1's token shape (`jti`)
|
||||
- **Polish (Phase 8)**: Depends on all five user stories
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001-T003 (independent scaffolding)
|
||||
- T007 (unit test) alongside T009-T011 (the implementation it tests)
|
||||
- T014 (unit test) alongside T019 (the implementation it tests)
|
||||
- T034 in Polish
|
||||
|
||||
### Sequencing Note
|
||||
|
||||
T020 (adding `requireRole('ADMIN')` across 002-009's existing routes) is the one task in this
|
||||
feature that touches code outside `identity/*` — run each touched module's own existing test
|
||||
suite immediately after, not only in T036's final regression pass, so a role-gating regression
|
||||
in, say, 007's own suite is caught close to its cause rather than at the very end.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1-2 Only)
|
||||
|
||||
1. Setup + Foundational (T001-T006)
|
||||
2. User Story 1 (T007-T013) → login works, no account-existence leak
|
||||
3. User Story 2 (T014-T021) → the gate is real everywhere it already existed
|
||||
4. **STOP and VALIDATE**: Quickstart Scenarios 1-2 pass, including the cross-module spot-check
|
||||
(T016). This is the feature's MVP — every other user story is a smaller addition on top of a
|
||||
now-real auth system.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → schema migrated, demo accounts have real passwords
|
||||
2. Add User Story 1 → login is real
|
||||
3. Add User Story 2 → the gate is real everywhere (P1-complete, MVP)
|
||||
4. Add User Story 3 → self-identity, re-validated against live account state
|
||||
5. Add User Story 4 → admins can provision new accounts
|
||||
6. Add User Story 5 → explicit logout
|
||||
7. Polish → full regression across every feature this touches
|
||||
@@ -0,0 +1,58 @@
|
||||
# Specification Quality Checklist: Agent Ticket Queue
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**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
|
||||
|
||||
- This feature was not on the original 11-phase roadmap, and wasn't anticipated by 010's own
|
||||
scope either — it surfaced while beginning supporthub-web's 001-agent-admin-ui planning: its
|
||||
User Story 1 (agent dashboard) needs to list "tickets currently assigned to me," and no route,
|
||||
repository method, or even a documented gap anywhere in the ticketing or orchestration modules
|
||||
answers that question. Numbered 011 in supporthub-api's own sequence for the same reason 010
|
||||
was — a genuine, immediately-needed backend prerequisite discovered while building the
|
||||
consuming feature, not deferred hardening.
|
||||
- User Story 1 (linking `Agent.userId`) is itself a "finish the scaffold's own intended design"
|
||||
case, same pattern as 010: the field was added in 010-identity-auth specifically for this
|
||||
purpose ("schema capability only, no workflow sets it yet") and simply never got its own
|
||||
endpoint until now.
|
||||
- Deliberately narrow: this is not a general ticket search/list endpoint (Assumptions) — only
|
||||
the one query supporthub-web's agent dashboard actually needs, to avoid speculative scope
|
||||
beyond what 001-agent-admin-ui's own spec calls for.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: research.md's plan to add a dedicated
|
||||
`AgentsService.requireAgentForUser` guard (rather than inlining the lookup in the ticketing
|
||||
controller) turned out to matter for testability, not just style — it let T007's unit test
|
||||
exercise the "no linked agent" rejection with a fake repository, with no real database
|
||||
involved, exactly the kind of isolated unit coverage tasks.md asked for. Worth defaulting to
|
||||
this shape (a small service method over inline controller logic) whenever a cross-module
|
||||
guard needs its own unit test.
|
||||
- No other deviations from plan.md — the two-routes-sharing-one-service-method design, the
|
||||
proactive existence/role/duplicate-link checks, and the new composite index all worked exactly
|
||||
as researched, and the full regression suite (unit + integration) stayed clean throughout.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Contract: Agent Ticket Queue
|
||||
|
||||
## `PATCH /admin/agents/:agentId` (existing route, extended)
|
||||
|
||||
**Auth**: `fastify.authenticate` (unchanged — this route was already agent-usable, not
|
||||
admin-only, since agents may already update their own roster fields per existing precedent).
|
||||
|
||||
**Request body** (existing shape plus one new optional field):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "string, optional",
|
||||
"teamId": "string, optional",
|
||||
"active": "boolean, optional",
|
||||
"userId": "string | null, optional"
|
||||
}
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
- `200` — updated `Agent`, including `userId`.
|
||||
- `404` — `agentId` doesn't exist, or (new) the target `userId` doesn't exist as a `User`.
|
||||
- `400` — (new) the target `User`'s role is not `AGENT`.
|
||||
- `409` — (new) the target `userId` is already linked to a different `Agent`.
|
||||
|
||||
## `GET /agents/me/tickets`
|
||||
|
||||
**Auth**: `fastify.authenticate` only — no `requireRole`, since any authenticated `AGENT` (or
|
||||
`ADMIN`, who may also hold an agent profile) may call this for their own session.
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "string",
|
||||
"code": "string",
|
||||
"status": "string",
|
||||
"priority": "string",
|
||||
"severity": "string",
|
||||
"product": { "id": "string", "externalProductId": "string", "name": "string" },
|
||||
"customer": { "externalUserId": "string", "externalTenantId": "string" },
|
||||
"assignedAt": "ISO 8601 datetime",
|
||||
"sla": {
|
||||
"status": "string",
|
||||
"firstResponseDueAt": "ISO 8601 datetime | null",
|
||||
"resolutionDueAt": "ISO 8601 datetime | null",
|
||||
"breachedAt": "ISO 8601 datetime | null"
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
`sla` is `null` when no `SLARun` exists yet for that ticket.
|
||||
|
||||
**Response `404`**: the session's `User` has no linked `Agent` row
|
||||
(`{ "success": false, "error": { "code": "NOT_FOUND", "message": "No agent profile is linked to this account." } }`).
|
||||
|
||||
## `GET /admin/agents/:agentId/tickets`
|
||||
|
||||
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
|
||||
|
||||
**Response**: identical shape to `GET /agents/me/tickets`'s `200`, for the `agentId` named in
|
||||
the URL. `404` if `agentId` doesn't exist as an `Agent` row (a plain "agent not found," distinct
|
||||
from the self-route's "no agent linked to this account").
|
||||
@@ -0,0 +1,48 @@
|
||||
# Data Model: Agent Ticket Queue
|
||||
|
||||
## Modified: `Agent`
|
||||
|
||||
No new column — `userId`/`user` already exist (010-identity-auth). This feature is the first to
|
||||
actually write `userId` through an endpoint, and adds the supporting index below.
|
||||
|
||||
```prisma
|
||||
model Assignment {
|
||||
// ...existing fields unchanged...
|
||||
|
||||
@@index([ticketId, isCurrent])
|
||||
@@index([agentId, isCurrent]) // NEW — supports "current assignments for agent X"
|
||||
@@map("assignments")
|
||||
}
|
||||
```
|
||||
|
||||
## New (response-shape only, no new table): `AssignedTicketSummary`
|
||||
|
||||
A read projection, not a persisted entity — assembled per-request from `Ticket` joined to its
|
||||
current `Assignment`, `Product`, `CustomerReference`, and (if present) `SLARun`.
|
||||
|
||||
| Field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `Ticket.id` | |
|
||||
| `code` | `Ticket.code` | e.g. `ACME-2026-0042` |
|
||||
| `status` | `Ticket.status` | One of the 12 lifecycle states (003's own state machine) |
|
||||
| `priority` | `Ticket.priority` | Opaque string, as already modeled |
|
||||
| `severity` | `Ticket.severity` | Opaque string, as already modeled |
|
||||
| `product` | `Ticket.product` | `{ id, externalProductId, name }` |
|
||||
| `customer` | `Ticket.customer` | `{ externalUserId, externalTenantId }` — no PII beyond what 002's own `CustomerReference` already stores |
|
||||
| `assignedAt` | `Assignment.assignedAt` | The current assignment's start time |
|
||||
| `sla` | `SLARun` (nullable) | `{ status, firstResponseDueAt, resolutionDueAt, breachedAt }` or `null` if no `SLARun` exists yet for this ticket |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- **Linking** (`PATCH /admin/agents/:agentId`'s new `userId` field):
|
||||
- The target `User` must exist and have role `AGENT` (FR-001).
|
||||
- No other `Agent` row may already have that `userId` (FR-001) — checked proactively before
|
||||
the write (research.md), not left to the database's own `@unique` constraint to reject.
|
||||
- `userId: null` explicitly unlinks (distinct from omitting the field, which leaves it
|
||||
unchanged — the existing `updateAgentSchema` pattern for optional fields).
|
||||
- **Listing** (`GET /agents/me/tickets`, `GET /admin/agents/:agentId/tickets`):
|
||||
- Only `Assignment.isCurrent: true` rows are considered (FR-003).
|
||||
- The agent-self route resolves `agentId` exclusively from `request.user.id` → `Agent.userId`
|
||||
lookup — never from any request input (FR-004).
|
||||
- A session with no linked `Agent` row throws a specific `NotFoundError`
|
||||
("No agent profile is linked to this account."), never an empty array (FR-006).
|
||||
@@ -0,0 +1,110 @@
|
||||
# Implementation Plan: Agent Ticket Queue
|
||||
|
||||
**Branch**: `011-agent-ticket-queue` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/011-agent-ticket-queue/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Finishes wiring `Agent.userId` (added in 010-identity-auth as schema-only) by extending the
|
||||
existing `PATCH /admin/agents/:agentId` with an optional `userId`, then adds the ticket-query
|
||||
this unblocks: `GET /agents/me/tickets` (agent's own session) and
|
||||
`GET /admin/agents/:agentId/tickets` (admin, explicit agent) — both returning the same
|
||||
summarized, dashboard-ready projection of every ticket currently assigned to that agent.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — reuses Prisma, the existing `identity/agents` and
|
||||
`ticketing/tickets` modules, and 010's `requireRole`.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma. Adds one index (`Assignment @@index([agentId, isCurrent])`)
|
||||
— the query this feature introduces (all current assignments for one agent) has no supporting
|
||||
index today; the existing `[ticketId, isCurrent]` index doesn't serve an agent-first lookup.
|
||||
|
||||
**Testing**: Vitest — unit test for the "no linked Agent" rejection path; integration tests
|
||||
against real Postgres/Redis for linking, the agent's-own-session query, the admin explicit-
|
||||
agent query, and cross-agent isolation (one agent never sees another's tickets).
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `identity/agents` (linking
|
||||
endpoint, `userId` already returned by existing reads) and `ticketing/tickets` (new summary
|
||||
query + routes) — no new module, since "list my tickets" is a ticketing concern reading
|
||||
orchestration's `Assignment` state, matching 003's existing module boundary (ticketing already
|
||||
depends on orchestration's public surface for status-transition side effects).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: The ticket-summary query is one indexed query for current assignments
|
||||
plus a single batched fetch of their tickets (with product/customer/SLA-run relations) — no
|
||||
N+1 per-ticket round trip, matching FR-003/SC-001's "single request" requirement.
|
||||
|
||||
**Constraints**: MUST NOT let an agent's own-session call accept a client-supplied `agentId`
|
||||
(FR-004 — always resolved from the session's own linked `Agent` row). MUST reject a session
|
||||
with no linked `Agent` row distinguishably from an empty list (FR-006).
|
||||
|
||||
**Scale/Scope**: One new admin endpoint (link), two new read endpoints (agent-self, admin-
|
||||
explicit) sharing one service method, one new Prisma index. Explicitly excludes: a general
|
||||
ticket search/filter endpoint, pagination, and self-service linking (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 | Purely internal to SupportHub's own domain (agent roster, ticket assignment) — no SaaS/customer identity involved. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | No new configurable values introduced. | PASS — N/A |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | The new query lives in `ticketing/tickets` (the module that owns `Ticket`), reading `Assignment` via orchestration's own public `index.ts` export — no reach-through to orchestration's internals. The link endpoint lives in `identity/agents`, alongside its existing agent CRUD. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | No new mutable state beyond the `Agent.userId` link itself, which `Agent`'s own `updatedAt` already timestamps. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries plus one simple linking write guarded by the existing `@unique` constraint on `Agent.userId` (a concurrent double-link race is rejected by the database itself, not application logic). | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — no problem-management involvement. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/011-agent-ticket-queue/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ └── schema.prisma # MODIFIED — Assignment @@index([agentId, isCurrent])
|
||||
└── src/
|
||||
└── modules/
|
||||
├── identity/
|
||||
│ └── agents/ # MODIFIED — link-user endpoint alongside existing agent CRUD
|
||||
│ ├── controller/ routes/ schema/
|
||||
│ └── service/
|
||||
└── ticketing/
|
||||
└── tickets/ # MODIFIED — new agent-assigned-tickets summary query
|
||||
├── controller/ routes/ schema/
|
||||
└── service/ mapper/
|
||||
└── tests/
|
||||
├── unit/identity/ # "no linked Agent" rejection unit test
|
||||
└── integration/ # linking flow + both list endpoints + cross-agent isolation
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module. The link endpoint extends
|
||||
`identity/agents` (already owns agent CRUD); the ticket-summary query extends
|
||||
`ticketing/tickets` (already owns `Ticket`) rather than a new module, since this is one small
|
||||
read query, not a new bounded concern.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,35 @@
|
||||
# Quickstart: Validating Agent Ticket Queue
|
||||
|
||||
Prerequisites: 010-identity-auth's login working; an existing `Team`/`Agent`/`User` (role
|
||||
`AGENT`) to link.
|
||||
|
||||
## Scenario 1 — linking (User Story 1)
|
||||
|
||||
1. `PATCH /admin/agents/:agentId` with `{ "userId": "<agent's User.id>" }` as an admin.
|
||||
**Expected**: `200`, response's `userId` matches.
|
||||
2. Repeat with a `userId` belonging to a `User` whose role is `ADMIN`. **Expected**: `400`.
|
||||
3. Repeat step 1's `userId` against a *different* `agentId`. **Expected**: `409`.
|
||||
|
||||
## Scenario 2 — an agent lists their own tickets (User Story 2)
|
||||
|
||||
1. With two tickets currently assigned to the linked agent (via the existing orchestration
|
||||
assignment flow) and one assigned to a different agent, log in as that agent and call
|
||||
`GET /agents/me/tickets`. **Expected**: `200`, exactly the two tickets, each with `product`/
|
||||
`customer`/`priority`/`severity`/`status`/`assignedAt`/`sla` populated.
|
||||
2. Reassign one of those two tickets away (to a different agent or node). **Expected**: calling
|
||||
`GET /agents/me/tickets` again returns only the one remaining ticket.
|
||||
3. Log in as a `User` (role `AGENT`) with no linked `Agent` row and call the same endpoint.
|
||||
**Expected**: `404` with the specific "no agent profile linked" message, not `[]`.
|
||||
|
||||
## Scenario 3 — an admin lists a specific agent's tickets
|
||||
|
||||
1. Log in as admin; call `GET /admin/agents/:agentId/tickets` for the agent from Scenario 2.
|
||||
**Expected**: `200`, same ticket set and shape as that agent's own `GET /agents/me/tickets`
|
||||
call.
|
||||
2. Log in as a non-admin agent; call the same admin route for another agent's `agentId`.
|
||||
**Expected**: `403`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass, and Scenario 2 step 2 specifically confirms the list reflects live
|
||||
assignment state rather than a snapshot from when the agent first logged in.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Research: Agent Ticket Queue
|
||||
|
||||
## Decision: extend the existing `PATCH /admin/agents/:agentId`, don't add a new link endpoint
|
||||
|
||||
- **Decision**: Add an optional `userId: z.string().min(1).nullable().optional()` to
|
||||
`updateAgentSchema` and handle it in `AgentsService.update` (proactively check the target
|
||||
`User`'s role and any existing link before writing, same pre-check style as
|
||||
`UsersService.create`'s duplicate-email check — see 010-identity-auth), rather than a
|
||||
dedicated `PATCH /admin/agents/:agentId/link-user` route.
|
||||
- **Rationale**: `PATCH /admin/agents/:agentId` already exists as the one place an agent's
|
||||
mutable fields are updated (`name`, `teamId`, `active`) — `userId` is exactly that kind of
|
||||
field, not a distinct workflow. A second endpoint would duplicate routing/auth wiring for no
|
||||
behavioral gain.
|
||||
- **Alternatives considered**: A dedicated `/link-user` endpoint — rejected as an unnecessary
|
||||
extra surface once the existing update endpoint's shape was checked and found to already fit.
|
||||
|
||||
## Decision: proactive existence/role checks, not a caught unique-constraint error
|
||||
|
||||
- **Decision**: Before writing `userId`, look up the target `User` (404 if it doesn't exist,
|
||||
a clear rejection if its role isn't `AGENT`) and look up any existing `Agent` already linked
|
||||
to that `userId` (a clear `ConflictError` if one exists and isn't this same agent) — the same
|
||||
pattern `UsersService.create` (010-identity-auth) already established for its own duplicate-
|
||||
email check, rather than letting Postgres's `@unique` constraint on `Agent.userId` throw and
|
||||
translating that error after the fact.
|
||||
- **Rationale**: Consistency with the one precedent this codebase already has for "reject a
|
||||
would-be duplicate before writing," and a clearer error message than parsing a raw
|
||||
`PrismaClientKnownRequestError` code.
|
||||
- **Alternatives considered**: Catch `P2002` (unique constraint violation) and translate it —
|
||||
workable, but the proactive-check style already used by `UsersService.create` was preferred
|
||||
for consistency within the same codebase.
|
||||
|
||||
## Decision: the ticket-summary query lives in `ticketing/tickets`, not `orchestration/assignments`
|
||||
|
||||
- **Decision**: `TicketsService` (or a new `TicketsRepository` method) owns the new
|
||||
"tickets currently assigned to agent X" query, reading `Assignment` rows via
|
||||
`orchestration/assignments`'s own already-public repository/service surface (its `index.ts`),
|
||||
not by reaching into `orchestration`'s internals.
|
||||
- **Rationale**: The result is fundamentally a list of `Ticket`s (with a projection of
|
||||
product/customer/SLA data) — `ticketing/tickets` already owns `Ticket` and its existing
|
||||
`findById`/`findByCode` methods; `orchestration/assignments` owns the assignment *decision*
|
||||
and *history*, not ticket listing. This mirrors 009's own precedent of `problem-management`
|
||||
reading `ticketing`'s public surface rather than duplicating ticket state there.
|
||||
- **Alternatives considered**: A new cross-cutting `reporting`/`dashboard` module — rejected as
|
||||
premature; this is one query, not a new bounded concern (spec.md Assumptions explicitly rule
|
||||
out a general-purpose list/search endpoint).
|
||||
|
||||
## Decision: one new Prisma index, `Assignment @@index([agentId, isCurrent])`
|
||||
|
||||
- **Decision**: Add this composite index. The existing `@@index([ticketId, isCurrent])` supports
|
||||
"is this ticket currently assigned, and to whom" (007's own original query shape); this
|
||||
feature's query is the mirror image — "which tickets is this agent currently assigned to" —
|
||||
and has no supporting index today.
|
||||
- **Rationale**: Without it, "all current assignments for agent X" is a sequential scan over the
|
||||
whole `assignments` table. Cheap, purely additive schema change; no data migration needed
|
||||
beyond the index build itself.
|
||||
- **Alternatives considered**: Rely on the existing `[ticketId, isCurrent]` index (Postgres can't
|
||||
use a composite index efficiently for a query that doesn't lead with its first column) —
|
||||
rejected; a plain sequential scan is the actual alternative, not this index.
|
||||
|
||||
## Decision: two routes sharing one service method, not one route with an optional param
|
||||
|
||||
- **Decision**: `GET /agents/me/tickets` (`fastify.authenticate` only — resolves the agent from
|
||||
`request.user.id` via the new `Agent.userId` link) and `GET /admin/agents/:agentId/tickets`
|
||||
(`fastify.authenticate` + `requireRole('ADMIN')` — resolves the agent directly from the URL
|
||||
param) both call the same `TicketsService.listAssignedTo(agentId)`.
|
||||
- **Rationale**: FR-004 requires an agent's own call can never accept a client-supplied
|
||||
`agentId` — collapsing both into one route with an optional query param would make that
|
||||
invariant a runtime `if` instead of a routing-level guarantee. Two routes make "whose tickets"
|
||||
structurally unambiguous per caller type, matching 010's own precedent of `GET /auth/me` vs.
|
||||
an admin-only equivalent being distinct routes rather than one parameterized one.
|
||||
- **Alternatives considered**: `GET /tickets?assignedAgentId=<id or 'me'>` — rejected; makes
|
||||
FR-004's guarantee a body of validation logic rather than routing structure.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Feature Specification: Agent Ticket Queue
|
||||
|
||||
**Feature Branch**: `011-agent-ticket-queue`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Give agents and the frontend a way to list tickets currently
|
||||
assigned to a given agent, with enough summary detail (customer, product, priority, status, SLA
|
||||
state) to power an agent dashboard, since no such query exists anywhere in the ticketing or
|
||||
orchestration modules today."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
|
||||
|
||||
An admin connects an existing `User` account (role `AGENT`, from 010-identity-auth) to its
|
||||
corresponding `Agent` roster row (from 006-support-organization), so the platform knows which
|
||||
login belongs to which routing/skills profile.
|
||||
|
||||
**Why this priority**: Every other story here depends on resolving "this logged-in session" to
|
||||
"this agent's roster row." `Agent.userId` was added in 010-identity-auth specifically for this
|
||||
purpose but has never been set by any workflow — this is that missing workflow.
|
||||
|
||||
**Independent Test**: Create a `User` (role `AGENT`) and a separate `Agent` roster row; link
|
||||
them via the admin endpoint; confirm the link is retrievable and that linking a `User` already
|
||||
linked to a different `Agent` is rejected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an unlinked `Agent` and a `User` with role `AGENT` not yet linked to any agent,
|
||||
**When** an admin links them, **Then** the `Agent` row's `userId` is set and retrievable.
|
||||
2. **Given** a `User` already linked to `Agent` A, **When** an admin attempts to link that same
|
||||
`User` to `Agent` B, **Then** the request is rejected (the existing unique constraint on
|
||||
`Agent.userId` is surfaced as a clear conflict, not a raw database error).
|
||||
3. **Given** a `User` whose role is `ADMIN` rather than `AGENT`, **When** an admin attempts to
|
||||
link it to an `Agent` row, **Then** the request is rejected — an `Agent` roster row
|
||||
represents a working agent, not an admin-only account.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
|
||||
|
||||
An authenticated agent (or an admin looking at a specific agent, for support purposes) can
|
||||
retrieve a list of every ticket currently assigned to that agent, each with enough summary data
|
||||
— customer reference, product, priority, severity, status, and SLA state if a run exists — to
|
||||
power an agent dashboard without a further per-ticket fetch.
|
||||
|
||||
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
|
||||
agent-dashboard user story (its 001-agent-admin-ui, User Story 1) has no data source without it,
|
||||
and no other endpoint in the ticketing or orchestration modules answers this question today.
|
||||
|
||||
**Independent Test**: With two tickets currently assigned to an agent (via the existing
|
||||
orchestration assignment engine) and a third assigned to a different agent, call the new
|
||||
endpoint as the first agent; confirm exactly the first two are returned, each with the summary
|
||||
fields populated, and the third is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with two tickets currently assigned to them, **When** they call this
|
||||
endpoint, **Then** both are returned, each including customer reference, product, priority,
|
||||
severity, status, and SLA state (or an explicit absence of one, if no `SLARun` exists yet).
|
||||
2. **Given** an agent with zero currently-assigned tickets, **When** they call this endpoint,
|
||||
**Then** an empty list is returned — not an error.
|
||||
3. **Given** a ticket reassigned away from an agent (its `Assignment.isCurrent` flips to another
|
||||
agent's row), **When** the original agent calls this endpoint again, **Then** that ticket no
|
||||
longer appears.
|
||||
4. **Given** a `User` session with no linked `Agent` row at all (User Story 1 never completed
|
||||
for this account), **When** that session calls this endpoint, **Then** the response is a
|
||||
clear, specific rejection — never a silent empty list that could be mistaken for "no tickets
|
||||
assigned," and never a raw null-reference error.
|
||||
5. **Given** an admin session, **When** they call this endpoint for a specific `agentId`,
|
||||
**Then** the same summary list is returned for that agent — an admin's own use of the
|
||||
endpoint is explicit about which agent it's asking about, unlike an agent's own call, which
|
||||
is always implicitly about themselves.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if an agent has a ticket assigned whose `Problem`/`Product`/`CustomerReference`
|
||||
was deleted (should not happen under normal FK constraints, but the endpoint's own contract
|
||||
should be explicit): every relation this endpoint reads is a required, non-nullable foreign
|
||||
key already enforced by the schema, so this case cannot occur without a prior data-integrity
|
||||
violation elsewhere: not specifically handled here.
|
||||
- What happens if two `Agent` rows somehow both have `isCurrent: true` assignments for the same
|
||||
ticket (should be impossible under 007's own assignment invariant)? This endpoint trusts that
|
||||
invariant rather than re-deriving it — it is 007's own concern, not this feature's.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let an admin set an `Agent` row's linked `User` (`userId`), MUST
|
||||
reject linking a `User` already linked to a different `Agent`, and MUST reject linking a
|
||||
`User` whose role is not `AGENT`.
|
||||
- **FR-002**: The system MUST let an admin read which `User`, if any, an `Agent` row is linked
|
||||
to (already covered by the existing `GET /admin/agents/:agentId`, which returns the full
|
||||
`Agent` row — this FR only requires `userId` not be excluded from that response).
|
||||
- **FR-003**: The system MUST provide an endpoint that returns every ticket currently assigned
|
||||
(`Assignment.isCurrent: true`) to a given agent, each with customer reference, product,
|
||||
priority, severity, status, and SLA state summarized without a further per-ticket request.
|
||||
- **FR-004**: When called by an agent's own session, the endpoint MUST resolve "which agent" from
|
||||
that session's linked `Agent` row (User Story 1), never from a client-supplied agent ID — an
|
||||
agent can only ever list their own tickets this way.
|
||||
- **FR-005**: When called by an admin session with an explicit `agentId`, the endpoint MUST
|
||||
return that agent's tickets — an admin-only capability for support/oversight purposes.
|
||||
- **FR-006**: The system MUST reject a call from a session with no linked `Agent` row with a
|
||||
specific, distinguishable error — never an empty list.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Agent-User Link**: The (now finally wired) association between a `User` account and the
|
||||
`Agent` roster row it authenticates as, via `Agent.userId`.
|
||||
- **Assigned Ticket Summary**: A read-only projection of a `Ticket` plus its current
|
||||
`Assignment` and (if present) `SLARun`, shaped for list display rather than full detail.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An agent's currently-assigned tickets are retrievable in a single request, with
|
||||
zero additional per-ticket requests needed to populate a dashboard-style summary list.
|
||||
- **SC-002**: 100% of sessions with no linked `Agent` row receive a specific rejection from the
|
||||
new endpoint, never an empty list indistinguishable from "genuinely zero tickets assigned."
|
||||
- **SC-003**: 0% of one agent's currently-assigned tickets are visible to another agent calling
|
||||
the endpoint as themselves.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **This feature does not add a general-purpose ticket search/filter/list endpoint** — only the
|
||||
narrow "tickets currently assigned to a specific agent" query supporthub-web's agent dashboard
|
||||
needs. A broader admin-facing ticket search is explicitly out of scope, deferred until a
|
||||
concrete need names its own filters.
|
||||
- **Linking (User Story 1) is a one-time admin action per agent, not a self-service flow** — an
|
||||
agent does not link their own account; matches 006/010's own existing pattern of admin-managed
|
||||
roster and account provisioning.
|
||||
- **No pagination is included** — an individual agent's currently-assigned ticket count is
|
||||
small enough (bounded by realistic per-agent workload) that a single unpaginated list is
|
||||
sufficient for this feature's scope; revisit if a future feature's data suggests otherwise.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
description: "Task list for 011-agent-ticket-queue"
|
||||
---
|
||||
|
||||
# Tasks: Agent Ticket Queue
|
||||
|
||||
**Input**: Design documents from `specs/011-agent-ticket-queue/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/agent-ticket-queue-contract.md](./contracts/agent-ticket-queue-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 linking, US2 = P1 ticket listing).
|
||||
US2 depends on a helper US1 also needs, so despite being nominally independent, build US1 first.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `Assignment @@index([agentId, isCurrent])` to `prisma/schema.prisma`; generate
|
||||
the migration (`prisma migrate diff` → hand-write `migration.sql` → `prisma migrate
|
||||
deploy`, this session's established non-interactive workaround) and run
|
||||
`npm run prisma:generate`
|
||||
|
||||
**Checkpoint**: Index in place. Both user stories can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
|
||||
|
||||
**Goal**: `Agent.userId` becomes settable through the existing update endpoint, with the
|
||||
rejection rules FR-001 requires.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T002 [P] [US1] Integration test covering Quickstart Scenario 1 (link succeeds; non-AGENT
|
||||
role rejected 400; already-linked-elsewhere rejected 409) in
|
||||
`tests/integration/agent-ticket-queue.test.ts` (depends on T001)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T003 [US1] Add `AgentsRepository.findByUserId(userId)` in
|
||||
`src/modules/identity/agents/repository/agents.repository.ts` — shared by this story's
|
||||
own duplicate-link check and by User Story 2's agent-self route (T010)
|
||||
- [x] T004 [US1] Add `userId: z.string().min(1).nullable().optional()` to `updateAgentSchema` in
|
||||
`src/modules/identity/agents/schema/agents.schema.ts`
|
||||
- [x] T005 [US1] In `AgentsService.update` (`src/modules/identity/agents/service/agents.service.ts`),
|
||||
when `data.userId !== undefined`: if non-null, look up the target `User` (via a small
|
||||
`UsersRepository.findById`) — 404 if missing, reject with a `ValidationError` if its role
|
||||
isn't `AGENT`; look up any `Agent` already linked to that `userId` (T003's
|
||||
`findByUserId`) — `ConflictError` if it's a different agent than `agentId` (depends on
|
||||
T003, T004)
|
||||
- [x] T006 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: An agent's login can now be resolved to its roster row.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
|
||||
|
||||
**Goal**: Both list endpoints return the same summarized projection, correctly scoped per
|
||||
caller.
|
||||
|
||||
**Independent Test**: Quickstart Scenarios 2-3.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T007 [P] [US2] Unit test: given a `User` id with no linked `Agent`, the service throws the
|
||||
specific `NotFoundError` — in `tests/unit/identity/agent-ticket-queue-guard.test.ts`
|
||||
- [x] T008 [US2] Integration test covering Quickstart Scenarios 2-3 (agent sees exactly their
|
||||
own current assignments; list updates after a reassignment; no-linked-agent session gets
|
||||
404 not `[]`; admin route returns the same shape for an explicit `agentId`; non-admin
|
||||
calling the admin route for another agent gets 403) in
|
||||
`tests/integration/agent-ticket-queue.test.ts` (depends on T006)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T009 [US2] Add `TicketsRepository.findAssignedToAgent(agentId)` in
|
||||
`src/modules/ticketing/tickets/repository/tickets.repository.ts` — one query joining
|
||||
current `Assignment` (via orchestration's public repository/service surface) to `Ticket`
|
||||
with `product`/`customer`/`sLARun` relations (depends on T001)
|
||||
- [x] T010 [US2] Add `TicketsService.listAssignedTo(agentId)` mapping each row to the
|
||||
`AssignedTicketSummary` shape (data-model.md) in
|
||||
`src/modules/ticketing/tickets/service/tickets.service.ts` (depends on T009)
|
||||
- [x] T011 [US2] Add `GET /agents/me/tickets` (`fastify.authenticate` only; resolves `agentId`
|
||||
via `agentsService`'s `findByUserId` (T003) against `request.user.id`, throwing the
|
||||
FR-006 `NotFoundError` if none) and `GET /admin/agents/:agentId/tickets`
|
||||
(`fastify.authenticate` + `requireRole('ADMIN')`) in `src/modules/ticketing/tickets/
|
||||
controller/` + `routes/`, registered from `src/api/routes.ts` (depends on T003, T010)
|
||||
- [x] T012 [US2] Run Quickstart Scenarios 2-3 locally and confirm all steps pass
|
||||
|
||||
**Checkpoint**: supporthub-web's agent dashboard now has a real data source.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T013 [P] Update `specs/011-agent-ticket-queue/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T014 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T015 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS both user stories
|
||||
- **User Story 1 (Phase 2)**: Depends on Foundational
|
||||
- **User Story 2 (Phase 3)**: Depends on Foundational and on T003 (built in Phase 2) — build
|
||||
Phase 2 before Phase 3 despite the two stories being otherwise independent
|
||||
- **Polish (Phase 4)**: Depends on both user stories
|
||||
@@ -0,0 +1,60 @@
|
||||
# Specification Quality Checklist: Admin List Views
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**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
|
||||
|
||||
- Discovered the same way 011-agent-ticket-queue was: while building supporthub-web's
|
||||
001-agent-admin-ui (User Stories 6 and 7 this time), a research pass over supporthub-api's
|
||||
actual endpoints found no cross-ticket SLA-run or escalation-event listing at all, and no
|
||||
products-with-integration-status endpoint — three separate but same-shaped gaps (an existing
|
||||
domain's data, never exposed as a list/join query), bundled into one feature rather than three
|
||||
separate ones since none is large enough to justify its own spec.
|
||||
- Deliberately narrow: read-only, no new persisted entity, no general search/filter API beyond
|
||||
the one filter (`status`) and one cap (`limit`) each list actually needs, per Assumptions.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: research.md's plan.md draft had described the existing
|
||||
single-ticket `GET /tickets/:ticketId/sla-run` as "agent-facing (fastify.authenticate)" — it's
|
||||
actually fully ungated (no preHandler at all). Didn't change this feature's own design
|
||||
(`GET /admin/sla-runs`/`GET /admin/escalation-events` still use `fastify.authenticate`, a
|
||||
deliberately more conservative choice than the existing route, matching spec.md's own
|
||||
"agent-usable" wording), but worth correcting for anyone reading research.md later.
|
||||
- No `SLA_RUN_STATUSES` constant existed anywhere before this feature — `SLARun.status` had
|
||||
only ever been written as free strings across the pause/resume/breach-detection code paths.
|
||||
Centralized it in `orchestration/sla/mapper/sla-run-status.ts` since this feature is the first
|
||||
caller that needs to validate against it, not just write it.
|
||||
- **Follow-up (post-implementation)**: while building supporthub-web's own knowledge-governance
|
||||
screen against this feature's own spirit, found a fourth same-shaped gap this spec's own scope
|
||||
didn't originally name: `GET /knowledge/retrieve` (004-product-knowledge) only ever returns
|
||||
`status: 'published'` entries — a governance screen that needs to see and publish a *draft*
|
||||
entry had no endpoint to list it at all. Added `GET /admin/products/:externalProductId/
|
||||
knowledge` directly to the knowledge module (not this feature's own routes, since it lives
|
||||
where `KnowledgeEntry` itself does) in a small follow-up commit, same spirit as this spec's
|
||||
three original endpoints.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contract: Admin List Views
|
||||
|
||||
## `GET /admin/sla-runs`
|
||||
|
||||
**Auth**: `fastify.authenticate` only (agent-usable, per spec.md Assumptions).
|
||||
|
||||
**Query**: `status?: 'running' | 'paused' | 'warning' | 'breached' | 'completed'`
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"status": "string",
|
||||
"firstResponseDueAt": "ISO 8601 datetime | null",
|
||||
"resolutionDueAt": "ISO 8601 datetime | null",
|
||||
"breachedAt": "ISO 8601 datetime | null",
|
||||
"firstResponseBreachedAt": "ISO 8601 datetime | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response `400`**: an invalid `status` value.
|
||||
|
||||
## `GET /admin/escalation-events`
|
||||
|
||||
**Auth**: `fastify.authenticate` only.
|
||||
|
||||
**Query**: `limit?: number` (1-200, default 50)
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"reason": "string",
|
||||
"ruleId": "string | null",
|
||||
"triggeredBy": "string",
|
||||
"toNodeId": "string | null",
|
||||
"createdAt": "ISO 8601 datetime"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
Ordered most-recent-first (`createdAt desc`).
|
||||
|
||||
## `GET /admin/products`
|
||||
|
||||
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "string",
|
||||
"externalProductId": "string",
|
||||
"name": "string",
|
||||
"status": "string",
|
||||
"supportEnabled": true,
|
||||
"integrationStatus": "active | suspended | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
`integrationStatus` is `null` when the product has no `ProductIntegration` at all — never
|
||||
defaulted to `"active"` or any other value that could be mistaken for a real integration state.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Data Model: Admin List Views
|
||||
|
||||
No schema changes. Three response-shape projections over existing models.
|
||||
|
||||
## `SlaRunListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `SLARun.ticketId` |
|
||||
| `ticketCode` | `SLARun.ticket.code` (via `include`) |
|
||||
| `status` | `SLARun.status` |
|
||||
| `firstResponseDueAt` | `SLARun.firstResponseDueAt` |
|
||||
| `resolutionDueAt` | `SLARun.resolutionDueAt` |
|
||||
| `breachedAt` | `SLARun.breachedAt` |
|
||||
| `firstResponseBreachedAt` | `SLARun.firstResponseBreachedAt` |
|
||||
|
||||
## `EscalationEventListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `EscalationEvent.ticketId` |
|
||||
| `ticketCode` | `EscalationEvent.ticket.code` (via `include`) |
|
||||
| `reason` | `EscalationEvent.reason` |
|
||||
| `ruleId` | `EscalationEvent.ruleId` (null for manual/no-match) |
|
||||
| `triggeredBy` | `EscalationEvent.triggeredBy` |
|
||||
| `toNodeId` | `EscalationEvent.toNodeId` |
|
||||
| `createdAt` | `EscalationEvent.createdAt` |
|
||||
|
||||
## `ProductCatalogListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `id` | `Product.id` |
|
||||
| `externalProductId` | `Product.externalProductId` |
|
||||
| `name` | `Product.name` |
|
||||
| `status` | `Product.status` |
|
||||
| `supportEnabled` | `Product.supportEnabled` |
|
||||
| `integrationStatus` | Derived: `product.integration?.status ?? null` — never the full `ProductIntegration` row (research.md) |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- `GET /admin/sla-runs?status=` — `status` validated against `SLA_RUN_STATUSES` (`running`,
|
||||
`paused`, `warning`, `breached`, `completed`); omitted means unfiltered.
|
||||
- `GET /admin/escalation-events?limit=` — `limit` coerced, `1..200`, default `50`; ordered by
|
||||
`createdAt desc`.
|
||||
- `GET /admin/products` — no filter; ordered by `name asc` (matches existing catalog list
|
||||
conventions elsewhere in this codebase, e.g. `TeamsRepository.findAll`).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Implementation Plan: Admin List Views
|
||||
|
||||
**Branch**: `012-admin-list-views` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/012-admin-list-views/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adds three read-only endpoints, each a straightforward `findMany` on an already-existing model
|
||||
plus a small ticket-id/code projection: `GET /admin/sla-runs` (optional `?status=`),
|
||||
`GET /admin/escalation-events` (optional `?limit=`), and `GET /admin/products` (products joined
|
||||
to their integration's status). No new persisted entity, no write capability.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — Prisma only.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma. No schema change — every field already exists; these are
|
||||
projections over `SLARun`, `EscalationEvent`, and `Product`/`ProductIntegration`.
|
||||
|
||||
**Testing**: Vitest — integration tests against real Postgres/Redis for each endpoint's filter/
|
||||
ordering/projection behavior, plus one admin-role-gating check for `GET /admin/products`.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `orchestration/sla` (new route +
|
||||
repository method), `orchestration/escalation` (new route + repository method), and
|
||||
`catalog/products` (new admin route + repository method) — no new module, each list lives in
|
||||
the module that already owns its underlying model.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Each list is one indexed/simple query — `SLARun` has no per-status
|
||||
index today (status is a small string column, not indexed), acceptable at this stage per
|
||||
spec.md's own "no general search API" scoping; revisit if a future feature's data volume
|
||||
demands one.
|
||||
|
||||
**Constraints**: FR-004 — read-only, no new write path. The product-catalog list must not leak
|
||||
`ProductIntegration.credentialRef` (encrypted secret) or any other sensitive integration field
|
||||
— only `status` is projected.
|
||||
|
||||
**Scale/Scope**: Three new GET routes across three existing modules, three new repository
|
||||
methods, no new module, no schema migration. Explicitly excludes: pagination (spec.md
|
||||
Assumptions — `limit` only on the escalation-event list), and any filter beyond `status`/`limit`.
|
||||
|
||||
## 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 | Purely internal SupportHub domain (SLA/escalation/product-catalog monitoring) — no SaaS/customer identity involved. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | No new configurable values. | PASS — N/A |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | Each list lives in the module that already owns its model (`orchestration/sla`, `orchestration/escalation`, `catalog/products`) — no cross-module reach-through; the ticket id/code projection reads `ticketsRepository`'s own public surface via `ticketing/tickets`'s existing `index.ts`. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Not applicable — no new mutable state. | PASS — N/A |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries; no concurrency concern. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/012-admin-list-views/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
└── src/
|
||||
└── modules/
|
||||
├── orchestration/
|
||||
│ ├── sla/ # MODIFIED — GET /admin/sla-runs
|
||||
│ │ ├── controller/ routes/
|
||||
│ │ └── repository/ (new findAll(status?) method)
|
||||
│ └── escalation/ # MODIFIED — GET /admin/escalation-events
|
||||
│ ├── controller/ routes/
|
||||
│ └── repository/ (new findRecent(limit?) method)
|
||||
└── catalog/
|
||||
└── products/ # MODIFIED — GET /admin/products
|
||||
├── controller/ routes/
|
||||
└── repository/ (new findAllWithIntegrationStatus() method)
|
||||
└── tests/
|
||||
└── integration/ # one new test file per endpoint's own scenarios
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module — each endpoint extends the module that
|
||||
already owns its underlying data, matching 011-agent-ticket-queue's own precedent.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Quickstart: Validating Admin List Views
|
||||
|
||||
## Scenario 1 — SLA runs across tickets
|
||||
|
||||
1. With SLA runs in `running`, `paused`, and `breached` states across three tickets, call
|
||||
`GET /admin/sla-runs` as any authenticated agent. **Expected**: `200`, all three, each with
|
||||
`ticketId`/`ticketCode` populated.
|
||||
2. Repeat with `?status=breached`. **Expected**: only the breached run.
|
||||
3. Repeat with `?status=not-a-real-status`. **Expected**: `400`.
|
||||
|
||||
## Scenario 2 — recent escalation events across tickets
|
||||
|
||||
1. With one automatic and one manual escalation event recorded on two different tickets, call
|
||||
`GET /admin/escalation-events`. **Expected**: `200`, both, most-recent-first, the automatic
|
||||
one showing its `ruleId` and the manual one showing `ruleId: null` and its `triggeredBy`.
|
||||
|
||||
## Scenario 3 — product catalog with integration status
|
||||
|
||||
1. With one product that has an active integration and one with no integration at all, call
|
||||
`GET /admin/products` as an admin. **Expected**: `200`, the first shows
|
||||
`integrationStatus: "active"`, the second shows `integrationStatus: null`.
|
||||
2. Repeat as a non-admin agent. **Expected**: `403`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass against a real Postgres/Redis, and none of the three endpoints leaks
|
||||
`ProductIntegration.credentialRef` or any other integration-internal field.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Research: Admin List Views
|
||||
|
||||
## Decision: project ticket id/code via a second query, not a raw join
|
||||
|
||||
- **Decision**: Each repository method fetches its own rows (`SLARun[]`/`EscalationEvent[]`)
|
||||
with Prisma's own `include: { ticket: { select: { id: true, code: true } } }` — a single
|
||||
Prisma query using the existing `ticket` relation already on both models, not a hand-written
|
||||
SQL join or a second round-trip.
|
||||
- **Rationale**: Both `SLARun` and `EscalationEvent` already have a `ticket` relation
|
||||
(`@relation(fields: [ticketId], references: [id])`) — Prisma's `include` turns this into one
|
||||
query, not N+1, and needs no new repository dependency on `ticketsRepository`.
|
||||
- **Alternatives considered**: A second batched `ticketsRepository.findByIds(...)` call — works,
|
||||
but `include` is simpler and already idiomatic in this codebase's own repositories (e.g.
|
||||
011-agent-ticket-queue's `findAssignedToAgent`).
|
||||
|
||||
## Decision: `status` filter on `GET /admin/sla-runs` is validated against `SLA_RUN_STATUSES`
|
||||
|
||||
- **Decision**: `status` is an optional query param validated with
|
||||
`z.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional()` — the same
|
||||
status vocabulary `SLARun.status` already uses (008-sla-escalation).
|
||||
- **Rationale**: A typo'd status silently returning zero rows (if left as a free string) would
|
||||
be a confusing, silent failure mode for a monitoring view; validating it up front makes an
|
||||
invalid filter a clear `400`, matching this codebase's existing "resolve/validate first, then
|
||||
act" convention (e.g. 011's proactive existence checks).
|
||||
- **Alternatives considered**: A free-text `z.string().optional()` — rejected for the silent-
|
||||
wrong-filter risk above.
|
||||
|
||||
## Decision: `GET /admin/escalation-events` defaults to `limit=50`, capped at `200`
|
||||
|
||||
- **Decision**: `limit` is `z.coerce.number().int().positive().max(200).default(50)`.
|
||||
- **Rationale**: Unlike `SLARun` (bounded by currently-open tickets) or `Product` (bounded by
|
||||
catalog size), `EscalationEvent` rows only ever accumulate — an unbounded list would grow
|
||||
without limit. A sane default plus a hard ceiling avoids both an accidentally-enormous
|
||||
response and a caller needing to know to always pass one.
|
||||
- **Alternatives considered**: True cursor-based pagination — rejected as more than this
|
||||
feature's own scope calls for (spec.md Assumptions); a capped `limit` is enough for a
|
||||
"recent escalations" monitoring view.
|
||||
|
||||
## Decision: product-catalog integration status is a derived string, not the raw `ProductIntegration` row
|
||||
|
||||
- **Decision**: `GET /admin/products` returns `integrationStatus: 'active' | 'suspended' | null`
|
||||
(`null` when `product.integration` is absent) — never the full `ProductIntegration` object.
|
||||
- **Rationale**: `ProductIntegration.credentialRef` is an encrypted secret at rest
|
||||
(002-saas-integration); even encrypted, there's no reason for a list-view response to include
|
||||
it, or any other integration-internal field (`rateLimitPerMinute`, `allowedScope`, etc.) this
|
||||
screen doesn't render (FR-003's own "constraints" — plan.md).
|
||||
- **Alternatives considered**: Nesting the full `include: { integration: true }` result under
|
||||
the product — rejected; a derived, minimal field is both simpler for the frontend and doesn't
|
||||
require re-auditing every future `ProductIntegration` field addition for accidental exposure
|
||||
through a public-adjacent list view (this route is admin-only, but the same discipline this
|
||||
codebase already applies to `AssignedTicketSummary`'s own minimal projection applies here too).
|
||||
|
||||
## Decision: `GET /admin/products` is a new admin route, not an extension of the existing public `GET /products`
|
||||
|
||||
- **Decision**: A separate route rather than adding an optional `includeIntegrationStatus` query
|
||||
param to the existing public, ungated `GET /products`.
|
||||
- **Rationale**: `GET /products` is intentionally public (spec.md Assumptions of
|
||||
002-saas-integration's own catalog read); layering an admin-only field onto a public route
|
||||
via a query flag would make that route's own auth requirement conditional on which fields
|
||||
were requested — a confusing, easy-to-get-wrong pattern. A separate `requireRole('ADMIN')`
|
||||
route keeps the gate unconditional and obvious.
|
||||
- **Alternatives considered**: The query-flag approach above — rejected for the reason stated.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Feature Specification: Admin List Views
|
||||
|
||||
**Feature Branch**: `012-admin-list-views`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Add missing read-only list endpoints supporthub-web's admin
|
||||
monitoring and catalog screens need: SLA runs across tickets, recent escalation events across
|
||||
tickets, and products with their integration status, none of which exist as a single query
|
||||
today."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent or admin sees SLA status across every ticket at a glance (Priority: P1)
|
||||
|
||||
Rather than checking one ticket's SLA state at a time, an agent or admin retrieves a list of
|
||||
every ticket's current SLA run, filterable by status (running/paused/warning/breached), each
|
||||
entry carrying enough to identify and link to its ticket.
|
||||
|
||||
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
|
||||
001-agent-admin-ui, User Story 6, has no data source for its SLA monitor view without it, and
|
||||
no endpoint in the SLA module answers "every ticket's SLA state," only one ticket's own.
|
||||
|
||||
**Independent Test**: With SLA runs in different states across several tickets, call this
|
||||
endpoint unfiltered and confirm every run appears; call it filtered by `status=breached` and
|
||||
confirm only breached runs appear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets with SLA runs in running, paused, and breached states, **When** the
|
||||
endpoint is called with no filter, **Then** every run is returned, each including its
|
||||
ticket's id and code, status, and due/breached timestamps.
|
||||
2. **Given** the same tickets, **When** the endpoint is called with `status=breached`, **Then**
|
||||
only the breached runs are returned.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent or admin sees recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
An agent or admin retrieves a list of recent escalation events across all tickets — each
|
||||
showing the triggering reason, the rule that fired it (if automatic) or the actor who triggered
|
||||
it (if manual), and the resulting target hierarchy node.
|
||||
|
||||
**Why this priority**: The same 001-agent-admin-ui User Story 6 has no data source for its
|
||||
escalation matrix view without it — today the only way to see an escalation event at all is
|
||||
`EscalationEventRepository.findAllForTicket`, which requires already knowing which ticket to
|
||||
ask about.
|
||||
|
||||
**Independent Test**: With escalation events (both automatic and manual) recorded across
|
||||
several tickets, call this endpoint and confirm every event appears, most recent first, each
|
||||
identifying its ticket, reason, rule-or-actor, and target node.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** three tickets each with one escalation event, **When** the endpoint is called,
|
||||
**Then** all three appear, ordered most-recent-first, each including its ticket id/code,
|
||||
reason, `ruleId` (or null for manual), `triggeredBy`, and `toNodeId`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An admin views the product catalog with integration status (Priority: P2)
|
||||
|
||||
An admin retrieves the product catalog with each product's integration status
|
||||
(active/suspended) visible directly in the list, rather than needing a second lookup per
|
||||
product.
|
||||
|
||||
**Why this priority**: Lower than User Stories 1-2 (matches 001-agent-admin-ui's own User Story
|
||||
7 being P3) — the product catalog changes far less often than SLA/escalation state, but its own
|
||||
consuming frontend story still has no single query to build a list screen against: the existing
|
||||
public `GET /products` doesn't include `ProductIntegration`, and integration status is only
|
||||
otherwise reachable per-integration-id, not per-product.
|
||||
|
||||
**Independent Test**: With two products, one with an active integration and one with a
|
||||
suspended integration, call this endpoint and confirm each product's own integration status is
|
||||
present without a further request.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a product with an active integration and one with a suspended integration, **When**
|
||||
an admin calls this endpoint, **Then** both appear with their correct integration status;
|
||||
a product with no integration at all shows a clearly-absent (not misleadingly "active")
|
||||
status.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens to a ticket whose SLA run was already marked `completed` (ticket resolved)? It
|
||||
still appears in the unfiltered SLA-run list (this is a monitoring view of everything that
|
||||
exists, not just "currently at risk") but is excluded by a `status=breached`/`running`/etc.
|
||||
filter unless it matches.
|
||||
- What happens for a ticket with no SLA run at all (no matching policy, or the run hasn't been
|
||||
created yet)? It simply doesn't appear in this list — this endpoint lists existing `SLARun`
|
||||
rows, it does not synthesize one for every ticket.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an endpoint listing every `SLARun`, each including its
|
||||
owning ticket's id and code, optionally filtered by `status`.
|
||||
- **FR-002**: The system MUST provide an endpoint listing recent `EscalationEvent` rows across
|
||||
all tickets, most-recent-first, each including its owning ticket's id and code.
|
||||
- **FR-003**: The system MUST provide an endpoint listing the product catalog with each
|
||||
product's integration status included, distinguishing "has an active integration," "has a
|
||||
suspended integration," and "has no integration at all."
|
||||
- **FR-004**: All three endpoints are read-only (no new write capability) and reuse existing
|
||||
`SLARun`/`EscalationEvent`/`Product`/`ProductIntegration` data — no new persisted entity.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **SLA Run List Item**: An `SLARun` projected with its ticket's `id`/`code` alongside its own
|
||||
existing fields.
|
||||
- **Escalation Event List Item**: An `EscalationEvent` projected with its ticket's `id`/`code`
|
||||
alongside its own existing fields.
|
||||
- **Product Catalog List Item**: A `Product` projected with its integration's `status`, or an
|
||||
explicit absence marker if it has none.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every ticket's SLA state is retrievable in a single request, filterable by status,
|
||||
with zero additional per-ticket requests needed.
|
||||
- **SC-002**: Recent escalation events across every ticket are retrievable in a single request.
|
||||
- **SC-003**: The product catalog with integration status is retrievable in a single request,
|
||||
with 0% of products showing a misleading status when they have no integration at all.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **No pagination on the SLA-run or product-catalog lists** — matches 011-agent-ticket-queue's
|
||||
own precedent (bounded, realistic data volumes for this stage); the escalation-event list
|
||||
DOES cap at a default/maximum `limit` (most-recent-first), since that list only ever grows
|
||||
and has no other natural bound.
|
||||
- **These are read-only monitoring/catalog views, not a general search/filter API** — the SLA
|
||||
list's only filter is `status`; no additional filters (date range, product, priority) are
|
||||
added speculatively beyond what 001-agent-admin-ui's own User Story 6 spec asks for.
|
||||
- **Auth**: SLA-run and escalation-event lists are agent-usable (`fastify.authenticate` only,
|
||||
matching the existing single-ticket `GET /tickets/:id/sla-run`'s own agent-facing nature and
|
||||
001-agent-admin-ui's "agents and admins" wording for User Story 6); the product-catalog list
|
||||
is admin-only (`requireRole('ADMIN')`), matching every other admin-configuration read in this
|
||||
codebase.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
description: "Task list for 012-admin-list-views"
|
||||
---
|
||||
|
||||
# Tasks: Admin List Views
|
||||
|
||||
**Input**: Design documents from `specs/012-admin-list-views/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/admin-list-views-contract.md](./contracts/admin-list-views-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 SLA runs, US2 = P1 escalation
|
||||
events, US3 = P2 product catalog). All three are independent of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: User Story 1 - SLA runs across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
- [x] T001 [P] [US1] Add `SLARunRepository.findAll(status?)` in
|
||||
`src/modules/orchestration/sla/repository/sla-run.repository.ts` — `include: { ticket:
|
||||
{ select: { id: true, code: true } } }`, optional `where: { status }`
|
||||
- [x] T002 [US1] Add `SLAService.listAll(status?)` (or directly on the controller if no service
|
||||
method is warranted — check existing pattern) validating `status` against
|
||||
`SLA_RUN_STATUSES` (400 on an invalid value) in
|
||||
`src/modules/orchestration/sla/service/sla.service.ts` (depends on T001)
|
||||
- [x] T003 [US1] Add `GET /admin/sla-runs` (`fastify.authenticate` only) in
|
||||
`src/modules/orchestration/sla/controller/` + `routes/`, projecting each row to
|
||||
`SlaRunListItem` (data-model.md) (depends on T002)
|
||||
- [x] T004 [US1] Integration test covering Quickstart Scenario 1 (unfiltered returns all;
|
||||
`status=breached` filters correctly; an invalid status is 400) in
|
||||
`tests/integration/admin-list-views.test.ts`
|
||||
- [x] T005 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 2 - Recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
- [x] T006 [P] [US2] Add `EscalationEventRepository.findRecent(limit)` in
|
||||
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` —
|
||||
`include: { ticket: { select: { id: true, code: true } } }`, `orderBy: { createdAt:
|
||||
'desc' }`, `take: limit`
|
||||
- [x] T007 [US2] Add `GET /admin/escalation-events` (`fastify.authenticate` only, `limit` query
|
||||
param `z.coerce.number().int().positive().max(200).default(50)`) in
|
||||
`src/modules/orchestration/escalation/controller/` + `routes/`, projecting to
|
||||
`EscalationEventListItem` (depends on T006)
|
||||
- [x] T008 [US2] Integration test covering Quickstart Scenario 2 (both events appear, most-
|
||||
recent-first, automatic vs manual distinguished by `ruleId`) in
|
||||
`tests/integration/admin-list-views.test.ts` (same file as T004)
|
||||
- [x] T009 [US2] Run Quickstart Scenario 2 locally and confirm it passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 - Product catalog with integration status (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
- [x] T010 [P] [US3] Add `ProductsRepository.findAllWithIntegrationStatus()` in
|
||||
`src/modules/catalog/products/repository/products.repository.ts` — `include: {
|
||||
integration: { select: { status: true } } }`, `orderBy: { name: 'asc' }`
|
||||
- [x] T011 [US3] Add `GET /admin/products` (`fastify.authenticate` + `requireRole('ADMIN')`) in
|
||||
`src/modules/catalog/products/controller/` + `routes/`, projecting each row to
|
||||
`ProductCatalogListItem` (`integrationStatus: product.integration?.status ?? null` —
|
||||
never the full `ProductIntegration` row, research.md) (depends on T010)
|
||||
- [x] T012 [US3] Integration test covering Quickstart Scenario 3 (active + no-integration
|
||||
products both correct; non-admin gets 403) in `tests/integration/admin-list-views.test.ts`
|
||||
(same file as T004/T008)
|
||||
- [x] T013 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T014 [P] Update `specs/012-admin-list-views/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T015 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T016 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **User Stories 1-3**: Fully independent of each other and of any Foundational phase (no shared
|
||||
prerequisite beyond the existing schema) — parallelizable in any order
|
||||
- **Polish (Phase 4)**: Depends on all three user stories
|
||||
@@ -0,0 +1,67 @@
|
||||
# Specification Quality Checklist: Authentication Hardening
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**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
|
||||
|
||||
- This is `docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), first
|
||||
slice, per explicit user direction — the two concrete gaps 010-identity-auth's own Assumptions
|
||||
named as deliberately out of its scope: password-reset and login rate-limiting. MFA, the third
|
||||
item 010 named, is intentionally excluded here as its own larger follow-up.
|
||||
- Password-reset's email-delivery step is explicitly stubbed (server-side log, not a real send)
|
||||
per explicit user decision — this codebase has no email-sending infrastructure at all today
|
||||
(no library, no configured provider), discovered while scoping this feature, and introducing
|
||||
one is a separate decision the user chose to defer rather than bundle into this pass.
|
||||
- Password-strength policy (User Story 2) was added beyond the two named gaps because it's a
|
||||
direct, unavoidable dependency of User Story 1 — a password-reset flow that accepts any
|
||||
password would be hardening one gap while leaving the other wide open at the same door.
|
||||
- All items pass; no revision iterations were needed.
|
||||
|
||||
## Implementation Notes (post-build)
|
||||
|
||||
- `tests/helpers/auth.ts`'s shared `loginAs()` helper previously reused two fixed accounts
|
||||
(`test-admin@supporthub.test` / `test-agent@supporthub.test`) across every integration test
|
||||
file via `upsert`. Once login became rate-limited per email (User Story 3), the ~30 files that
|
||||
each call it once in their own `beforeAll` collectively exceeded the attempt budget for those
|
||||
two shared addresses well before most files' own tests ran, turning their legitimate logins
|
||||
into `429`s. Fixed by giving each `loginAs()` call its own unique, randomly-suffixed email —
|
||||
nothing in the suite depended on the literal fixed addresses, so no call sites needed to
|
||||
change, only the helper itself.
|
||||
- While re-running the full suite for regression, `tests/integration/orchestration-strategies.test.ts`'s
|
||||
"SKILL_BASED prefers the eligible agent with the higher proficiency level" test was found
|
||||
failing (picks the lower-proficiency agent). Verified via `git stash` that this reproduces
|
||||
identically on the clean pre-013 `HEAD` with none of this feature's changes present — it is a
|
||||
pre-existing bug in 007-orchestration-assignment's `SKILL_BASED` strategy, unrelated to and out
|
||||
of scope for this feature. Left unfixed here; worth its own follow-up.
|
||||
- `tests/integration/ticket-attachments.test.ts`'s 2 known MinIO-dependent failures (accepted
|
||||
baseline, this project doesn't run MinIO) remain unchanged by this feature.
|
||||
- All other integration and unit tests pass, including 010-identity-auth's own login/admin-account
|
||||
tests, confirming no regression from `AuthService.login`'s new rate-limit check or the shared
|
||||
`validatePasswordStrength` call added to `UsersService.create`.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contract: Authentication Hardening
|
||||
|
||||
## `POST /auth/password-reset/request`
|
||||
|
||||
**Auth**: None (like login itself — the caller has no session yet).
|
||||
|
||||
**Request body**: `{ "email": "string" }`
|
||||
|
||||
**Response `200`** (always, regardless of whether the account exists):
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { "message": "If that account exists, a reset link has been sent." }, "meta": null }
|
||||
```
|
||||
|
||||
No token, ever, appears in this response — it's only visible via the stub's own server-side log
|
||||
line (`{ "event": "password_reset_requested", "userId": "...", "resetUrl": "..." }`).
|
||||
|
||||
## `POST /auth/password-reset/consume`
|
||||
|
||||
**Auth**: None (the token itself is the credential).
|
||||
|
||||
**Request body**: `{ "token": "string", "newPassword": "string" }`
|
||||
|
||||
**Responses**:
|
||||
- `200` — `{ "success": true, "data": { "message": "Password updated." }, "meta": null }`
|
||||
- `400 VALIDATION_ERROR` — `newPassword` doesn't meet `validatePasswordStrength`.
|
||||
- `400 INVALID_RESET_TOKEN` (or equivalent) — token missing, expired, or already used. The
|
||||
response never distinguishes which of the three — matching data-model.md's own note that a
|
||||
consumer can't otherwise tell "expired" from "already used" from "never existed."
|
||||
|
||||
## `PATCH /admin/users` — unchanged route, tightened validation
|
||||
|
||||
`POST /admin/users` (010-identity-auth) now also rejects a `password` shorter than
|
||||
`PASSWORD_MIN_LENGTH` with the same `validatePasswordStrength` message the reset-consume
|
||||
endpoint uses — no new route, no schema field change, just a stricter check on the existing
|
||||
`password` field.
|
||||
|
||||
## `POST /auth/login` — unchanged route, new pre-check
|
||||
|
||||
Before this feature: any number of attempts, any speed. After: attempts for the same submitted
|
||||
`email` beyond `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` within `LOGIN_RATE_LIMIT_WINDOW_SECONDS` receive:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many login attempts. Try again later." } }
|
||||
```
|
||||
|
||||
with HTTP `429`, distinct from the existing `401` identical-failure-response 010 already
|
||||
returns for wrong credentials.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Data Model: Authentication Hardening
|
||||
|
||||
No Postgres schema changes. `User.passwordHash` (010-identity-auth) is updated in place by a
|
||||
successful reset; no other model changes.
|
||||
|
||||
## Redis-only: Password Reset Token
|
||||
|
||||
Not a Prisma model — exists only as two paired Redis keys, both expiring together.
|
||||
|
||||
| Key | Value | TTL |
|
||||
|---|---|---|
|
||||
| `password-reset:token:<sha256(token)>` | `userId` | `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` |
|
||||
| `password-reset:user:<userId>` | `sha256(token)` | same |
|
||||
|
||||
**Issuing** (`requestPasswordReset`): if `password-reset:user:<userId>` already has a value,
|
||||
delete `password-reset:token:<that value>` first (invalidating the prior token — FR-002), then
|
||||
set both new keys.
|
||||
|
||||
**Consuming** (`resetPassword`): `GET password-reset:token:<sha256(presented token)>` → if
|
||||
absent, reject (FR-004: invalid/expired/already-used, indistinguishably — the key not existing
|
||||
covers all three cases identically, which is itself desirable: a consumer can't tell "expired"
|
||||
from "already used" from "never existed," matching the same non-leaking spirit as 010's own
|
||||
login-failure parity). If present, resolve `userId`, delete both keys (single-use), update the
|
||||
password.
|
||||
|
||||
## Configuration (new)
|
||||
|
||||
| Env var | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PASSWORD_MIN_LENGTH` | Minimum password length, enforced everywhere a password is set | `10` |
|
||||
| `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` | How long a reset token stays valid | `30` |
|
||||
| `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` | Max login attempts per email per window | `5` |
|
||||
| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | The window `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` applies over | `300` |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- `requestPasswordReset(email)`: always returns the same shape regardless of whether `email`
|
||||
resolves to a real, active account (FR-001) — internally, only issues a real token when it
|
||||
does; the caller-visible response is identical either way.
|
||||
- `resetPassword(token, newPassword)`: `validatePasswordStrength` runs first (fail fast on the
|
||||
cheap, stateless check), then the token is looked up. Unlike login/reset-request,
|
||||
account-existence secrecy doesn't apply here — FR-004 and User Story 2 both call for their
|
||||
*own*, specific rejection reasons ("password too short" vs. "invalid or expired token"); only
|
||||
FR-001's account-existence question needs the identical-response treatment, not this
|
||||
endpoint's two legitimately-different failure modes.
|
||||
- `login(email, password)`: the rate-limit check (`login:<email>`) runs first, before
|
||||
`repo.findByEmail`/`verifyPassword` (FR-007) — a rate-limited request never reaches the
|
||||
identical-failure-response logic 010 already built; it gets its own distinct rate-limit
|
||||
rejection instead (Acceptance Scenario 1's own point: a rate limit is an honestly-different
|
||||
condition from a credentials failure, not disguised as one).
|
||||
@@ -0,0 +1,126 @@
|
||||
# Implementation Plan: Authentication Hardening
|
||||
|
||||
**Branch**: `013-auth-hardening` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/013-auth-hardening/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adds `POST /auth/password-reset/request` and `POST /auth/password-reset/consume` to
|
||||
`identity/auth` (the module that already owns login/logout/self-identity mechanics), backed by
|
||||
a Redis-stored, single-use reset token — the "delivery" step logs the token server-side rather
|
||||
than emailing it. Adds a shared password-strength validator used by both the reset-consume
|
||||
endpoint and 010's own `POST /admin/users`. Adds a pre-credential-check rate limit to
|
||||
`POST /auth/login`, reusing the existing `checkRateLimit` helper 002's own inbound trust
|
||||
boundary already established.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — reuses `crypto` (Node built-in, for token generation and
|
||||
hashing), the existing `ioredis` client, and `zod`.
|
||||
|
||||
**Storage**: No schema change. Reset tokens live entirely in Redis (never in Postgres) — two
|
||||
keys per active token, mirroring the existing revocation-denylist's own Redis-key-with-TTL shape:
|
||||
`password-reset:token:<sha256(token)>` → `userId`, and `password-reset:user:<userId>` →
|
||||
`sha256(token)`, both with the same TTL (the reset token's own lifetime). The second key is what
|
||||
lets issuing a new token invalidate the previous one (FR-002) without a database table.
|
||||
|
||||
**Testing**: Vitest — unit tests for the password-strength validator and the rate-limit's own
|
||||
pre-credential-check ordering; integration tests against real Postgres/Redis for the full
|
||||
request → (read the token from the stub's log output) → consume → login-with-new-password flow,
|
||||
the identical-response-regardless-of-existing-account behavior, and the login rate limit
|
||||
actually rejecting the N+1th attempt while a different account's login proceeds normally.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `identity/auth` (new routes,
|
||||
service methods, the shared password-strength validator) and `identity/agents` (existing
|
||||
`POST /admin/users` now calls the shared validator instead of accepting any password
|
||||
unchecked).
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: The login rate-limit check is one Redis `INCR` (already how
|
||||
`checkRateLimit` works) — no added database round trip on the login hot path, consistent with
|
||||
010's own performance goal for `fastify.authenticate`.
|
||||
|
||||
**Constraints**: FR-001/SC-001 — reset-request must respond identically regardless of account
|
||||
existence, including timing-shape (the same pattern 010's login already established: do the
|
||||
same amount of work either way). FR-007 — the rate-limit check MUST run before
|
||||
`bcrypt.compare`, not after i.e. before any password-verification cost is paid, both for
|
||||
FR-007's own ordering requirement and so a rate-limited attacker gains no timing signal from a
|
||||
skipped bcrypt call.
|
||||
|
||||
**Scale/Scope**: Two new routes, one new shared validator, one new env-configured rate-limit
|
||||
policy, one modified existing endpoint (`POST /admin/users`). No new module, no schema
|
||||
migration, no new module dependencies. Explicitly excludes: MFA, real email delivery, IP-based
|
||||
rate limiting, password complexity rules beyond minimum length (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 | Same carve-out as 010 — this hardens SupportHub's own staff authentication, never touching SaaS-delegated customer identity. | PASS |
|
||||
| II. Configuration Over Hardcoding | Password minimum length and the login rate-limit's max-attempts/window are both new env-configured values (`PASSWORD_MIN_LENGTH`, `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS`), never hardcoded magic numbers — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | Reset endpoints live in `identity/auth` (owns auth mechanics); the shared password-strength validator is exported from `identity/auth`'s own public `index.ts` for `identity/agents` to consume, the same precedent `hashPassword`/`verifyPassword` themselves already set. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Not applicable — no new audit-relevant mutable domain state (a password hash change isn't itself an audited business event in this codebase's existing model). | PASS — N/A |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Reset-token issuance/consumption is a single Redis operation per step, no shared in-memory state; two concurrent consume attempts for the same token race safely (Redis `GET`+`DEL` — the loser sees the key already gone and is rejected, not a partial/double-apply). | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure — email delivery is explicitly stubbed (spec.md Assumptions, user decision), not a real provider integration. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/013-auth-hardening/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── auth.ts # MODIFIED — passwordMinLength, loginRateLimit config
|
||||
│ └── modules/
|
||||
│ └── identity/
|
||||
│ ├── auth/ # MODIFIED
|
||||
│ │ ├── mapper/
|
||||
│ │ │ └── password-policy.ts # NEW — shared validatePasswordStrength
|
||||
│ │ ├── mapper/
|
||||
│ │ │ └── reset-token.ts # NEW — generate/hash reset tokens
|
||||
│ │ ├── repository/
|
||||
│ │ │ └── reset-token.repository.ts # NEW — the two-Redis-key shape
|
||||
│ │ ├── service/ # MODIFIED — requestPasswordReset, resetPassword,
|
||||
│ │ │ login's new pre-check rate-limit call
|
||||
│ │ ├── controller/ routes/ # MODIFIED — the two new routes
|
||||
│ │ └── schema/ # MODIFIED — request/consume body schemas
|
||||
│ └── agents/
|
||||
│ └── service/
|
||||
│ └── users.service.ts # MODIFIED — calls the shared validator
|
||||
└── tests/
|
||||
├── unit/identity/ # password-policy validator, rate-limit ordering
|
||||
└── integration/ # full reset flow, identical-response check,
|
||||
login rate-limit behavior
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module. Everything lives in `identity/auth`
|
||||
(already owns login/logout/self-identity) except the one-line call site change in
|
||||
`identity/agents/service/users.service.ts`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,37 @@
|
||||
# Quickstart: Validating Authentication Hardening
|
||||
|
||||
## Scenario 1 — password reset, end to end
|
||||
|
||||
1. `POST /auth/password-reset/request` with a real seeded account's email. **Expected**: `200`,
|
||||
generic message; the server log shows a `password_reset_requested` line with a `resetUrl`
|
||||
containing the real token.
|
||||
2. Repeat with an email that doesn't exist. **Expected**: identical `200` response body to
|
||||
step 1 — diff them to confirm.
|
||||
3. `POST /auth/password-reset/consume` with the token from step 1's log and a policy-meeting new
|
||||
password. **Expected**: `200`.
|
||||
4. Repeat step 3 with the same token. **Expected**: rejected — the token is single-use.
|
||||
5. `POST /auth/login` with the account's email and the new password from step 3. **Expected**:
|
||||
`200`. Repeat with the account's old password. **Expected**: `401`.
|
||||
|
||||
## Scenario 2 — password strength enforced everywhere
|
||||
|
||||
1. `POST /admin/users` (as admin) with a password shorter than `PASSWORD_MIN_LENGTH`.
|
||||
**Expected**: `400`, naming the actual minimum length.
|
||||
2. `POST /auth/password-reset/consume` with a valid token and a too-short new password.
|
||||
**Expected**: the same `400` rejection reason as step 1.
|
||||
|
||||
## Scenario 3 — login rate limiting
|
||||
|
||||
1. Submit `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` failed login attempts for the same email within
|
||||
`LOGIN_RATE_LIMIT_WINDOW_SECONDS`. **Expected**: each returns `401` (the existing
|
||||
identical-failure-response).
|
||||
2. Submit one more attempt for that same email, still within the window — this time with the
|
||||
*correct* password. **Expected**: `429`, not `200` — the rate limit is checked before
|
||||
credentials (FR-007).
|
||||
3. Submit an attempt for a *different* email within the same window. **Expected**: proceeds
|
||||
normally (evaluated on its own credentials, not rate-limited).
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass against a real Postgres/Redis, and `POST /admin/users`'s own existing
|
||||
tests (010-identity-auth) still pass with the added password-strength check in place.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Research: Authentication Hardening
|
||||
|
||||
## Decision: reset tokens live only in Redis, as a paired key shape, never in Postgres
|
||||
|
||||
- **Decision**: A random 32-byte token (`crypto.randomBytes(32).toString('hex')`) is generated
|
||||
per request; only its SHA-256 hash is ever stored (the raw token is returned to the caller of
|
||||
`requestPasswordReset` for the stub-delivery step to log, then discarded). Two Redis keys per
|
||||
active token, both with the same TTL (the reset lifetime):
|
||||
- `password-reset:token:<hash>` → `userId` (resolves a presented token at consume time)
|
||||
- `password-reset:user:<userId>` → `hash` (lets issuing a new token find and delete the prior
|
||||
one's `token:` key, invalidating it — FR-002)
|
||||
- **Rationale**: Storing only the hash (never the raw token) mirrors this codebase's own
|
||||
password-hashing discipline (010's `hashPassword`) and 002's encrypted-credential-at-rest
|
||||
precedent — a Redis compromise alone shouldn't hand over usable reset tokens. The paired-key
|
||||
shape gets "only one active token per account" (FR-002) without a database table or a list
|
||||
scan; it's the same Redis-key-with-TTL pattern 010's own revocation denylist and 002's jti
|
||||
replay-guard already established, not a new pattern for this codebase.
|
||||
- **Alternatives considered**: A signed JWT with a `purpose: 'password-reset'` claim — rejected;
|
||||
a JWT can't be "invalidated by issuing a new one" without also tracking issued tokens
|
||||
somewhere (defeating the point of using a stateless token), so it would need the same Redis
|
||||
bookkeeping anyway while adding JWT-parsing overhead for no benefit. A Postgres table — works,
|
||||
but adds a migration and a cleanup/expiry job for data Redis's own TTL already expires for
|
||||
free; rejected as unnecessary durability for a short-lived, non-audit-relevant credential.
|
||||
|
||||
## Decision: the "delivery" stub is a structured log line, not a fake email object
|
||||
|
||||
- **Decision**: `requestPasswordReset` logs `{ event: 'password_reset_requested', userId,
|
||||
resetUrl }` at `info` level via the existing Pino logger — no new "mock email" abstraction,
|
||||
no `EmailService` interface to later swap out.
|
||||
- **Rationale**: Per the user's own explicit choice (stub delivery, not real email), the
|
||||
simplest honest stub is exactly what a developer needs during this phase: the token, visible
|
||||
in the same place every other structured log already goes. Building a fake `EmailService`
|
||||
interface now, before any real provider is chosen, would be speculative abstraction for a
|
||||
contract nobody has decided yet (which provider, which template).
|
||||
- **Alternatives considered**: A dedicated `EmailService`/`NotificationService` interface with a
|
||||
console/log implementation, swapped for a real one later — rejected as premature
|
||||
infrastructure for a single call site; revisit when a real provider is actually chosen (a
|
||||
separate, later decision per spec.md Assumptions).
|
||||
|
||||
## Decision: one shared `validatePasswordStrength`, minimum length only, `PASSWORD_MIN_LENGTH`-configured
|
||||
|
||||
- **Decision**: `identity/auth/mapper/password-policy.ts` exports
|
||||
`validatePasswordStrength(password: string): void`, throwing `ValidationError` naming the
|
||||
actual requirement (e.g. "Password must be at least N characters.") if `password.length <
|
||||
env.PASSWORD_MIN_LENGTH`. Called from both `AuthService`'s new `resetPassword` and
|
||||
`identity/agents`'s existing `UsersService.create`.
|
||||
- **Rationale**: FR-005 requires one policy enforced identically everywhere a password is set —
|
||||
a shared function is the only way to guarantee that rather than trusting two call sites to
|
||||
stay in sync by convention. Minimum length only (no character-class rules) matches current
|
||||
NIST guidance (length matters far more than forced complexity) and spec.md's own explicit
|
||||
scope boundary.
|
||||
- **Alternatives considered**: A zod `.refine()` embedded separately in each schema — rejected;
|
||||
duplicates the rule text and the minimum-length constant at two call sites, exactly the drift
|
||||
FR-005 exists to prevent.
|
||||
|
||||
## Decision: login rate-limit reuses the existing `checkRateLimit` helper, keyed by email
|
||||
|
||||
- **Decision**: `AuthService.login` calls
|
||||
`checkRateLimit(`login:${email}`, env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
env.LOGIN_RATE_LIMIT_WINDOW_SECONDS)` as its very first step, before `repo.findByEmail` or
|
||||
`verifyPassword` — throwing `RateLimitError` (already a distinct error/status from
|
||||
`AuthenticationError`, per the existing `common/errors`) if exceeded.
|
||||
- **Rationale**: `checkRateLimit` (`src/infrastructure/cache/rate-limiter.ts`) already exists,
|
||||
already used by 002's own inbound-request rate limiting, and is exactly the fixed-window
|
||||
Redis-`INCR` shape this feature needs — reusing it is the literal instruction 010's own
|
||||
Assumptions gave ("beyond what 002's existing generic rate-limit infrastructure might already
|
||||
cover"). Keying by the *submitted* email (not a resolved user id) means the limiter runs
|
||||
identically whether or not the account exists, so it can't itself become a second
|
||||
account-existence oracle.
|
||||
- **Alternatives considered**: `@fastify/rate-limit`'s own global plugin (already registered,
|
||||
1000 req/min) — insufficient on its own; that's a blunt per-IP-or-global HTTP-level limit, not
|
||||
a per-account brute-force defense, and 010's own Assumptions already anticipated needing
|
||||
something more targeted for login specifically.
|
||||
|
||||
## Decision: `POST /admin/users` gets the shared validator via a one-line call-site change
|
||||
|
||||
- **Decision**: `UsersService.create` calls `validatePasswordStrength(body.password)` before
|
||||
hashing, right alongside its existing duplicate-email check — no schema change, no new route.
|
||||
- **Rationale**: FR-005's "identically everywhere" requirement includes this pre-existing
|
||||
010 endpoint, which today accepts any non-empty string as a password. Minimal, surgical fix
|
||||
at the one call site that needed it.
|
||||
- **Alternatives considered**: None — this is the only other password-setting call site in the
|
||||
codebase (confirmed by searching for every `hashPassword(` call).
|
||||
@@ -0,0 +1,194 @@
|
||||
# Feature Specification: Authentication Hardening
|
||||
|
||||
**Feature Branch**: `013-auth-hardening`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Phase 11 security hardening pass, first slice: password-reset
|
||||
(self-service, with a stubbed email-delivery step logging the reset link instead of actually
|
||||
emailing it), a password-strength policy applied wherever a password is set, and login
|
||||
rate-limiting to slow down credential-stuffing/brute-force attempts against POST /auth/login.
|
||||
MFA is a separate, larger follow-up feature, not this one's scope."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - A user resets a forgotten password (Priority: P1)
|
||||
|
||||
A user who has forgotten their password requests a reset; the system issues a single-use,
|
||||
short-lived reset token and "delivers" it (this feature stubs delivery — see Assumptions — a
|
||||
later feature wires up real email). The user submits the token with a new password and can log
|
||||
in with it immediately afterward.
|
||||
|
||||
**Why this priority**: 010-identity-auth explicitly deferred this ("the smallest viable fix
|
||||
today is an admin recreating the account") — this is the first real self-service fix for a
|
||||
locked-out user, and the whole reason this feature exists.
|
||||
|
||||
**Independent Test**: Request a reset for a known account; retrieve the issued token (via the
|
||||
stub's own log output, since there's no real inbox to check); consume it with a new password;
|
||||
confirm login succeeds with the new password and fails with the old one.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an existing account, **When** its email requests a password reset, **Then** a
|
||||
single-use reset token is issued and "delivered" via the stub — the response itself never
|
||||
includes the token (it's not a client-visible value, matching a real email-delivery
|
||||
contract).
|
||||
2. **Given** an email that doesn't correspond to any account, **When** it requests a password
|
||||
reset, **Then** the response is identical to Scenario 1's own success response — never
|
||||
revealing whether the account exists (mirrors 010's own FR-002 philosophy).
|
||||
3. **Given** a valid, unexpired reset token, **When** it's submitted with a new password meeting
|
||||
the password-strength policy (User Story 2), **Then** the account's password is updated and
|
||||
the token becomes unusable — a second consume attempt with the same token is rejected.
|
||||
4. **Given** an expired or already-used reset token, **When** it's submitted, **Then** the
|
||||
request is rejected with a clear, specific reason — never silently accepted.
|
||||
5. **Given** a freshly-reset password, **When** the user logs in with it, **Then** login
|
||||
succeeds; the old password no longer works.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Password strength is enforced wherever a password is set (Priority: P1)
|
||||
|
||||
Whenever a password is set — an admin creating a new staff account, or a user resetting their
|
||||
own — the system enforces a minimum strength policy and rejects a weak password with a specific,
|
||||
actionable reason.
|
||||
|
||||
**Why this priority**: 010-identity-auth's own admin-account-creation (`POST /admin/users`) and
|
||||
this feature's own password-reset both accept a plaintext password with no strength check today
|
||||
— the most basic hardening gap a "security hardening pass" exists to close first.
|
||||
|
||||
**Independent Test**: Attempt to create an account (or reset a password) with a password that
|
||||
fails the policy (too short); confirm a clear rejection naming what's wrong. Repeat with a
|
||||
policy-meeting password; confirm it succeeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the admin account-creation endpoint, **When** a password shorter than the
|
||||
configured minimum length is submitted, **Then** the request is rejected with a message
|
||||
naming the actual requirement, not a generic validation error.
|
||||
2. **Given** the password-reset consume endpoint, **When** a policy-violating password is
|
||||
submitted, **Then** it's rejected the same way — one policy, enforced identically everywhere
|
||||
a password is ever set.
|
||||
3. **Given** a password meeting the policy, **When** it's submitted to either endpoint,
|
||||
**Then** it's accepted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Login attempts are rate-limited (Priority: P1)
|
||||
|
||||
Repeated login attempts against the same account within a short window are throttled, slowing
|
||||
down credential-stuffing and brute-force attacks without permanently locking out a legitimate
|
||||
user who mistypes their password a few times.
|
||||
|
||||
**Why this priority**: `POST /auth/login` has no attempt limit today — an attacker can try
|
||||
passwords against a known email address as fast as the network allows. This is the other
|
||||
baseline hardening gap named explicitly in 010-identity-auth's own Assumptions.
|
||||
|
||||
**Independent Test**: Submit repeated failed login attempts for the same email within the
|
||||
configured window; confirm attempts beyond the configured maximum are rejected with a
|
||||
rate-limit response, distinct from an authentication failure; confirm a successful login for a
|
||||
*different* account is unaffected.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the configured maximum login attempts per window has been reached for one email,
|
||||
**When** another attempt is made for that same email within the window, **Then** it's
|
||||
rejected with a clear rate-limit response (not the identical-failure-response body User
|
||||
Story 1/010 uses for wrong credentials — a rate limit is a different, honestly-reported
|
||||
condition).
|
||||
2. **Given** the same exhausted window, **When** a login attempt is made for a *different*
|
||||
email, **Then** it proceeds normally — the limit is per-account, not global.
|
||||
3. **Given** the rate-limit window has elapsed, **When** a new attempt is made for the
|
||||
previously-limited email, **Then** it's evaluated normally again.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if a user requests a password reset for the same account multiple times before
|
||||
consuming the first token? Each request issues its own new token; consuming any valid,
|
||||
unexpired one succeeds, and consuming one invalidates all of that account's other outstanding
|
||||
reset tokens (never allowing two guesses to both later succeed independently).
|
||||
- What happens if a reset token is consumed for an account that was deactivated after the token
|
||||
was issued but before it was used? The reset is rejected — reactivating a deactivated account
|
||||
is an admin action (010's own domain), not something a password-reset flow performs
|
||||
incidentally.
|
||||
- What happens to a rate-limited login attempt that would have actually succeeded (correct
|
||||
password, but the account is rate-limited from prior failed attempts)? It's still rejected —
|
||||
the rate limit is evaluated before credentials, exactly like a real brute-force defense must
|
||||
be, not skipped for a lucky correct guess.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST let a user request a password reset by email, always returning an
|
||||
identical response regardless of whether the email corresponds to an existing account
|
||||
(mirrors 010's FR-002).
|
||||
- **FR-002**: The system MUST issue a single-use, time-limited reset token per request, and MUST
|
||||
invalidate a token immediately upon use or upon a newer token being issued for the same
|
||||
account.
|
||||
- **FR-003**: The system MUST "deliver" the reset token via a clearly-labeled stub (server-side
|
||||
log output) rather than a real email — this feature does not add email-sending infrastructure
|
||||
(Assumptions).
|
||||
- **FR-004**: The system MUST let a user consume a valid reset token with a new password,
|
||||
updating the account's password hash and rejecting an invalid, expired, or already-used token
|
||||
with a specific, distinguishable reason.
|
||||
- **FR-005**: The system MUST enforce one configured password-strength policy (at minimum, a
|
||||
minimum length) identically at every point a password is ever set — admin account creation
|
||||
and password-reset consumption alike — never two different or duplicated policies.
|
||||
- **FR-006**: The system MUST rate-limit `POST /auth/login` attempts per submitted email within
|
||||
a configured window, rejecting attempts beyond the configured maximum with a response distinct
|
||||
from a credentials failure.
|
||||
- **FR-007**: The login rate limit MUST be evaluated before password verification, so a
|
||||
rate-limited attempt is rejected regardless of whether the submitted password is actually
|
||||
correct.
|
||||
- **FR-008**: The system MUST NOT lock an account indefinitely — the rate limit is a rolling/
|
||||
fixed window that clears on its own, not a manual-unlock-required lockout.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Password Reset Token**: A single-use, time-limited credential tying one request to one
|
||||
account, consumed exactly once to authorize a password change.
|
||||
- **Password Policy**: The configured minimum-strength rule(s) applied identically at every
|
||||
password-setting point in the system.
|
||||
- **Login Attempt Counter**: A rolling/fixed-window count of failed login attempts per
|
||||
submitted email, backing the rate limit.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of password-reset requests (existing or nonexistent account) receive an
|
||||
identical response — 0% reveal account existence.
|
||||
- **SC-002**: 100% of password-reset tokens are usable exactly once; a second consume attempt
|
||||
with the same token fails 100% of the time.
|
||||
- **SC-003**: 100% of passwords accepted by any password-setting endpoint meet the configured
|
||||
policy; 0% of policy-violating passwords are ever stored.
|
||||
- **SC-004**: An account subjected to more login attempts than the configured maximum within
|
||||
the configured window is rejected on 100% of the excess attempts, regardless of whether the
|
||||
submitted password was correct.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Email delivery is stubbed, not real** — the reset token is logged server-side rather than
|
||||
emailed, per explicit user decision; wiring up a real email provider is a separate, later
|
||||
concern once that infrastructure choice is made.
|
||||
- **MFA is out of scope** — a separate, larger follow-up feature; this pass only closes the two
|
||||
gaps 010-identity-auth's own Assumptions named as "not this feature's job."
|
||||
- **No account self-registration** — unchanged from 010; password reset only ever applies to an
|
||||
existing account, never creates one.
|
||||
- **The password-strength policy is a minimum-length rule, configurable, not a fixed hardcoded
|
||||
value** (`docs/10-implementation-roadmap.md`'s own "never hardcode a placeholder value and
|
||||
ship it as final" instruction) — the exact minimum is a `CONFIGURABLE` value with a reasonable
|
||||
default, not a business-confirmed final number; additional complexity rules (character
|
||||
classes, breached-password checks) are a possible future enhancement, not required here.
|
||||
- **Rate limiting is per submitted email, not per IP** — the most direct defense against
|
||||
credential-stuffing a specific known account; IP-based limiting is a possible future
|
||||
enhancement layered on top, not required here.
|
||||
- **Existing sessions are not force-revoked on password reset** — a reset invalidates the
|
||||
password (and all other outstanding reset tokens for that account), but any already-issued,
|
||||
unexpired login session remains valid until its own natural expiry (010's own 4-hour token
|
||||
lifetime bounds this) rather than requiring a database check on every authenticated request
|
||||
(010's own performance goal of a single Redis round trip per request, no DB read).
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
description: "Task list for 013-auth-hardening"
|
||||
---
|
||||
|
||||
# Tasks: Authentication Hardening
|
||||
|
||||
**Input**: Design documents from `specs/013-auth-hardening/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/auth-hardening-contract.md](./contracts/auth-hardening-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 password reset, US2 = P1 password
|
||||
policy, US3 = P1 login rate-limiting). US2 is a dependency US1's own consume endpoint needs, so
|
||||
build it first despite the nominal priority tie; US3 is independent of both.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `PASSWORD_MIN_LENGTH` (default `10`),
|
||||
`PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` (default `30`),
|
||||
`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` (default `5`), and `LOGIN_RATE_LIMIT_WINDOW_SECONDS`
|
||||
(default `300`) to `src/config/env.ts`, exposed via `src/config/auth.ts`'s existing
|
||||
`authConfig` object
|
||||
|
||||
**Checkpoint**: Config in place. Both user stories can now be built.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 2 - Password strength is enforced wherever a password is set (Priority: P1)
|
||||
|
||||
**Goal**: One shared validator, called from both the (not-yet-built) reset-consume endpoint and
|
||||
the existing admin account-creation endpoint.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the
|
||||
actual minimum named; policy-meeting password passes) in
|
||||
`tests/unit/identity/password-policy.test.ts`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s
|
||||
`validatePasswordStrength(password): void`, throwing `ValidationError` (depends on T001)
|
||||
- [x] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003)
|
||||
- [x] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`,
|
||||
before hashing (depends on T004)
|
||||
- [x] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run
|
||||
010-identity-auth's own existing `POST /admin/users` tests to confirm no regression
|
||||
|
||||
**Checkpoint**: No password shorter than the policy can ever be set via the admin endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - A user resets a forgotten password (Priority: P1)
|
||||
|
||||
**Goal**: The full request → stub-delivery → consume → login-with-new-password flow.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via
|
||||
the log stub; a nonexistent email gets an identical response; consume succeeds once and
|
||||
fails the second time; login works with the new password and fails with the old) in
|
||||
`tests/integration/password-reset-flow.test.ts` (depends on T006)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T008 [US1] Add `identity/auth/mapper/reset-token.ts` — `generateResetToken()` (raw token +
|
||||
its SHA-256 hash) (depends on T001)
|
||||
- [x] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId,
|
||||
tokenHash, ttlSeconds)` (deletes any prior token for this user first, per data-model.md's
|
||||
paired-key shape), `resolve(tokenHash)` (returns `userId` or null), `consume(tokenHash,
|
||||
userId)` (deletes both keys) (depends on T008)
|
||||
- [x] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public
|
||||
result; internally, if the email resolves to an active account, issues a token and logs
|
||||
the stub delivery event (structured log, research.md) (depends on T009)
|
||||
- [x] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password
|
||||
strength first (depends on T004), then resolves/consumes the token, 400s with a specific
|
||||
reason if the token is missing/expired/used, hashes and stores the new password (depends
|
||||
on T009, T004)
|
||||
- [x] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume`
|
||||
(both ungated — no session exists yet) in `identity/auth/controller/` + `routes/` +
|
||||
`schema/`, registered from `src/api/routes.ts` (already registers `authRoutes` as a
|
||||
whole, so no new registration call needed — depends on T010, T011)
|
||||
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
|
||||
|
||||
**Checkpoint**: A locked-out user has a real, working self-service fix.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Login attempts are rate-limited (Priority: P1)
|
||||
|
||||
**Goal**: `POST /auth/login` throttles repeated attempts per submitted email, checked before any
|
||||
credential verification.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before
|
||||
`repo.findByEmail`/`verifyPassword` in `AuthService.login` (a fake repo/mapper that would
|
||||
throw if called after an already-exceeded limit) in
|
||||
`tests/unit/identity/login-rate-limit-ordering.test.ts`
|
||||
- [x] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th
|
||||
— even with the correct password — gets 429, a different email is unaffected) in
|
||||
`tests/integration/login-rate-limit.test.ts` (depends on T001)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T016 [US3] In `AuthService.login`, call the existing
|
||||
`checkRateLimit(`login:${email}`, authConfig.loginRateLimitMaxAttempts,
|
||||
authConfig.loginRateLimitWindowSeconds)` (from `@/infrastructure/cache`) as the very first
|
||||
step, throwing `RateLimitError` if exceeded (depends on T001)
|
||||
- [x] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
|
||||
|
||||
**Checkpoint**: All three user stories work independently and together — this feature's full
|
||||
scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T020 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
(particularly 010-identity-auth's own login/admin-account tests, now touched by this
|
||||
feature's changes)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS everything
|
||||
- **User Story 2 (Phase 2)**: Depends on Foundational — BLOCKS User Story 1 (its consume
|
||||
endpoint needs the shared validator)
|
||||
- **User Story 1 (Phase 3)**: Depends on User Story 2
|
||||
- **User Story 3 (Phase 4)**: Depends only on Foundational — independent of US1/US2, could be
|
||||
built in parallel with either
|
||||
- **Polish (Phase 5)**: Depends on all three
|
||||
@@ -0,0 +1,100 @@
|
||||
# Specification Quality Checklist: Full Observability
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**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
|
||||
|
||||
- This is `docs/10-implementation-roadmap.md`'s own Phase 11, second sub-area, per explicit user
|
||||
direction (the first was 013-auth-hardening's security pass). The user explicitly chose "Full
|
||||
observability" over "Reporting/analytics dashboards" as a distinct, separately-scoped sub-area
|
||||
— FR-009 and several Assumptions exist specifically to keep this feature from drifting into
|
||||
that adjacent, not-yet-started work.
|
||||
- The three named infrastructure gaps (no per-request access log, a dead request-duration
|
||||
histogram, a never-initialized tracer) and all eleven "key metrics to track" being completely
|
||||
untracked today were confirmed by direct code inspection before writing this spec, not assumed.
|
||||
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
|
||||
required — every open question had a reasonable, documented default (see Assumptions).
|
||||
|
||||
## Implementation Notes (post-build)
|
||||
|
||||
- Registering a real `TracerProvider` alone was not sufficient to make span nesting work across
|
||||
this feature's own async event-bus subscribers: without also registering an
|
||||
`AsyncLocalStorageContextManager` (`@opentelemetry/context-async-hooks`, a third new
|
||||
dependency beyond the two research.md originally named), the OpenTelemetry API's
|
||||
`context.active()` is a no-op that does not propagate across `await` boundaries at all —
|
||||
`orchestration.assignment` came out as its own unrelated root span/trace instead of nesting
|
||||
under `ai.escalation`. Caught by the tracing integration test's own parent/child assertions
|
||||
actually failing on the first implementation, not assumed correct from reading the SDK's docs.
|
||||
- Installing `@opentelemetry/exporter-trace-otlp-http` alongside the already-pinned
|
||||
`@opentelemetry/sdk-trace-base@^1.22.0` pulled two incompatible OpenTelemetry core/resources
|
||||
major versions (1.x and 2.x) side by side. Resolved by bumping `sdk-trace-base` to `^2.11.0` to
|
||||
match — this also happened to close a moderate DoS advisory in `@opentelemetry/core <2.8.0`
|
||||
that the 1.x line was pinned to.
|
||||
- T016 (graceful degradation under an unreachable OTLP endpoint) ended up as its own unit test
|
||||
(`tests/unit/observability/tracing-graceful-degradation.test.ts`) rather than living in
|
||||
`tracing.test.ts` as tasks.md originally described. Reason: `tracing.ts` always uses the
|
||||
in-memory test exporter when `NODE_ENV=test`, so the integration suite's own running app can't
|
||||
be pointed at a bad OTLP endpoint to exercise this. The unit test instead constructs a real
|
||||
`BasicTracerProvider`/`BatchSpanProcessor`/`OTLPTraceExporter` pointed at a genuinely
|
||||
unreachable address directly, and — importantly — verifies the SDK's _background_ export path
|
||||
(what production actually exercises) never produces an unhandled rejection, rather than calling
|
||||
`forceFlush()` directly, which is documented OpenTelemetry behavior that _does_ reject on a
|
||||
failed export by design (the first version of this test asserted the wrong thing and failed
|
||||
against real, correct SDK behavior — not a bug in this feature's own code).
|
||||
- `sla.service.ts`'s pre-existing status-overwrite gap (a `'breached'` run's status silently
|
||||
becomes `'completed'` if the ticket later resolves — see research.md §5) was worked around for
|
||||
the metric's own correctness (read `run.status` before the overwrite) but left unfixed in the
|
||||
underlying data, consistent with how 013-auth-hardening documented a pre-existing bug it found
|
||||
without fixing it.
|
||||
- Found and fixed one genuine cross-file test-isolation bug this feature's own new test caused:
|
||||
`business-metrics.test.ts`'s "human resolution" case originally drove a ticket through a real
|
||||
`HUMAN_ESCALATION` transition via `ticketsService.updateStatus`, which — same as any other
|
||||
escalation in this codebase — triggers the real orchestration subscriber's default
|
||||
`ROUND_ROBIN` auto-assignment against every agent in the shared throwaway database, including
|
||||
other concurrently-running test files' own dedicated agents (reproduced deterministically
|
||||
against `agent-ticket-queue.test.ts`). Fixed by driving the intermediate state-machine
|
||||
transitions directly through `ticketsRepository.updateStatus` (no domain-event publish)
|
||||
instead, reserving the real, event-publishing `ticketsService.updateStatus` call for only the
|
||||
final `RESOLVED` transition the metric subscriber actually needs to observe.
|
||||
- Separately, found (not caused by this feature — confirmed via `git checkout` to the clean
|
||||
pre-014 commit and reproducing the identical failure) a pre-existing systemic collision risk in
|
||||
ticket-code generation: `ticket-code.ts`'s `deriveProductCode` keeps only the first 4
|
||||
alphabetic characters of `externalProductId`, so essentially every integration test file in
|
||||
this codebase (nearly all of which name their test products `TEST_<SOMETHING>`) collapses to
|
||||
the identical `"TEST"` code prefix. Running enough `TEST_*`-prefixed files concurrently (as
|
||||
vitest does by default across worker threads/processes) makes independent files race for the
|
||||
same `TEST-<year>-<sequence>` numbering space, occasionally exceeding
|
||||
`tickets.service.ts`'s fixed `MAX_CODE_RETRIES = 5` and surfacing as a real `500`
|
||||
(`Unique constraint failed on the fields: (code)`) instead of the retry silently absorbing it.
|
||||
Confirmed independent of this feature (reproduces on `79bc2ef`, 013-auth-hardening's tip, with
|
||||
none of this feature's code present) and left unfixed here — a ticket-code-generation
|
||||
concurrency fix belongs to 003-ticketing's own module, out of scope for an observability
|
||||
feature. Worth a dedicated future fix (e.g. a longer/hash-based product code, or a
|
||||
database-level sequence rather than a `COUNT`-then-retry scheme).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Contract: `/metrics` output
|
||||
|
||||
This feature adds no new HTTP endpoints — `GET /metrics` already exists and its response shape
|
||||
(Prometheus text exposition format) is unchanged. This document is the contract for its
|
||||
**content**: which metric series a consumer (Prometheus, or any scraper) can rely on after this
|
||||
feature ships, replacing the usual per-endpoint request/response contract for a feature with no
|
||||
new routes.
|
||||
|
||||
## Guarantees
|
||||
|
||||
1. Every metric already exposed today (the default `prom-client` process metrics, and
|
||||
`supporthub_http_request_duration_seconds`) continues to appear, with the same name and label
|
||||
set — FR-010. `supporthub_http_request_duration_seconds` gains real observations where today
|
||||
it has none; its metric name/labels/type do not change.
|
||||
2. Each of the eleven new series in [data-model.md](../data-model.md#metrics-prometheus-via-prom-client)
|
||||
appears on `/metrics` from process start (a `Counter`/`Histogram` with zero observations
|
||||
still exports its metadata — `# HELP`/`# TYPE` lines — even before its first increment; a
|
||||
consumer's dashboard/alert config can reference it immediately without waiting for the first
|
||||
event).
|
||||
3. No metric name or label value is derived from unbounded, request-supplied input — every
|
||||
label is one of: a fixed small enum (`outcome`, `resolved_by`, `matched`), a route pattern
|
||||
(bounded by the number of registered routes), a tool name (bounded by the tool registry), an
|
||||
error code or category ID (bounded by admin-configured product data, not raw user text).
|
||||
This is a deliberate constraint, not an incidental one — unbounded label cardinality is a
|
||||
well-known way to make a Prometheus deployment fall over, and every label chosen in
|
||||
data-model.md was checked against this before being finalized.
|
||||
4. `/health`, `/health/live`, `/health/ready` response shapes are unchanged (FR-010) — this
|
||||
feature does not touch `health.service.ts` or `health.routes.ts`.
|
||||
|
||||
## Example (illustrative, not exhaustive)
|
||||
|
||||
```text
|
||||
# HELP supporthub_http_request_duration_seconds Duration of HTTP requests in seconds
|
||||
# TYPE supporthub_http_request_duration_seconds histogram
|
||||
supporthub_http_request_duration_seconds_bucket{method="POST",route="/tickets",status_code="201",le="0.1"} 3
|
||||
supporthub_http_request_duration_seconds_count{method="POST",route="/tickets",status_code="201"} 3
|
||||
|
||||
# HELP supporthub_ai_session_outcomes_total Count of AI support sessions by terminal outcome
|
||||
# TYPE supporthub_ai_session_outcomes_total counter
|
||||
supporthub_ai_session_outcomes_total{outcome="resolved"} 12
|
||||
supporthub_ai_session_outcomes_total{outcome="escalated"} 4
|
||||
|
||||
# HELP supporthub_sla_run_outcomes_total Count of SLA runs by outcome
|
||||
# TYPE supporthub_sla_run_outcomes_total counter
|
||||
supporthub_sla_run_outcomes_total{outcome="met"} 9
|
||||
supporthub_sla_run_outcomes_total{outcome="breached"} 1
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Integration tests assert against this contract by scraping `GET /metrics` (a real
|
||||
`app.inject` call, real registry) before and after driving each metric's real underlying event
|
||||
through the real API, parsing the specific series' value out of the text response and asserting
|
||||
it moved by exactly the expected amount — never by mocking `prom-client` or the registry itself.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Data Model: Full Observability
|
||||
|
||||
No Prisma schema changes — every entity here is in-process or exported to an external
|
||||
observability sink, never persisted to Postgres.
|
||||
|
||||
## Request Context Store
|
||||
|
||||
`AsyncLocalStorage<RequestContextSnapshot>`, populated once per request in
|
||||
`request-context.plugin.ts`'s existing `onRequest` hook (the same hook that already builds
|
||||
`request.reqContext`), read by `logger.ts`'s Pino `mixin` function on every subsequent log call
|
||||
made anywhere during that request's handling.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `requestId` | `string` | Same value already assigned to `request.reqContext.requestId` |
|
||||
| `correlationId` | `string` | Same value already assigned to `request.reqContext.correlationId` |
|
||||
|
||||
## Access Log Line (shape, not a stored entity)
|
||||
|
||||
Emitted once per completed request via the existing `logger` singleton from the new
|
||||
`onResponse` hook.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `method` | `string` | HTTP method |
|
||||
| `route` | `string` | Parameterized route pattern (`request.routeOptions.url`), not the raw URL |
|
||||
| `statusCode` | `number` | Response status |
|
||||
| `durationMs` | `number` | `reply.elapsedTime` |
|
||||
| `requestId` / `correlationId` | `string` | Via the mixin, same as every other line for this request |
|
||||
| `event` | `string` | Fixed value `"http_request_completed"` — lets log queries filter to access-log lines specifically |
|
||||
|
||||
Log level: `info` for 2xx/3xx, `warn` for 4xx, `error` for 5xx — mirrors the existing
|
||||
error-handler's own level choices (`app.ts`) so severity is consistent across both sources of
|
||||
request-outcome logging.
|
||||
|
||||
## Metrics (Prometheus, via `prom-client`)
|
||||
|
||||
All registered in `infrastructure/observability/metrics.ts` on the existing default registry
|
||||
(`metricsRegistry`, already exposed at `GET /metrics`), all prefixed `supporthub_` to match the
|
||||
existing histogram and default-metrics prefix.
|
||||
|
||||
| Metric name | Type | Labels | Incremented/observed when |
|
||||
|---|---|---|---|
|
||||
| `supporthub_http_request_duration_seconds` | Histogram *(existing, now actually observed)* | `method`, `route`, `status_code` | Every completed HTTP request |
|
||||
| `supporthub_ai_session_outcomes_total` | Counter | `outcome` (`resolved` \| `escalated`) | An AI support session reaches a terminal `resolved`/`escalated` status |
|
||||
| `supporthub_ticket_resolutions_total` | Counter | `resolved_by` (`ai` \| `human`) | A ticket reaches `RESOLVED`, labeled from the ticket's `Resolution.resolvedBy` |
|
||||
| `supporthub_ticket_resolution_duration_seconds` | Histogram | — | A ticket reaches `RESOLVED` — observes `resolvedAt - ticket.createdAt` |
|
||||
| `supporthub_ticket_first_response_duration_seconds` | Histogram | — | The first `AGENT_MESSAGE` is posted on a ticket — observes `firstResponseAt - ticket.createdAt` |
|
||||
| `supporthub_sla_run_outcomes_total` | Counter | `outcome` (`met` \| `breached`) | An SLA run completes on time (`met`) or is flagged by the breach sweep (`breached`) |
|
||||
| `supporthub_escalations_total` | Counter | `reason` | An `ESCALATION_TRIGGERED` domain event fires (already published unconditionally today) |
|
||||
| `supporthub_problems_created_total` | Counter | `category_id` (or `uncategorized`) | A `Problem` row is created (at ticket-intake time) |
|
||||
| `supporthub_known_error_lookups_total` | Counter | `code` | A valid error code's known issues are looked up |
|
||||
| `supporthub_knowledge_retrieval_outcomes_total` | Counter | `matched` (`true` \| `false`) | The AI's `searchProductKnowledge` tool call returns zero vs. one-or-more results |
|
||||
| `supporthub_tool_invocations_total` | Counter | `tool`, `outcome` (`success` \| `failed`) | Every AI tool-call result, any tool |
|
||||
|
||||
Deliberately **not** separate metrics (per spec.md's Assumptions): "recurring problems" and
|
||||
"most common errors" are read directly off `supporthub_problems_created_total` and
|
||||
`supporthub_known_error_lookups_total` respectively via a monitoring stack's own `topk`/`rate`
|
||||
query — no additional "top N" metric or logic is computed by this application.
|
||||
|
||||
## Traces / Spans (exported, not persisted)
|
||||
|
||||
| Span | Parent | Attributes | Created in |
|
||||
|---|---|---|---|
|
||||
| `ticket.create` | (root) | `ticket.id`, `product.externalProductId` | `ticketing/tickets/service/tickets.service.ts` |
|
||||
| `ai.escalation` | `ticket.create` (if within the same request) or its own root (async paths) | `ticket.id`, `session.id` | `ai-support/sessions/service/session.service.ts`, around the escalation branch |
|
||||
| `orchestration.assignment` | `ai.escalation` (via the `TICKET_UPDATED`/`HUMAN_ESCALATION` subscriber) | `ticket.id`, `strategy` | `orchestration/orchestration` + `orchestration/assignments`, wrapping the existing `handleHumanEscalation` call |
|
||||
|
||||
Span context propagation across the domain-event bus relies on the OpenTelemetry Context API's
|
||||
own async-local propagation — since `eventBus.publish(...)` is `await`ed synchronously within
|
||||
the same call chain (confirmed in `tickets.service.ts`/`escalation.service.ts`), no manual
|
||||
context-carrying payload field is needed.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Implementation Plan: Full Observability
|
||||
|
||||
**Branch**: `014-full-observability` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/014-full-observability/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Wires three already-scaffolded-but-inert observability primitives into something real: a
|
||||
per-request structured access log (none exists today — Fastify's own request logging is fully
|
||||
disabled), the existing-but-never-observed request-duration histogram, and a real OpenTelemetry
|
||||
tracer provider behind the existing-but-never-called `getTracer()` helper. Adds eleven live
|
||||
Prometheus counters/histograms for the business-health metrics `docs/09-testing-observability-
|
||||
cicd.md` names, each wired at one existing choke point per metric (an event-bus subscriber where
|
||||
one already exists for the transition, a single already-existing method otherwise) rather than
|
||||
scattered across every call site. No new endpoints, no schema changes, no `supporthub-web` work
|
||||
— see research.md for the exact hook point chosen for each of the fourteen instrumentation
|
||||
targets (3 infra + 11 named metrics) and why.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: New — `@opentelemetry/exporter-trace-otlp-http` (OTLP/HTTP span
|
||||
export), `@opentelemetry/resources` (service-name resource attribute). Reused, already
|
||||
installed — `@opentelemetry/api`, `@opentelemetry/sdk-trace-base` (provider, processors, and
|
||||
both the console and in-memory exporters used here all come from this one package), `prom-client`,
|
||||
`pino`. Reused Node built-in — `async_hooks`' `AsyncLocalStorage`.
|
||||
|
||||
**Storage**: No schema change. All new state is either in-process (Prometheus metric registry,
|
||||
the ALS request-context store, the tracer provider) or exported to wherever tracing is
|
||||
configured to send it — no new Postgres/Redis reads or writes beyond a handful of existing-table
|
||||
lookups already needed to label a metric correctly (e.g. `resolutionRepository.findByTicketId`
|
||||
to distinguish AI vs. human resolution).
|
||||
|
||||
**Testing**: Vitest — unit tests for the ALS-based logger mixin (a log call inside a request
|
||||
context carries requestId/correlationId; one outside carries neither) and for the
|
||||
SLA-compliance metric's "don't double-count an already-breached run as met" guard. Integration
|
||||
tests against real Postgres/Redis for: the access-log line's presence/shape (captured via a
|
||||
`logger.info` spy, same technique as 013's password-reset test), `/metrics` scraped before/after
|
||||
real traffic showing the duration histogram and each of the eleven business counters/histograms
|
||||
change by the expected amount when their real underlying event is driven through the real API,
|
||||
and a real multi-span trace (read back from the test-environment `InMemorySpanExporter`) for the
|
||||
two named cross-module paths.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies
|
||||
`infrastructure/observability/*` (logger, metrics, tracing, a new request-context store) and
|
||||
`plugins/request-context.plugin.ts` (the new `onResponse` hook); adds small, single-call-site
|
||||
instrumentation lines inside `ai-support/sessions`, `ai-support/knowledge`, `ai-support/tools`,
|
||||
`ticketing/tickets`, `ticketing/messages`, `orchestration/sla`, and a handful of new subscribers
|
||||
in `src/events/handlers/index.ts`. No module gains a new public export surface beyond what
|
||||
`getTracer()` already exposed.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: The `onResponse` hook adds one Pino log call and one histogram `.observe`
|
||||
per request — both already-paid-for infrastructure (the logger and the metric object already
|
||||
exist), no new I/O on the request hot path. Trace export runs via `BatchSpanProcessor` (out of
|
||||
the request's own async chain) so span export latency never adds to response time. Metric
|
||||
increments at the eleven business hook points are in-memory counter operations, not database
|
||||
writes — the handful of read lookups needed for correct labeling (e.g. the resolution lookup for
|
||||
#3/#4) are single-row, already-indexed reads on tables these modules already query routinely.
|
||||
|
||||
**Constraints**: FR-007 — tracing must degrade gracefully; the API must start and serve traffic
|
||||
normally with no collector configured or reachable. FR-009 — no new human-facing endpoint,
|
||||
dashboard, or aggregation logic; every FR-008 metric is a raw counter/histogram for an external
|
||||
scraper, full stop. FR-010 — `/health*` and the existing histogram's shape on `/metrics` must
|
||||
not change for any existing consumer (only new metrics are added, nothing existing is renamed or
|
||||
removed).
|
||||
|
||||
**Scale/Scope**: Zero new routes. Three modified observability infrastructure files plus one new
|
||||
request-context store. Eleven new metric definitions plus their one-choke-point instrumentation
|
||||
call each. Two new dependencies. No schema migration, no new module.
|
||||
|
||||
## 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 | Not applicable — no identity/access surface touched. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | The tracing exporter destination (`OTEL_EXPORTER_OTLP_ENDPOINT`) is env-driven, not hardcoded per environment; no business policy value is introduced by this feature (no SLA/routing/threshold numbers). | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | No new module; existing module boundaries unchanged (each metric's instrumentation call lives inside the module that already owns the event, per research.md's per-metric table). The two repository-layer instrumentation calls (#1/#2, AI session status) are a deliberate, disclosed exception — see research.md §5's justification: observability calls are already a cross-cutting concern used from any layer in this codebase (e.g. `logger.error` inside `tool-executor.ts`), not the kind of business-logic leakage this principle targets. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI decision logic changed, only observation of its outcomes. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Directly implements this principle's own stated requirement — "every log line MUST carry a request ID/correlation ID" is written in the constitution today but not actually true until this feature (FR-001/FR-002). | PASS — this feature closes a pre-existing constitutional gap |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | The first-response-time metric (#5) has a benign, disclosed race (two concurrent first `AGENT_MESSAGE`s could both read "zero prior messages" and both observe) — acceptable because it is a best-effort observability metric, not the assignment/SLA correctness this principle is protecting; no persisted state or business decision depends on it. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — no model change. | PASS — N/A |
|
||||
| Technology & Platform Constraints | Two new dependencies (both OpenTelemetry, both already in the stack's declared technology list — "OpenAPI" aside, tracing itself was always part of the stated stack via the pre-existing `@opentelemetry/api`/`sdk-trace-base` dependencies) — no new infrastructure category introduced. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/014-full-observability/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── metrics-contract.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── src/
|
||||
│ ├── infrastructure/
|
||||
│ │ └── observability/
|
||||
│ │ ├── logger.ts # MODIFIED — mixin reads the new ALS store
|
||||
│ │ ├── metrics.ts # MODIFIED — 11 new Counter/Histogram definitions
|
||||
│ │ ├── tracing.ts # MODIFIED — real provider init, exporter selection
|
||||
│ │ └── request-context.store.ts # NEW — AsyncLocalStorage<RequestContext>
|
||||
│ ├── plugins/
|
||||
│ │ └── request-context.plugin.ts # MODIFIED — onResponse access-log + histogram hook,
|
||||
│ │ onRequest now runs the rest of the request
|
||||
│ │ inside the ALS store
|
||||
│ ├── events/
|
||||
│ │ └── handlers/index.ts # MODIFIED — 3 new subscribers (human-resolution +
|
||||
│ │ resolution-time on TICKET_UPDATED/RESOLVED,
|
||||
│ │ escalation-rate on ESCALATION_TRIGGERED)
|
||||
│ └── modules/
|
||||
│ ├── ai-support/
|
||||
│ │ ├── sessions/repository/session.repository.ts # MODIFIED — AI resolution/escalation
|
||||
│ │ ├── knowledge/service/error-codes.service.ts # MODIFIED — most-common-errors
|
||||
│ │ └── tools/service/tools.service.ts # MODIFIED — tool-failure + knowledge-
|
||||
│ │ effectiveness
|
||||
│ ├── ticketing/
|
||||
│ │ ├── tickets/service/tickets.service.ts # MODIFIED — recurring-problems, plus
|
||||
│ │ │ the two named trace spans
|
||||
│ │ └── messages/service/messages.service.ts # MODIFIED — first-response-time
|
||||
│ └── orchestration/
|
||||
│ └── sla/service/sla.service.ts # MODIFIED — SLA-compliance
|
||||
└── tests/
|
||||
├── unit/observability/ # ALS mixin, SLA-compliance double-count guard
|
||||
└── integration/observability/ # access log, /metrics scrape assertions (11 metrics
|
||||
+ duration histogram), cross-module trace
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module. All changes are surgical additions inside
|
||||
`infrastructure/observability` (the module that already owns this concern) plus one small,
|
||||
justified instrumentation line inside each of six existing business modules, following the
|
||||
per-metric hook points research.md already identified against the real, current code.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,70 @@
|
||||
# Quickstart: Full Observability
|
||||
|
||||
Manual verification steps for each user story, against a running instance backed by real
|
||||
Postgres/Redis (the throwaway Docker containers already used throughout this project's test
|
||||
suite work equally well for a manual run).
|
||||
|
||||
## Scenario 1 — Per-request access log (User Story 1)
|
||||
|
||||
1. Start the API. Send any request (e.g. `GET /health`).
|
||||
2. **Expected**: exactly one log line appears with `event: "http_request_completed"`, the
|
||||
request's method, route, status code, and a `requestId`.
|
||||
3. Send a request to a route that triggers additional internal logging (e.g. a login attempt).
|
||||
4. **Expected**: every log line produced while handling that request — the access-log line and
|
||||
any domain log lines — carries the same `requestId`/`correlationId`.
|
||||
5. Send a request to a route that doesn't exist.
|
||||
6. **Expected**: a 404 access-log line is still emitted (not silently dropped).
|
||||
|
||||
## Scenario 2 — Live request-health metrics (User Story 2)
|
||||
|
||||
1. Send a mix of successful and failing requests (e.g. a valid login, then three wrong-password
|
||||
logins).
|
||||
2. Scrape `GET /metrics`.
|
||||
3. **Expected**: `supporthub_http_request_duration_seconds_count` has observations labeled
|
||||
`route="/auth/login"` with both `status_code="200"` and `status_code="401"` present, letting
|
||||
an operator compute the error rate for that route from these two series alone.
|
||||
|
||||
## Scenario 3 — Cross-module trace (User Story 3)
|
||||
|
||||
1. With the API running in a mode where tracing exports to the console (no
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` configured), drive a request that escalates a ticket to a human
|
||||
and triggers automatic orchestration/assignment.
|
||||
2. **Expected**: console output shows a `ticket.create`-or-`ai.escalation` root span and an
|
||||
`orchestration.assignment` child span sharing the same trace ID, with the child's start time
|
||||
at or after the parent's.
|
||||
3. Stop the (nonexistent) collector / leave `OTEL_EXPORTER_OTLP_ENDPOINT` pointed at an
|
||||
unreachable address.
|
||||
4. **Expected**: the API still starts and serves requests normally; only a logged export-failure
|
||||
warning appears, nothing surfaces to any HTTP response.
|
||||
|
||||
## Scenario 4 — Business-health metrics (User Story 4)
|
||||
|
||||
For each metric, scrape `/metrics`, note the current value, drive the real event, scrape again,
|
||||
and confirm the expected series moved by exactly one (or by the expected duration observation):
|
||||
|
||||
1. Complete an AI session without escalating → `supporthub_ai_session_outcomes_total{outcome="resolved"}` +1.
|
||||
2. Complete an AI session that escalates, then have a human agent resolve the ticket →
|
||||
`supporthub_ai_session_outcomes_total{outcome="escalated"}` +1, and once resolved,
|
||||
`supporthub_ticket_resolutions_total{resolved_by="human"}` +1.
|
||||
3. Resolve any ticket → `supporthub_ticket_resolution_duration_seconds` gains one new observation.
|
||||
4. Post the first agent reply on a ticket → `supporthub_ticket_first_response_duration_seconds`
|
||||
gains one new observation.
|
||||
5. Let an SLA run complete on time, and separately let one breach (via the existing breach-sweep
|
||||
test helper) → `supporthub_sla_run_outcomes_total{outcome="met"}` and
|
||||
`{outcome="breached"}` each +1 respectively.
|
||||
6. Trigger an escalation → `supporthub_escalations_total{reason="<the actual reason>"}` +1.
|
||||
7. Create a ticket for a categorized problem →
|
||||
`supporthub_problems_created_total{category_id="<id>"}` +1.
|
||||
8. Look up a valid error code's known issues →
|
||||
`supporthub_known_error_lookups_total{code="<code>"}` +1.
|
||||
9. Have the AI's `searchProductKnowledge` tool return zero results, then results →
|
||||
`supporthub_knowledge_retrieval_outcomes_total{matched="false"}` then `{matched="true"}`,
|
||||
each +1 in turn.
|
||||
10. Have any AI tool invocation fail → `supporthub_tool_invocations_total{tool="<name>",
|
||||
outcome="failed"}` +1.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All four scenarios pass against a real Postgres/Redis, `/health*` and the existing
|
||||
`supporthub_http_request_duration_seconds` metric's shape are unchanged for any existing
|
||||
consumer, and the API starts and serves traffic normally with no tracing collector configured.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Research: Full Observability
|
||||
|
||||
All decisions below were made against the actual current code (grep/read), not assumption —
|
||||
several existing pieces (the histogram, `getTracer()`) are dead scaffolding that looked complete
|
||||
from their exports alone but do nothing today.
|
||||
|
||||
## 1. Per-request access log
|
||||
|
||||
**Decision**: Add an `onResponse` hook (Fastify fires this for every completed response,
|
||||
including 404s and early replies from other hooks like the rate limiter, satisfying the FR-001
|
||||
edge case) that logs one line via the existing `logger` singleton: `{method, route, statusCode,
|
||||
durationMs, requestId, correlationId}`. `route` uses `request.routeOptions.url` (the
|
||||
parameterized pattern, e.g. `/tickets/:id`) rather than `request.url`, to keep label/log
|
||||
cardinality bounded — the raw URL contains IDs. `reply.elapsedTime` (Fastify's own built-in
|
||||
per-request timer) supplies duration with no manual `Date.now()` bookkeeping.
|
||||
|
||||
**Why not Fastify's built-in request logger**: `app.ts` deliberately sets `logger: false` and
|
||||
routes all logging through the shared Pino `logger` singleton (see its own comment: "Managed
|
||||
centrally via Pino logger instance"). Re-enabling Fastify's built-in logger would mean two
|
||||
independent logging paths with two different configurations; a hook that calls the existing
|
||||
singleton keeps one path.
|
||||
|
||||
**Where**: `request-context.plugin.ts` already owns the per-request lifecycle (it's the one
|
||||
place with an `onRequest` hook establishing `reqContext`) — its `onResponse` counterpart is
|
||||
added in the same file, not a new plugin, so request-lifecycle logging concerns stay together.
|
||||
|
||||
## 2. Attaching request ID/correlation ID to every log line (FR-002)
|
||||
|
||||
**Decision**: `AsyncLocalStorage<RequestContext>`, populated in the same `onRequest` hook that
|
||||
already builds `reqContext`, combined with Pino's `mixin` option (a function called for every
|
||||
log line, merging its return value into that line) reading from the store. This makes every
|
||||
call through the existing shared `logger` singleton automatically carry `requestId`/
|
||||
`correlationId` with **zero changes to any existing call site** — dozens of `logger.info/warn/
|
||||
error(...)` calls across every module already pass ad hoc fields but not always `requestId`
|
||||
consistently.
|
||||
|
||||
**Why not `request.log`**: Fastify's per-request child logger (`request.log`) is the standard
|
||||
Fastify idiom for this, but it would require passing `request` (or `request.log`) into every
|
||||
service/repository/mapper that currently imports the plain `logger` singleton directly — a
|
||||
sweeping, high-risk refactor across nearly every module for a feature whose whole point is
|
||||
*reducing* risk. The ALS+mixin approach reaches the same outcome (every log line correlated)
|
||||
without touching a single existing call site.
|
||||
|
||||
**Merge order**: Pino applies `mixin()`'s fields before merging the call's own object, so an
|
||||
explicit `requestId` passed at a call site (several already do this manually, e.g.
|
||||
`app.ts`'s error handler) still wins — no behavior change for those call sites, just now
|
||||
redundant (harmless).
|
||||
|
||||
## 3. Request-duration histogram + request-count
|
||||
|
||||
**Decision**: `httpRequestDurationHistogram.observe({method, route, status_code},
|
||||
reply.elapsedTime / 1000)` in the same `onResponse` hook. Prometheus histograms automatically
|
||||
expose a `<name>_count` and `<name>_sum` per label combination — FR-004's "compute error rate
|
||||
per route/status" is satisfied by that built-in output; no separate counter metric is added, to
|
||||
avoid two metrics tracking overlapping information.
|
||||
|
||||
## 4. Distributed tracing
|
||||
|
||||
**Decision**: Initialize a real `BasicTracerProvider` (from the already-installed
|
||||
`@opentelemetry/sdk-trace-base` — no new dependency for the SDK itself) at process start, with
|
||||
`trace.setGlobalTracerProvider(...)` so the existing, previously-inert `getTracer()` helper
|
||||
starts returning a working tracer with zero change to its own signature. Exporter selection is
|
||||
config-driven (`OTEL_EXPORTER_OTLP_ENDPOINT`, following the OpenTelemetry project's own standard
|
||||
env var name rather than inventing a new one):
|
||||
|
||||
- Set → `OTLPTraceExporter` (new dependency: `@opentelemetry/exporter-trace-otlp-http`, the
|
||||
lighter HTTP/JSON variant, avoiding the gRPC exporter's heavier dependency footprint), wrapped
|
||||
in a `BatchSpanProcessor`.
|
||||
- Unset (local dev, and any environment that hasn't configured a collector) →
|
||||
`ConsoleSpanExporter` (part of `sdk-trace-base`, zero extra dependency) wrapped in a
|
||||
`SimpleSpanProcessor`, so spans are visible immediately without standing up a collector.
|
||||
- Test environment → `InMemorySpanExporter` (also part of `sdk-trace-base`, built specifically
|
||||
for tests) wrapped in a `SimpleSpanProcessor` — this lets integration tests assert on real,
|
||||
actually-exported span data (names, parent/child nesting, attributes) with a real
|
||||
`TracerProvider` doing real work, the only substitution is *where the spans end up*, the same
|
||||
"real infrastructure, substitute only the destination" pattern already used for Pino's
|
||||
transport (`pino-pretty` in development, plain JSON otherwise).
|
||||
|
||||
**New dependencies**: `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/resources` (for
|
||||
a `service.name: supporthub-api` resource attribute — without it, every span is anonymous in
|
||||
whatever backend receives them).
|
||||
|
||||
**Graceful degradation (FR-007)**: `BatchSpanProcessor`'s own export failures are caught and
|
||||
logged by the OpenTelemetry SDK internally (it never throws into application code); nothing in
|
||||
this feature needs to add its own try/catch around span creation for this to hold, but the SDK's
|
||||
internal diagnostic logger is wired to `logger.warn` (via `diag.setLogger`) so export failures
|
||||
are visible in this project's own log stream rather than swallowed silently.
|
||||
|
||||
**Where spans are added (FR-006)**: two entry points, wrapping already-existing method calls
|
||||
rather than restructuring them:
|
||||
- `ai-support/sessions/service/session.service.ts`'s escalation path — a span around the call
|
||||
that ultimately triggers `orchestrationService.handleHumanEscalation` (via the
|
||||
`TICKET_UPDATED` → `HUMAN_ESCALATION` domain-event subscriber in
|
||||
`src/events/handlers/index.ts`), and a child span inside
|
||||
`orchestration/orchestration`'s and `orchestration/assignments`'s own handling — showing the
|
||||
AI-diagnosis → escalation → assignment path as one connected trace.
|
||||
- `ticketing/tickets/service/tickets.service.ts`'s ticket-creation method — a root span for
|
||||
ticket intake, with the domain-event-driven downstream reactions (SLA-run creation, etc.)
|
||||
as child spans, per FR-006's second named path.
|
||||
|
||||
Trace context propagates across the event-bus's synchronous `await eventBus.publish(...)` calls
|
||||
for free (both publisher and subscriber run within the same Node async-context chain the OTel
|
||||
context API rides on — no manual context passing needed, since nothing here crosses a process/
|
||||
queue boundary; BullMQ jobs are explicitly out of scope for this feature's two named paths).
|
||||
|
||||
## 5. The eleven named business-health metrics (FR-008) — instrumentation points
|
||||
|
||||
Each is a `prom-client` `Counter` or `Histogram`, registered once in
|
||||
`infrastructure/observability/metrics.ts` alongside the existing histogram, and incremented/
|
||||
observed at one single already-existing choke point per metric — chosen specifically to avoid
|
||||
scattering an instrumentation call across every one of a metric's several call sites.
|
||||
|
||||
| # | Metric | Type | Hook point (file : method) | Label(s) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | AI resolution rate | Counter | `ai-support/sessions/repository/session.repository.ts` : `updateStatus`, when `status === 'resolved'` | — |
|
||||
| 2 | AI escalation rate | Counter | same method, when `status === 'escalated'` | — |
|
||||
| 3 | Human resolution rate | Counter | new `TICKET_UPDATED` subscriber (`events/handlers/index.ts`) on `newStatus === 'RESOLVED'`, looking up `resolutionRepository.findByTicketId` for `resolvedBy` | `resolvedBy !== 'ai'` only |
|
||||
| 4 | Average resolution time | Histogram | same subscriber — observes `resolvedAt - ticket.createdAt` | — |
|
||||
| 5 | First response time | Histogram | `ticketing/messages/service/messages.service.ts` : `post`, when `type === 'AGENT_MESSAGE'` and no prior `AGENT_MESSAGE` exists for the ticket | — |
|
||||
| 6 | SLA compliance | Counter | `orchestration/sla/service/sla.service.ts` : `complete` (outcome `met`, only if the run wasn't already `breached`) and `runBreachDetectionSweep` (outcome `breached`) | `outcome` |
|
||||
| 7 | Escalation rate | Counter | new `ESCALATION_TRIGGERED` subscriber (`events/handlers/index.ts`) — this event is already published unconditionally on every escalation (`escalation.service.ts`) but "for audit, not for logic" (its own comment) and has zero subscribers today | `reason` |
|
||||
| 8 | Recurring problems | Counter | `ticketing/tickets/service/tickets.service.ts` — the ticket-creation method's existing `problemsRepo.create(...)` call | `categoryId` (or `uncategorized`) |
|
||||
| 9 | Most common errors | Counter | `ai-support/knowledge/service/error-codes.service.ts` : `findKnownIssuesByErrorCode`, after a valid code is confirmed to exist | `code` |
|
||||
| 10 | Knowledge effectiveness | Counter | `ai-support/tools/service/tools.service.ts`'s single `executeTool(...)` call site, when `block.name === 'searchProductKnowledge'` | `matched` (results non-empty vs empty) |
|
||||
| 11 | Tool failure rate | Counter | same call site, every tool invocation | `tool`, `outcome` |
|
||||
|
||||
**Why the event bus for #3, #4, #7 instead of editing `resolutions.service.ts`/
|
||||
`escalation.service.ts` directly**: those two modules' domain events (`TICKET_UPDATED` with
|
||||
`newStatus`, and `ESCALATION_TRIGGERED`) are already published unconditionally for every
|
||||
relevant transition (confirmed by reading `tickets.service.ts` and `escalation.service.ts`
|
||||
directly) specifically so that a new concern reacting to "a ticket resolved" or "an escalation
|
||||
happened" never needs to modify the module that owns the transition — the exact precedent
|
||||
`src/events/handlers/index.ts`'s existing four subscribers already establish for 005/007/008.
|
||||
Metrics is exactly this kind of concern.
|
||||
|
||||
**Why the repository layer for #1/#2 instead of the event bus**: AI-session resolved/escalated
|
||||
is not currently published as a domain event at all (only ticket-level and escalation-level
|
||||
events exist) and `session.service.ts` calls `this.sessions.updateStatus(...)` from ten
|
||||
different branches — adding a domain-event publish there to reuse the event-bus pattern would
|
||||
mean either introducing a new event type used by exactly one subscriber (this feature) or
|
||||
touching all ten call sites to route through a new shared wrapper. Instrumenting the one
|
||||
repository method both approaches would have to fire through instead is the minimal, lowest-risk
|
||||
option. This mirrors how `logger` calls already appear directly inside repository/service code
|
||||
throughout this codebase (e.g. `tool-executor.ts`'s `logger.error`) — observability calls are
|
||||
already treated as a cross-cutting concern usable from any layer, not something Constitution
|
||||
Principle III's "repository is Prisma-only" rule was written to police (that rule targets
|
||||
business-logic leakage and direct Prisma access from the wrong layer, not a metrics increment
|
||||
alongside an existing Prisma call).
|
||||
|
||||
**A pre-existing correctness note surfaced while researching #6**: `sla.service.ts`'s
|
||||
`complete()` only skips its update when the run is *already* `'completed'` — not when it is
|
||||
`'breached'` — so a run that breached and then later resolved would have its `status`
|
||||
overwritten from `'breached'` back to `'completed'` in the database, silently losing the breach
|
||||
record. This is a pre-existing 008/012 behavior, not something this feature changes (the SLA
|
||||
run's persisted status is out of scope for an observability feature) — the metric itself reads
|
||||
`run.status` *before* calling `complete()`'s own update, so the metric is accurate (correctly
|
||||
counted as `breached`, never double-counted as `met`) regardless of this separate, pre-existing
|
||||
data-quality gap. Documented in this feature's own checklist Notes as a discovered issue for a
|
||||
future fix, the same way 013-auth-hardening documented the `orchestration-strategies.test.ts`
|
||||
bug it found without fixing it.
|
||||
|
||||
## 6. Test strategy for the eleven metrics and tracing
|
||||
|
||||
**Decision**: Integration tests scrape the real `/metrics` endpoint's text output (a real
|
||||
`app.inject({method: 'GET', url: '/metrics'})` call, no mocking) before and after driving the
|
||||
real underlying event through the real API (create a ticket, resolve an AI session, trigger an
|
||||
escalation, etc. — exactly as every prior feature's integration suite already does against real
|
||||
Postgres/Redis), asserting the specific metric line's value increased by the expected amount.
|
||||
Tracing is verified by reading back spans from the `InMemorySpanExporter` (test-environment
|
||||
exporter, per §4) after a real cross-module request, asserting span names and parent/child
|
||||
`spanId`/`parentSpanId` relationships — a real trace, produced by a real `TracerProvider`, just
|
||||
captured in memory instead of shipped to a collector.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Feature Specification: Full Observability
|
||||
|
||||
**Feature Branch**: `014-full-observability`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Full observability: wire the already-scaffolded logging, metrics, and tracing infrastructure into an actually working end-to-end observability layer — structured per-request access logs, a working request-duration histogram, real OpenTelemetry tracing with exported spans across critical request paths, and live Prometheus counters for the key operational metrics named in docs/09-testing-observability-cicd.md."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trace one request end to end from its logs (Priority: P1)
|
||||
|
||||
An engineer investigating a production incident (a customer's ticket got stuck, an API call failed) needs to reconstruct exactly what the system did for that one request: which route was hit, how long it took, what it returned, and — because a support case touches many internal calls (AI session → tool calls → escalation → assignment → SLA events) — which of those internal log lines belong to the same originating request.
|
||||
|
||||
**Why this priority**: Without a per-request access log, there is currently no record that a given request even happened unless it errored. This is the minimum viable observability floor everything else builds on.
|
||||
|
||||
**Independent Test**: Can be fully tested by sending a request to any route and confirming exactly one structured access-log line is emitted for it, carrying the same request ID as any other log line produced while handling that request.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is running, **When** any HTTP request completes (success or failure), **Then** exactly one structured log line is emitted recording its method, route, status code, and duration.
|
||||
2. **Given** a request carries an inbound correlation ID header (or one is generated for it), **When** that request triggers further log lines anywhere in the codebase during its handling, **Then** every one of those log lines carries the same request ID and correlation ID as the access-log line for that request.
|
||||
3. **Given** a request fails with an unhandled error, **When** the access log line is emitted, **Then** it is distinguishable (by log level) from a successful request without needing to duplicate the existing error-handler logging.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See live request-health metrics (Priority: P1)
|
||||
|
||||
An engineer wants to know, right now, whether the API is healthy under current traffic — request volume, latency distribution, and error rate by route — without needing to grep logs.
|
||||
|
||||
**Why this priority**: A request-duration metric already exists in code but is never recorded, so `/metrics` currently reports nothing useful about request health. This is the second half of the observability floor (logs tell you what happened to one request; metrics tell you the shape of all of them).
|
||||
|
||||
**Independent Test**: Can be fully tested by sending a mix of successful and failing requests, then scraping `/metrics` and confirming the request-duration histogram and a request-count-by-status metric both reflect that traffic.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API has served requests since it started, **When** `/metrics` is scraped, **Then** the request-duration histogram has observations labeled by method, route, and status code matching that traffic.
|
||||
2. **Given** some requests succeeded and others returned 4xx/5xx, **When** `/metrics` is scraped, **Then** a request-count metric lets an operator compute error rate by route and status class.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Trace a single incident's cross-module path (Priority: P2)
|
||||
|
||||
An engineer debugging why a specific ticket took an unexpectedly long or unexpected path (e.g., AI failed to resolve it, escalation didn't fire when expected) wants to see the causal chain of operations across modules for that one ticket — not just isolated log lines, but a connected trace showing how long each step took relative to the others.
|
||||
|
||||
**Why this priority**: Distributed tracing infrastructure already exists in the dependency list and a `getTracer()` helper is exported, but no tracer provider is ever initialized and no code ever calls it — today it silently does nothing. This is more valuable than plain logs for understanding *why* a multi-step flow behaved the way it did, but the system is usable without it (User Stories 1-2 already restore basic visibility), so it is P2.
|
||||
|
||||
**Independent Test**: Can be fully tested by triggering a request that flows through at least two instrumented modules (e.g., an AI escalation that results in orchestration/assignment) and confirming a trace is produced whose spans are parented correctly and whose combined duration accounts for the modules involved.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tracing is enabled, **When** the API starts, **Then** a real tracer provider is active (not the OpenTelemetry no-op default) and spans created via the existing `getTracer()` helper are actually exported somewhere inspectable.
|
||||
2. **Given** a request flows through multiple instrumented operations (e.g., AI diagnosis triggers an escalation which triggers orchestration/assignment), **When** that request completes, **Then** the resulting trace shows each operation as a distinct, correctly-nested span under one root.
|
||||
3. **Given** tracing is not configured with an external collector in a given environment, **When** the API starts, **Then** it still starts successfully (tracing degrades gracefully, it never blocks startup or request handling).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - See the business-health metrics this project committed to tracking (Priority: P2)
|
||||
|
||||
An engineer or team lead wants live visibility (via the same `/metrics` endpoint, for consumption by whatever monitoring stack is deployed) into the operational health metrics this project's own design doc names as important: AI resolution rate, AI escalation rate, human resolution rate, average resolution time, first response time, SLA compliance, escalation rate, recurring problems, most common errors, knowledge effectiveness, and tool failure rate.
|
||||
|
||||
**Why this priority**: These are real, currently-invisible gaps — none of them are tracked anywhere today, live or otherwise. They are P2 (not P1) because they instrument business outcomes that already have a durable system of record (the ticket/problem/SLA/escalation tables) — a missing counter is a visibility gap, not a data-loss risk, unlike User Stories 1-2's request-level blind spot.
|
||||
|
||||
**Why this scope boundary**: This story is about each metric *existing and being live-updated correctly* at the point the underlying event occurs, exposed as raw counters/histograms on `/metrics` for an external monitoring stack to graph and alert on. It explicitly does NOT include building any dashboard, chart, or human-facing report — that is a separate, not-yet-started project phase (reporting/analytics dashboards).
|
||||
|
||||
**Independent Test**: Can be fully tested, metric by metric, by driving the real underlying event (resolve a ticket via AI, resolve one via a human agent, breach an SLA, trigger an escalation, log a known error, etc.) against a running instance and confirming the corresponding value on `/metrics` changed by exactly the expected amount.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an AI session resolves a ticket without escalating, **When** `/metrics` is scraped, **Then** the AI-resolution counter has incremented and the AI-escalation counter has not.
|
||||
2. **Given** an AI session escalates to a human and that human later resolves the ticket, **When** `/metrics` is scraped, **Then** the AI-escalation counter and the human-resolution counter have both incremented.
|
||||
3. **Given** a ticket is resolved, **When** `/metrics` is scraped, **Then** the resolution-time histogram has a new observation reflecting that ticket's actual open-to-resolved duration.
|
||||
4. **Given** an agent sends the first reply on a ticket, **When** `/metrics` is scraped, **Then** the first-response-time histogram has a new observation.
|
||||
5. **Given** an SLA run resolves as either met or breached, **When** `/metrics` is scraped, **Then** the SLA-compliance counter reflects that outcome.
|
||||
6. **Given** an escalation event fires, **When** `/metrics` is scraped, **Then** the escalation-rate counter increments, labeled by trigger reason.
|
||||
7. **Given** an AI tool invocation succeeds or fails, **When** `/metrics` is scraped, **Then** the tool-failure-rate counter reflects the outcome, labeled by tool name.
|
||||
8. **Given** a known error code is surfaced to a customer, **When** `/metrics` is scraped, **Then** a counter labeled by that error code has incremented (supports both "most common errors" and, via repeated occurrence on the same product/category, "recurring problems").
|
||||
9. **Given** the AI's knowledge retrieval step either does or does not find a usable match for the customer's problem, **When** `/metrics` is scraped, **Then** a knowledge-effectiveness counter reflects that outcome.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the configured tracing exporter/collector is unreachable? The API must still start and continue serving requests; span export failures must be logged but never surface to the request/response cycle.
|
||||
- What happens to in-flight metrics/traces if the process crashes before a scrape/export completes? Acceptable data loss for that window — this feature does not need to guarantee zero metric loss across a crash, only correctness of what is recorded and exported during normal operation.
|
||||
- What happens when a request has no matching route (404) or is rejected before reaching a handler (e.g., by a global rate limiter)? It must still produce exactly one access-log line and one metrics observation, so operators can see rejected traffic, not just successfully-routed traffic.
|
||||
- What happens when two requests share the same client-supplied correlation ID (e.g., a retried request)? Each still gets its own request ID and its own access-log line; only the correlation ID is shared, by design (that is what lets an operator group retries together).
|
||||
- How does the system behave for a route that legitimately never touches any of the business-event counters (e.g., a health check)? No business-metric line is expected for it — only the generic request-count/duration metrics from User Story 2 apply.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST emit exactly one structured access-log line per completed HTTP request (including requests that error, 404, or are rejected by a global hook before reaching a route handler), containing at minimum: HTTP method, route/path, response status code, duration, request ID, and correlation ID.
|
||||
- **FR-002**: System MUST attach the request ID and correlation ID already established by the existing request-context mechanism to every log line produced while handling that request, not only the access-log line.
|
||||
- **FR-003**: System MUST record every completed HTTP request's duration into the existing request-duration metric, labeled at minimum by method, route, and status code.
|
||||
- **FR-004**: System MUST expose a request-count metric (or equivalent derivable from FR-003's histogram) sufficient to compute error rate per route and status class.
|
||||
- **FR-005**: System MUST initialize a real distributed-tracing pipeline at startup so that spans created via the existing `getTracer()` helper are captured and exported to an inspectable destination, rather than discarded by the OpenTelemetry no-op default.
|
||||
- **FR-006**: System MUST create spans for the AI diagnosis → escalation → orchestration/assignment path and for the ticket-creation → orchestration path, correctly nested under one root span per originating request, so a single incident's cross-module timing is visible in one trace.
|
||||
- **FR-007**: System MUST continue to start up and serve requests normally if the configured tracing export destination is unreachable; export failures MUST be logged, never raised to the request/response cycle.
|
||||
- **FR-008**: System MUST expose live counters/histograms on the existing `/metrics` endpoint for each of: AI resolution rate, AI escalation rate, human resolution rate, average resolution time, first response time, SLA compliance, escalation rate, recurring problems, most common errors, knowledge effectiveness, and tool failure rate — each updated at the moment its underlying real event occurs (not computed by a batch job or exposed through any new endpoint).
|
||||
- **FR-009**: System MUST NOT introduce any new human-facing dashboard, chart, or reporting API as part of this feature — every metric from FR-008 is a raw, unaggregated-by-this-system counter/histogram intended for an external monitoring stack to graph, in keeping with the explicit scope boundary against the separate reporting/analytics dashboards work.
|
||||
- **FR-010**: Existing `/health`, `/health/live`, `/health/ready`, and `/metrics` endpoints MUST continue to function unchanged in shape for any existing consumer.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Access log line**: A structured log record emitted once per completed HTTP request; not a persisted database entity — it exists only in the log stream.
|
||||
- **Request-duration metric**: A histogram, keyed by method/route/status, recording how long each request took.
|
||||
- **Trace / span**: A record of one operation's start/end time and its parent-child relationship to other operations within the same originating request, exported to wherever tracing is configured to send it.
|
||||
- **Business-event counter**: One of the eleven named live metrics in FR-008/User Story 4, each incremented (or observed, for the two duration-based ones) at the exact point its real-world event already occurs elsewhere in the system (ticket resolution, SLA run completion, escalation firing, tool invocation, etc.) — this feature adds the instrumentation call at each of those existing points, it does not change what those points do.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Given any request made to the running API, an operator can identify, from logs alone, its method, route, outcome, duration, and every other log line produced while handling it, within seconds of it happening.
|
||||
- **SC-002**: An operator watching `/metrics` can determine current request error rate and latency distribution per route without needing to read application logs.
|
||||
- **SC-003**: An operator can find and inspect the complete cross-module trace for a specific incident that touched at least two instrumented modules, showing correctly-attributed timing per module.
|
||||
- **SC-004**: All eleven named business-health metrics are visible on `/metrics` and each one's value changes correctly and immediately in response to its real underlying event, verified against real (non-mocked) system behavior.
|
||||
- **SC-005**: Enabling this feature's tracing pipeline introduces no observable request-handling failure, and the API starts and serves traffic normally even when the tracing destination is unreachable.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Exported to an inspectable destination" (FR-005) means a destination this project's own test/dev environment can actually verify against — an OTLP-compatible collector endpoint in production-like environments, and an in-process/console exporter for local development and automated tests, both driven by configuration rather than hardcoded per environment. No specific commercial tracing backend (e.g., Jaeger, Honeycomb, Datadog) is mandated by this feature; wiring a specific backend in a given deployment is an operations concern outside this spec.
|
||||
- The existing Prometheus (`prom-client`) and Pino stack are the metrics/logging technology already chosen for this project (confirmed by existing code) and are reused rather than replaced.
|
||||
- "Knowledge effectiveness" is scoped to whether the AI's knowledge-retrieval step found and used a matching entry for a given diagnosis attempt (a binary outcome per attempt), not a more elaborate relevance-scoring scheme — no such scoring exists elsewhere in the system to build on.
|
||||
- "Recurring problems" and "most common errors" (FR-008) are satisfied by labeled counters an operator's monitoring stack can rank/aggregate over any time window (e.g., `topk` in PromQL) — this feature does not need to compute or store a "top N" itself, consistent with FR-009's boundary against building reporting logic.
|
||||
- This feature is backend-only (`supporthub-api`); no `supporthub-web` changes are in scope, since nothing here is presented to any human through a UI.
|
||||
- Existing `RequestContext` (`requestId`/`correlationId`), already populated by both the customer and staff auth paths (010-identity-auth), is reused as the identifier scheme for FR-001/FR-002 rather than introducing a second identifier scheme.
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
description: 'Task list for 014-full-observability'
|
||||
---
|
||||
|
||||
# Tasks: Full Observability
|
||||
|
||||
**Input**: Design documents from `specs/014-full-observability/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/metrics-contract.md](./contracts/metrics-contract.md), [quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 access log, US2 = P1 request-health
|
||||
metrics, US3 = P2 tracing, US4 = P2 business-health metrics). US2 shares its hook point with
|
||||
US1 (both live in the same `onResponse` hook) so US2 depends on US1's hook existing, not on its
|
||||
own separate one. US3 and US4 are each independent of US1/US2 and of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `@opentelemetry/exporter-trace-otlp-http` and `@opentelemetry/resources` to
|
||||
`package.json` (`npm install`)
|
||||
- [x] T002 Add `src/infrastructure/observability/request-context.store.ts` — a module-level
|
||||
`AsyncLocalStorage<{requestId: string; correlationId: string}>` with a `run()` passthrough
|
||||
and a `getStore()` re-export
|
||||
- [x] T003 [P] Wire `logger.ts`'s Pino options with a `mixin` function reading from T002's store
|
||||
(returns `{}` when no store is active — a log call outside any request, e.g. at startup,
|
||||
must not throw) (depends on T002)
|
||||
|
||||
**Checkpoint**: Every subsequent log call through the shared `logger` singleton is
|
||||
request-correlated automatically, once a request actually runs inside the store (US1 wires that
|
||||
part next).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Trace one request end to end from its logs (Priority: P1)
|
||||
|
||||
**Goal**: One structured access-log line per request; every other log line produced during that
|
||||
request's handling shares its request ID.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T004 [P] [US1] Unit test: a `logger.info(...)` call made inside T002's `store.run(...)`
|
||||
carries `requestId`/`correlationId` in its output; one made outside carries neither, in
|
||||
`tests/unit/observability/request-context-mixin.test.ts` (depends on T003)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T005 [US1] In `plugins/request-context.plugin.ts`'s existing `onRequest` hook, after
|
||||
building `request.reqContext`, call the T002 store's `run()` wrapping the remainder of the
|
||||
request's handling (Fastify's `onRequest` hooks accept a `done` callback / return a
|
||||
promise — the run wraps whichever style this hook currently uses) so every subsequent
|
||||
hook/handler for this request executes inside the ALS context (depends on T002)
|
||||
- [x] T006 [US1] Add an `onResponse` hook (same plugin) that logs one line via the shared
|
||||
`logger`: `{event: "http_request_completed", method, route: request.routeOptions.url,
|
||||
statusCode: reply.statusCode, durationMs: reply.elapsedTime}`, at `info`/`warn`/`error`
|
||||
level by status class (depends on T005)
|
||||
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (one access-log line per
|
||||
request incl. 404; shared requestId across the access-log line and an internal log line
|
||||
from the same request) in `tests/integration/observability/access-log.test.ts`, using a
|
||||
`logger.info`/`logger.warn` spy the same way `password-reset-flow.test.ts` (013) already
|
||||
does (depends on T006)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes. Every request is now visible in logs even when it
|
||||
never errors.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - See live request-health metrics (Priority: P1)
|
||||
|
||||
**Goal**: The existing (previously dead) request-duration histogram actually has observations;
|
||||
error rate per route/status is computable from `/metrics` alone.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T008 [US2] In the same `onResponse` hook added by T006, call
|
||||
`httpRequestDurationHistogram.observe({method, route: request.routeOptions.url, status_code:
|
||||
String(reply.statusCode)}, reply.elapsedTime / 1000)` (depends on T006)
|
||||
- [x] T009 [US2] Integration test covering Quickstart Scenario 2 (send a mix of successful/
|
||||
failing requests to the same route, scrape `/metrics`, assert both status-code label
|
||||
values are present with the expected counts) in
|
||||
`tests/integration/observability/request-metrics.test.ts` (depends on T008)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes. `/metrics` now reflects real request traffic.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Trace a single incident's cross-module path (Priority: P2)
|
||||
|
||||
**Goal**: A real `TracerProvider` is active; `getTracer()` produces spans that are actually
|
||||
exported; two named cross-module paths are instrumented.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T010 [P] [US3] Rewrite `infrastructure/observability/tracing.ts` to initialize a
|
||||
`BasicTracerProvider` at module load with a `Resource` (`service.name: "supporthub-api"`)
|
||||
and register it via `trace.setGlobalTracerProvider(...)`; exporter/processor chosen by
|
||||
`NODE_ENV`/`OTEL_EXPORTER_OTLP_ENDPOINT` per research.md §4 (`InMemorySpanExporter` +
|
||||
`SimpleSpanProcessor` in test, `OTLPTraceExporter` + `BatchSpanProcessor` when the env var
|
||||
is set, `ConsoleSpanExporter` + `SimpleSpanProcessor` otherwise); export a
|
||||
`getTestSpanExporter()` accessor (test env only) for T015 to read exported spans back;
|
||||
`getTracer()`'s own exported signature is unchanged (depends on T001)
|
||||
- [x] T011 [US3] Wire the OpenTelemetry SDK's internal diagnostic logger
|
||||
(`diag.setLogger(...)`) to the shared `logger.warn`, so span-export failures land in this
|
||||
project's own log stream instead of stderr or nowhere (depends on T010)
|
||||
- [x] T012 [P] [US3] Add a `ticket.create` span (`getTracer().startActiveSpan(...)`) around
|
||||
`tickets/service/tickets.service.ts`'s ticket-creation method, with `ticket.id` and
|
||||
`product.externalProductId` attributes, ended in a `finally` (depends on T010)
|
||||
- [x] T013 [P] [US3] Add an `ai.escalation` span around `ai-support/sessions/service/
|
||||
session.service.ts`'s escalation branch(es), with `ticket.id`/`session.id` attributes
|
||||
(depends on T010)
|
||||
- [x] T014 [US3] Add an `orchestration.assignment` span wrapping the existing
|
||||
`orchestrationService.handleHumanEscalation` call in the `TICKET_UPDATED`/
|
||||
`HUMAN_ESCALATION` subscriber (`src/events/handlers/index.ts`), with `ticket.id`/
|
||||
`strategy` attributes, so it nests under T013's span when both occur in the same request
|
||||
(depends on T010, T013)
|
||||
- [x] T015 [US3] Integration test covering Quickstart Scenario 3 steps 1-2: drive a real
|
||||
ticket-creation → escalation → orchestration/assignment flow, read spans back via T010's
|
||||
`getTestSpanExporter()`, assert `ticket.create`/`ai.escalation`/`orchestration.assignment`
|
||||
all share one trace ID with correct parent/child `spanId` relationships, in
|
||||
`tests/integration/observability/tracing.test.ts` (depends on T012, T013, T014)
|
||||
- [x] T016 [US3] Integration test covering Quickstart Scenario 3 steps 3-4: point
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` at an unreachable address, confirm `buildApp()` still
|
||||
resolves and a request still completes successfully, in the same test file (depends on
|
||||
T010)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes. A real, inspectable trace exists for the first
|
||||
time; tracing failure never blocks the app.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - See the business-health metrics this project committed to tracking (Priority: P2)
|
||||
|
||||
**Goal**: All eleven named metrics (data-model.md) are live on `/metrics`, each updated at the
|
||||
exact real event research.md identified.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T017 [US4] Define all eleven new `Counter`/`Histogram` objects in
|
||||
`infrastructure/observability/metrics.ts` per data-model.md's table, exported individually
|
||||
(depends on T001)
|
||||
- [x] T018 [P] [US4] Increment `supporthub_ai_session_outcomes_total` in `ai-support/sessions/
|
||||
repository/session.repository.ts`'s `updateStatus`, labeled `outcome` when `status` is
|
||||
`'resolved'`/`'escalated'` (depends on T017)
|
||||
- [x] T019 [P] [US4] Add a `TICKET_UPDATED`/`newStatus === 'RESOLVED'` subscriber in
|
||||
`src/events/handlers/index.ts` that looks up `resolutionRepository.findByTicketId`,
|
||||
increments `supporthub_ticket_resolutions_total{resolved_by}` (`ai` vs. any other value),
|
||||
fetches the ticket for `createdAt`, and observes
|
||||
`supporthub_ticket_resolution_duration_seconds` (depends on T017)
|
||||
- [x] T020 [P] [US4] In `ticketing/messages/service/messages.service.ts`'s `post`, when
|
||||
`type === 'AGENT_MESSAGE'`, check for a prior `AGENT_MESSAGE` on the ticket and — only for
|
||||
the first one — observe `supporthub_ticket_first_response_duration_seconds` against the
|
||||
ticket's `createdAt` (depends on T017)
|
||||
- [x] T021 [P] [US4] In `orchestration/sla/service/sla.service.ts`: in `complete()`, read
|
||||
`run.status` before updating and increment `supporthub_sla_run_outcomes_total{outcome:
|
||||
"met"}` only if it was not already `'breached'`; in `runBreachDetectionSweep()`, increment
|
||||
`{outcome: "breached"}` for each newly-flagged run (depends on T017)
|
||||
- [x] T022 [P] [US4] Add an `ESCALATION_TRIGGERED` subscriber in `src/events/handlers/index.ts`
|
||||
that increments `supporthub_escalations_total{reason}` from the event payload's `reason`
|
||||
(depends on T017)
|
||||
- [x] T023 [P] [US4] In `ticketing/tickets/service/tickets.service.ts`'s ticket-creation method,
|
||||
increment `supporthub_problems_created_total{category_id}` right after `problemsRepo.create`
|
||||
succeeds (`categoryId ?? 'uncategorized'`) (depends on T017)
|
||||
- [x] T024 [P] [US4] In `ai-support/knowledge/service/error-codes.service.ts`'s
|
||||
`findKnownIssuesByErrorCode`, increment `supporthub_known_error_lookups_total{code}` once
|
||||
the error code is confirmed to exist (after the `NotFoundError` branch, not before)
|
||||
(depends on T017)
|
||||
- [x] T025 [P] [US4] In `ai-support/tools/service/tools.service.ts`'s single `executeTool(...)`
|
||||
call site: increment `supporthub_tool_invocations_total{tool, outcome}` for every call, and
|
||||
— only when `block.name === 'searchProductKnowledge'` — increment
|
||||
`supporthub_knowledge_retrieval_outcomes_total{matched}` from whether `result.output` is a
|
||||
non-empty array (depends on T017)
|
||||
- [x] T026 [US4] Unit test: `sla.service.ts`'s `complete()` does not increment the `met` outcome
|
||||
for a run already `'breached'` (a fake repo returning `status: 'breached'`) in
|
||||
`tests/unit/observability/sla-compliance-metric.test.ts` (depends on T021)
|
||||
- [x] T027 [US4] Integration test covering Quickstart Scenario 4 (all ten sub-scenarios — the
|
||||
eleventh, tool-failure, is covered by the same test file's tool-invocation case) —
|
||||
scrape `/metrics` before/after driving each real event through the real API, in
|
||||
`tests/integration/observability/business-metrics.test.ts` (depends on T018, T019, T020,
|
||||
T021, T022, T023, T024, T025)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes. All eleven named metrics are live and correct
|
||||
against real infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T028 [P] Update `specs/014-full-observability/checklists/requirements.md` Notes with any
|
||||
implementation-time findings (including the pre-existing SLA-run status data-quality gap
|
||||
research.md §5 already surfaced)
|
||||
- [x] T029 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T030 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
(particularly every module touched by a single-line instrumentation addition: ai-support
|
||||
sessions/knowledge/tools, ticketing tickets/messages, orchestration/sla, and the event-bus
|
||||
handlers)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS User Story 1 (and transitively 2)
|
||||
- **User Story 1 (Phase 2)**: Depends on Foundational — BLOCKS User Story 2 (shares its hook)
|
||||
- **User Story 2 (Phase 3)**: Depends on User Story 1
|
||||
- **User Story 3 (Phase 4)**: Depends only on Foundational (T001) — independent of US1/US2/US4
|
||||
- **User Story 4 (Phase 5)**: Depends only on Foundational (T001/T017) — independent of
|
||||
US1/US2/US3
|
||||
- **Polish (Phase 5)**: Depends on all four user stories
|
||||
@@ -0,0 +1,94 @@
|
||||
# Specification Quality Checklist: Reporting and Analytics Dashboards
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-09
|
||||
**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
|
||||
|
||||
- This is `docs/10-implementation-roadmap.md`'s own Phase 11, third sub-area, per explicit user
|
||||
direction (013 was the security pass, 014 was full observability). Backend-first scope
|
||||
(Assumptions) follows the same pattern already established three times this session
|
||||
(010-identity-auth, 011-agent-ticket-queue, and 014-full-observability's own frontend-free
|
||||
scope) — a `supporthub-web` dashboard UI is a natural, separate follow-on, not re-litigated
|
||||
here via a fresh question.
|
||||
- The pre-scaffolded-but-inert `platform/reports` module (`ReportsService.generateSummaryReport`
|
||||
currently returns `{}`) and the `ANALYTICS` queue stub (`src/jobs/analytics`, logs only) were
|
||||
both confirmed via direct code inspection before writing this spec — the same
|
||||
"provisioned before this session's rebuild but never wired up" pattern found repeatedly this
|
||||
session. This feature wires up the former; the Assumptions section explicitly keeps the latter
|
||||
out of scope (synchronous queries, no pre-aggregation job, for this first cut).
|
||||
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
|
||||
required — every open question (default date window, SLA-risk threshold, top-N limit) had a
|
||||
reasonable, documented, CONFIGURABLE default (see Assumptions), matching the roadmap's own
|
||||
"never hardcode a placeholder value and ship it as final" instruction.
|
||||
|
||||
## Implementation Notes (post-build)
|
||||
|
||||
- Named the Product dashboard's own repository class `ProductReportRepository` (not
|
||||
`ProductRepository`) once it became clear resolving `externalProductId -> Product` should
|
||||
reuse `catalog/products`' own already-public `productsRepository.findByExternalProductId`
|
||||
rather than duplicating that lookup — avoids a name collision and keeps "one authority per
|
||||
concern" (Constitution Principle I's spirit) for product resolution.
|
||||
- `ManagementRepository` and `SupportRepository` both needed byte-identical
|
||||
first-response-duration and resolution-duration queries. Extracted into a shared
|
||||
`SharedReportRepository` both compose, rather than duplicating the Prisma query (or the
|
||||
averaging helper alone) twice — discovered while writing the second repository and seeing the
|
||||
copy-paste, not planned upfront in research.md.
|
||||
- "Top errors"/"most common errors" resolution-back-to-`code` logic moved into
|
||||
`ErrorCodesService.getTopErrorCodesForProduct` (a new method on the module that already owns
|
||||
`ErrorCode`), rather than the reports module reaching into `errorCodesRepository`/
|
||||
`errorCodeLookupRepository` directly — cleaner module-boundary ownership than research.md's
|
||||
original per-repository sketch implied.
|
||||
- The AI dashboard's "failed troubleshooting then escalated" figure (spec.md User Story 4) has
|
||||
no single stored flag anywhere in this codebase — `classifyStepOutcome`'s per-step verdicts are
|
||||
never persisted as their own durable record. Implemented as a documented proxy instead: an
|
||||
escalated session with `toolCallCount > 0` attempted troubleshooting before giving up, one with
|
||||
zero attempts escalated immediately. Documented directly in `ai.repository.ts`'s own code
|
||||
comment, the same "honest, documented simplification" precedent research.md §7 already set for
|
||||
the confidence-distribution bucketing.
|
||||
- Three of this module's public exports needed adding to their owning modules' top-level
|
||||
`index.ts` (not previously exposed): `decideConfidenceBand`/`ConfidenceBand` and
|
||||
`knowledgeReferenceRepository` from `ai-support/sessions`, matching the "extend an existing
|
||||
module's public surface for a later feature" precedent already used repeatedly this session
|
||||
(004's `productsRepository`, 009's `problemsRepository`).
|
||||
- Found a real regression during T028's full regression pass: `known-issues.test.ts` (004-
|
||||
product-knowledge, pre-existing) calls `findKnownIssuesByErrorCode` and its own `afterAll`
|
||||
deleted `ErrorCode` rows before this feature's new `ErrorCodeLookup` FK (RESTRICT) existed —
|
||||
once T004 started writing a lookup row on every call, that cleanup order started failing with
|
||||
an FK violation. Fixed by deleting `ErrorCodeLookup` rows first in that test's own `afterAll`.
|
||||
This feature's own new test files never delete `ErrorCode` rows at all, so they weren't
|
||||
affected the same way (leftover rows there are the same accepted throwaway-data tradeoff
|
||||
already established elsewhere this session).
|
||||
- Confirmed (not caused by this feature — the exact pre-existing issue 014-full-observability's
|
||||
own checklist already documented and root-caused via `git checkout` comparison) that this
|
||||
feature's own new integration test files, which also name their test products `TEST_*`,
|
||||
occasionally hit the same shared `deriveProductCode` "TEST" prefix collision under vitest's
|
||||
concurrent file execution when run alongside other `TEST_*`-prefixed files. Every dashboard
|
||||
test passes reliably run individually or in small groups; the intermittent 500 in a full
|
||||
combined run is the same known, out-of-scope, 003-ticketing concern.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Contract: Reporting API
|
||||
|
||||
All four routes require a valid staff session with role `ADMIN` (`requireRole('ADMIN')`), the
|
||||
same gate every admin-only surface uses since 010-identity-auth. All return the standard
|
||||
envelope: `{ success: true, data: <shape>, meta: null }` on success, `{ success: false, error:
|
||||
{code, message, details} }` on failure — no change to this codebase's existing response
|
||||
convention.
|
||||
|
||||
## `GET /admin/reports/management`
|
||||
|
||||
**Query**: `from?`, `to?` (ISO dates).
|
||||
|
||||
**200**: `ManagementDashboard` (data-model.md).
|
||||
|
||||
**400** `VALIDATION_ERROR`: `from` is after `to`.
|
||||
|
||||
**401/403**: missing/invalid session, or a non-`ADMIN` role.
|
||||
|
||||
## `GET /admin/reports/product/:externalProductId`
|
||||
|
||||
**Path**: `externalProductId` — the SaaS-facing product identifier (same convention every other
|
||||
admin product-scoped route already uses, e.g. `GET /admin/products/:externalProductId/knowledge`
|
||||
from 004-product-knowledge).
|
||||
|
||||
**Query**: `from?`, `to?`.
|
||||
|
||||
**200**: `ProductDashboard`.
|
||||
|
||||
**404** `NOT_FOUND`: no product with that `externalProductId` (FR-006 — never an empty-but-200
|
||||
response for an unknown product).
|
||||
|
||||
**400** `VALIDATION_ERROR`: `from` is after `to`.
|
||||
|
||||
## `GET /admin/reports/support`
|
||||
|
||||
**Query**: `from?`, `to?` (applies only to the performance figures — workload/SLA-risk/breached
|
||||
are always current, per data-model.md's `SupportDashboard.generatedAt`).
|
||||
|
||||
**200**: `SupportDashboard`.
|
||||
|
||||
## `GET /admin/reports/ai`
|
||||
|
||||
**Query**: `from?`, `to?`.
|
||||
|
||||
**200**: `AiDashboard`.
|
||||
|
||||
## Guarantees
|
||||
|
||||
1. Every rate/average field is `number | null` — `null` means no qualifying data existed in the
|
||||
requested range (FR-007). A consumer must never see `NaN` or a silently-substituted `0` for
|
||||
"no data."
|
||||
2. Every count field is a plain `number`, always present, `0` is a legitimate, meaningful value
|
||||
for a count (distinct from the `null`-for-no-data rule above, which applies only to
|
||||
rates/averages).
|
||||
3. `from`/`to` in every response echo the *resolved* range actually used (including the default,
|
||||
when omitted) — a caller never has to separately know what "the default" was.
|
||||
4. No route in this contract mutates any data — a repeated identical request returns the same
|
||||
shape (though not necessarily identical figures, since the underlying data can change between
|
||||
requests) with no side effect.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Data Model: Reporting and Analytics Dashboards
|
||||
|
||||
## New Prisma Model
|
||||
|
||||
### `ErrorCodeLookup`
|
||||
|
||||
Append-only audit record — see research.md §6 for why this is the one new table this feature
|
||||
needs.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `errorCodeId` | `String` | FK → `ErrorCode.id` |
|
||||
| `productId` | `String` | FK → `Product.id` — denormalized from `errorCode.productId` so the Product dashboard's range query never needs to join back through `ErrorCode` just to filter by product |
|
||||
| `createdAt` | `DateTime @default(now())` | |
|
||||
|
||||
Indexes: `@@index([productId, createdAt])` (the Product dashboard's own access pattern).
|
||||
|
||||
No `updatedAt`, no soft-delete, no unique constraint — every lookup is its own row, duplicates
|
||||
across time are the entire point (frequency is what "top errors" measures).
|
||||
|
||||
## Response Shapes (not persisted — computed per request)
|
||||
|
||||
### Management dashboard — `GET /admin/reports/management`
|
||||
|
||||
```ts
|
||||
interface ManagementDashboard {
|
||||
range: { from: string; to: string }; // ISO 8601, echoes the resolved (possibly defaulted) range
|
||||
totalCases: number;
|
||||
aiResolved: number;
|
||||
humanEscalated: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
slaCompliance: { met: number; breached: number; rate: number | null }; // rate = met / (met + breached)
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Product dashboard — `GET /admin/reports/product/:externalProductId`
|
||||
|
||||
```ts
|
||||
interface ProductDashboard {
|
||||
productId: string; // externalProductId, echoed back
|
||||
range: { from: string; to: string };
|
||||
supportVolume: number;
|
||||
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
|
||||
recurringProblems: Array<{ categoryId: string | null; count: number }>; // same data, top N, descending
|
||||
aiResolutionRate: number | null;
|
||||
humanEscalationRate: number | null;
|
||||
topErrors: Array<{ code: string; count: number }>; // top N, descending
|
||||
}
|
||||
```
|
||||
|
||||
### Support dashboard — `GET /admin/reports/support`
|
||||
|
||||
```ts
|
||||
interface SupportDashboard {
|
||||
generatedAt: string; // workload/risk are point-in-time, not range-scoped (research.md §2)
|
||||
range: { from: string; to: string }; // still applies to the performance figures below
|
||||
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
|
||||
slaAtRisk: number;
|
||||
slaBreached: number;
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### AI dashboard — `GET /admin/reports/ai`
|
||||
|
||||
```ts
|
||||
interface AiDashboard {
|
||||
range: { from: string; to: string };
|
||||
totalSessions: number;
|
||||
aiResolutionRate: number | null;
|
||||
humanHandoffRate: number | null;
|
||||
failedTroubleshootingEscalationRate: number | null;
|
||||
knowledgeMatchRate: number | null;
|
||||
confidenceDistribution: { proceed: number; ask: number; escalate: number };
|
||||
toolInvocations: { success: number; failed: number };
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters (all four routes)
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `from` | ISO date, optional | Defaults to `to - REPORTING_DEFAULT_WINDOW_DAYS` |
|
||||
| `to` | ISO date, optional | Defaults to now |
|
||||
|
||||
`from > to` is a 400 `VALIDATION_ERROR` (spec.md Edge Cases), not silently swapped.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Implementation Plan: Reporting and Analytics Dashboards
|
||||
|
||||
**Branch**: `015-reporting-dashboards` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/015-reporting-dashboards/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Wires the pre-scaffolded, unused `platform/reports` module into four real, admin-gated,
|
||||
read-only aggregation endpoints (Management, Product, Support, AI) matching
|
||||
`docs/09-testing-observability-cicd.md`'s own dashboard table — each computed synchronously,
|
||||
on request, directly from existing durable tables (Ticket, Problem, SLARun, EscalationEvent,
|
||||
AISupportSession, AIDiagnosis, AIAction, Resolution, Assignment). The one new piece of state is
|
||||
a small durable `ErrorCodeLookup` audit table, needed only because no existing record lets "top
|
||||
errors" be computed historically (014-full-observability's own equivalent is a process-lifetime
|
||||
Prometheus counter, unusable for a dated report). No presentation layer — see spec.md's
|
||||
Assumptions for why `supporthub-web` work is a separate follow-on.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — Prisma's own `groupBy`/`count`/`aggregate`/`findMany`, no
|
||||
raw SQL (research.md §5), reusing `decideConfidenceBand` (005-ai-support) and the
|
||||
`Resolution.resolvedBy` convention (014-full-observability) rather than reimplementing either.
|
||||
|
||||
**Storage**: One new table, `ErrorCodeLookup` (`id`, `errorCodeId` FK, `productId` FK,
|
||||
`createdAt`) — append-only, no update/delete path, indexed `(productId, createdAt)` for the
|
||||
Product dashboard's range-scoped ranking query. No change to any existing table.
|
||||
|
||||
**Testing**: Vitest — unit tests for the "no data → `null`, never `NaN`" averaging helper and the
|
||||
confidence-bucketing reuse; integration tests against real Postgres/Redis driving each
|
||||
dashboard's real underlying data (tickets in various terminal states, SLA runs met/breached,
|
||||
escalation events, AI sessions/diagnoses/actions, error-code lookups) and asserting every
|
||||
returned figure against hand-computed expected values — the same rigor and mixed
|
||||
HTTP-driven/direct-repository setup style as 014's `business-metrics.test.ts`.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Rewrites `platform/reports` (service,
|
||||
new controller, new routes, new schema for the date-range/product-id query params) from its
|
||||
current one-stub-method state into the real module. Adds one line to
|
||||
`ai-support/knowledge/service/error-codes.service.ts`'s existing `findKnownIssuesByErrorCode`
|
||||
(the same call site 014 already instrumented) to also write the new durable audit row.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Every dashboard query is bounded by the requested date range (default 30
|
||||
days, config) and, where a full-row fetch is needed for in-application averaging (research.md
|
||||
§5), only the two timestamp columns needed for that specific average — never a full-table scan
|
||||
with no range filter. Acceptable at current data volumes per spec.md's own Assumptions;
|
||||
pre-aggregation is explicitly deferred to if/when load testing (a separate, not-yet-started
|
||||
Phase 11 sub-area) shows it's actually needed.
|
||||
|
||||
**Constraints**: FR-006 — an unknown `productId` on the Product dashboard is a 404, never an
|
||||
empty-but-200 response. FR-007 — every rate/average is `number | null`, `null` meaning "no
|
||||
qualifying data," computed by checking the qualifying count before ever dividing. FR-008 — every
|
||||
route requires `requireRole('ADMIN')`, the same gate every admin surface uses since
|
||||
010-identity-auth.
|
||||
|
||||
**Scale/Scope**: Four new `GET` routes, one new Prisma model + migration, four new service
|
||||
methods (one per dashboard) replacing the single stub method, one new schema file for query-param
|
||||
validation, three new env-configured values (Constitution Principle II). No new module — this
|
||||
extends `platform/reports`, already the correct architectural home.
|
||||
|
||||
## 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 | Not applicable — no identity/access surface touched; every figure is derived from SupportHub's own domain data (tickets, problems, SLA, escalation, AI sessions), squarely inside SupportHub's own sole-authority domain per this principle's own second sentence. | PASS |
|
||||
| II. Configuration Over Hardcoding | The default reporting window, the SLA-risk threshold, and the top-N ranking limit are all new env-configured values (`REPORTING_DEFAULT_WINDOW_DAYS`, `REPORTING_SLA_RISK_THRESHOLD_MINUTES`, `REPORTING_TOP_N_LIMIT`), never hardcoded — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | All new code lives inside `platform/reports` (already its correct home) following Route → Schema → Controller → Service → Repository → Prisma; cross-module reads (tickets, AI support, orchestration, SLA/escalation, problem resolution) go through each owning module's own public `index.ts`, the same precedent every prior feature this session established (e.g. `tool-executor.ts` reading `ticketsService` from `@/modules/ticketing/tickets`). | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI tool-execution or decision logic changed; the AI dashboard only reports on outcomes the existing, already-deterministic confidence-band/tool-policy code already produced. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable — no resolution-recording logic changed. | PASS — N/A |
|
||||
| VI. Durable Audit & History | The one new table (`ErrorCodeLookup`) is itself an append-only audit record, directly in this principle's spirit — "which error codes came up, when" becomes durably answerable for the first time. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Not applicable — read-only aggregation queries, no job handlers, no assignment/SLA state mutated. | PASS — N/A |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Respected — the Product dashboard's problem-type breakdown queries `Problem` directly, never conflating it with `Ticket`. | PASS |
|
||||
| Technology & Platform Constraints | No new dependencies; one new Prisma model via the established non-interactive migration workflow (`prisma migrate diff` → hand-written `migration.sql` → `prisma migrate deploy`) this session has used for every prior schema change. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/015-reporting-dashboards/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── reports-api-contract.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # MODIFIED — new ErrorCodeLookup model
|
||||
│ └── migrations/
|
||||
│ └── <timestamp>_add_error_code_lookup/migration.sql # NEW
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── env.ts / reporting.ts (or similar) # MODIFIED — 3 new env-configured values
|
||||
│ └── modules/
|
||||
│ ├── platform/
|
||||
│ │ └── reports/ # REWRITTEN (was a 1-method stub)
|
||||
│ │ ├── controller/
|
||||
│ │ ├── mapper/ # date-range parsing/defaulting, averaging helper
|
||||
│ │ ├── repository/ # the 4 dashboards' Prisma queries
|
||||
│ │ ├── routes/
|
||||
│ │ ├── schema/ # query-param validation
|
||||
│ │ ├── service/
|
||||
│ │ └── index.ts
|
||||
│ └── ai-support/
|
||||
│ └── knowledge/
|
||||
│ ├── repository/ # MODIFIED — errorCodeLookupRepository
|
||||
│ └── service/
|
||||
│ └── error-codes.service.ts # MODIFIED — one new line at the existing
|
||||
│ lookup call site
|
||||
└── tests/
|
||||
├── unit/platform/reports/ # averaging/no-data-null helper, confidence
|
||||
│ bucketing reuse
|
||||
└── integration/platform-reports/ # all four dashboards against real data
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module — `platform/reports` already exists as the
|
||||
correct architectural home and simply needs its real implementation built out, following the
|
||||
same Route → Schema → Controller → Service → Repository → Prisma layering every other module
|
||||
already uses.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Quickstart: Reporting and Analytics Dashboards
|
||||
|
||||
Manual verification steps for each user story, against a running instance backed by real
|
||||
Postgres/Redis, logged in as an ADMIN.
|
||||
|
||||
## Scenario 1 — Management dashboard (User Story 1)
|
||||
|
||||
1. Create several tickets within a known date range: some reaching `AI_RESOLVED`/`RESOLVED` via
|
||||
an AI session, some escalated to a human and resolved via `resolutionsService.record`, some
|
||||
left open.
|
||||
2. Let one ticket's SLA run complete on time and another breach (via the existing breach sweep).
|
||||
3. `GET /admin/reports/management?from=<range start>&to=<range end>`.
|
||||
4. **Expected**: `totalCases`, `aiResolved`, `humanEscalated`, `resolved`, `open` all match what
|
||||
was actually created; `slaCompliance.met`/`.breached` match the two SLA outcomes;
|
||||
`averageResponseSeconds`/`averageResolutionSeconds` are non-null and plausible.
|
||||
5. Request the same endpoint for a date range with no activity at all.
|
||||
6. **Expected**: every count is `0`, every rate/average is `null`, not an error.
|
||||
|
||||
## Scenario 2 — Product dashboard (User Story 2)
|
||||
|
||||
1. Create tickets for two distinct products in the same range, one with a categorized problem.
|
||||
2. Look up a known error code for one product several times, a different code once.
|
||||
3. `GET /admin/reports/product/:externalProductId` for each product.
|
||||
4. **Expected**: each product's `supportVolume`/`problemsByCategory`/`aiResolutionRate` reflect
|
||||
only its own tickets; `topErrors` ranks the more-frequently-looked-up code first.
|
||||
5. Request the endpoint for a nonexistent `externalProductId`.
|
||||
6. **Expected**: `404 NOT_FOUND`, not an empty `200`.
|
||||
|
||||
## Scenario 3 — Support dashboard (User Story 3)
|
||||
|
||||
1. Assign several tickets across two agents (some via the real orchestration flow).
|
||||
2. Let one ticket's SLA run sit within `REPORTING_SLA_RISK_THRESHOLD_MINUTES` of its resolution
|
||||
due date without breaching.
|
||||
3. `GET /admin/reports/support`.
|
||||
4. **Expected**: `workloadByAgent` matches each agent's real current open-assignment count;
|
||||
`slaAtRisk` counts exactly the near-due run, distinct from `slaBreached`.
|
||||
|
||||
## Scenario 4 — AI dashboard (User Story 4)
|
||||
|
||||
1. Run AI sessions to a mix of terminal outcomes (`resolved`, `escalated`), with some tool
|
||||
invocations succeeding and others failing, and diagnoses spanning a range of confidence
|
||||
values.
|
||||
2. `GET /admin/reports/ai`.
|
||||
3. **Expected**: `aiResolutionRate`/`humanHandoffRate` reflect the real outcome mix;
|
||||
`confidenceDistribution` buckets match `decideConfidenceBand`'s own classification of each
|
||||
diagnosis's stored confidence against the system-default thresholds; `toolInvocations`
|
||||
reflects the real success/failure counts.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All four scenarios pass against a real Postgres/Redis, every figure independently verified
|
||||
against hand-computed expected values, and no route is reachable by a non-admin session.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Research: Reporting and Analytics Dashboards
|
||||
|
||||
## 1. Where this lives
|
||||
|
||||
**Decision**: Wire up the existing, pre-scaffolded `src/modules/platform/reports` module (today
|
||||
just `ReportsService.generateSummaryReport()` returning `{}`, confirmed unused anywhere) rather
|
||||
than creating a new module. Its four real methods (`getManagementDashboard`,
|
||||
`getProductDashboard`, `getSupportDashboard`, `getAiDashboard`) replace the one stub method.
|
||||
Routes live at `GET /admin/reports/management`, `GET /admin/reports/product/:externalProductId`,
|
||||
`GET /admin/reports/support`, `GET /admin/reports/ai`, admin-gated the same way every other
|
||||
admin-only endpoint since 010-identity-auth already is (`requireRole('ADMIN')`).
|
||||
|
||||
**Why not the `ANALYTICS` queue** (`src/jobs/analytics`, also pre-scaffolded, also inert): a
|
||||
queued background job fits pre-computing a report nobody is currently waiting on; a dashboard
|
||||
request is someone waiting right now for an answer. Per spec.md's Assumptions, this first cut is
|
||||
synchronous, direct-query aggregation — the queue stub stays exactly as inert as it already was,
|
||||
untouched by this feature.
|
||||
|
||||
## 2. Per-dashboard queries
|
||||
|
||||
All four use Prisma's `groupBy`/`count`/`aggregate`, scoped by `createdAt` (or the
|
||||
milestone-specific timestamp named below) within `[from, to]`, computed directly against the
|
||||
tables that already own each fact — no new table, no denormalized rollup.
|
||||
|
||||
### Management (FR-001)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Total cases | `Ticket.count({ createdAt in range })` |
|
||||
| AI resolved | `Ticket.count({ createdAt in range, status: 'AI_RESOLVED' })` — a ticket that reached `AI_RESOLVED` and stayed there or moved straight to `RESOLVED` without a `Resolution.resolvedBy` other than `'ai'`; see §4 below for the exact "who resolved it" rule shared with the Product dashboard |
|
||||
| Human escalated | `Ticket.count({ createdAt in range, status in [HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED] })` minus AI-only-path tickets — i.e. any ticket that ever reached `HUMAN_ESCALATION`; the state machine research.md already establishes this as one-way (003-ticketing) |
|
||||
| Resolved (either path) | `Ticket.count({ createdAt in range, status in [RESOLVED, CLOSED] })` |
|
||||
| Open | `Ticket.count({ createdAt in range, status not in [RESOLVED, CLOSED] })` |
|
||||
| SLA compliance / breach count | `SLARun.groupBy(['status'], { ticket: { createdAt in range } })`, `status: 'completed'` = met, `'breached'` = breached (mirrors 014's own metric semantics — see 014 research.md §5's "read status before the overwrite" caveat, which applies equally here: a `'breached'`-then-`'completed'` run is still counted breached, by reading the `breachedAt`/`firstResponseBreachedAt` timestamps rather than only the current `status` string) |
|
||||
| Escalation count | `EscalationEvent.count({ createdAt in range })` |
|
||||
| Average response time | `avg(firstAgentMessage.createdAt - ticket.createdAt)` over tickets with at least one `AGENT_MESSAGE` in range — computed in application code over a bounded query result (see §5, no raw SQL) |
|
||||
| Average resolution time | `avg(resolution.createdAt - ticket.createdAt)` over tickets with a `Resolution` row in range |
|
||||
|
||||
### Product (FR-002)
|
||||
|
||||
Same shape as Management, `WHERE Ticket.productId = :productId` (resolved from the given
|
||||
`externalProductId`, 404 if not found — FR-006), plus:
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Problem-type breakdown | `Problem.groupBy(['categoryId'], { productId, createdAt in range })` |
|
||||
| Recurring problems | Same grouped result, sorted descending, top N (config, default 10) |
|
||||
| Top errors | `reuses 014's own instrumentation point conceptually but queries fresh` — no, see §6: there is no persisted "error code lookup" table, only 014's in-memory Prometheus counter, which is NOT queryable historically. Resolved by adding a durable audit read instead: see §6. |
|
||||
|
||||
### Support (FR-003)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Per-agent workload | `Assignment.groupBy(['agentId'], { isCurrent: true })` — a snapshot of *right now*, not date-ranged (workload is inherently current, not historical — spec.md's own framing: "how much work is currently assigned") |
|
||||
| SLA risk / breached | `SLARun.findMany({ status: 'running', resolutionDueAt: {gte: now} })` filtered in application code by "due within `SLA_RISK_THRESHOLD_MINUTES` of now" for risk, vs. `status: 'breached'` for already-breached |
|
||||
| Escalation count | Same as Management, unfiltered by product |
|
||||
| Response/resolution performance | Same computation as Management's averages |
|
||||
|
||||
### AI (FR-004)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| AI resolution rate / human-handoff rate | `AISupportSession.groupBy(['status'], { startedAt in range })` — `resolved` vs. `escalated`/`ended_by_agent` as a share of total terminal sessions |
|
||||
| Failed-troubleshooting-then-escalated rate | Sessions with `status: 'escalated'` that have at least one `AIInteraction`/`AIAction` recording a failed troubleshooting attempt — see 005-ai-support's own runbook-step-outcome classification (`classifyStepOutcome`), reused rather than reinvented |
|
||||
| Knowledge-match rate | `AIKnowledgeReference` presence per session (`recordMany` is only ever called with actual retrieval results — 005-ai-support's own `diagnose.ts`) vs. sessions with zero references recorded |
|
||||
| Confidence distribution | `AIDiagnosis.findMany({ createdAt in range })`, bucketed in application code against `aiConfig.defaultHighConfidence`/`defaultLowConfidence` (see §7 — NOT a per-diagnosis resolved policy) |
|
||||
| Tool success/failure | `AIAction` joined to `AIActionResult`, grouped by `result.status` |
|
||||
|
||||
## 3. "No data" convention (FR-007)
|
||||
|
||||
**Decision**: every rate/average field is `number | null` — `null` means "no qualifying records
|
||||
in range," distinguished in the response shape from a genuine `0` (e.g., a real 0% AI resolution
|
||||
rate because everything escalated is a valid, meaningful `0`; "nobody's data exists yet" is
|
||||
`null`). Application code computes every average by fetching the qualifying count first and
|
||||
returning `null` before ever dividing, never relying on `0/0` producing `NaN` and hoping a caller
|
||||
notices.
|
||||
|
||||
## 4. "Who resolved it" — reused from 014, not reinvented
|
||||
|
||||
014-full-observability's own event subscriber already established the authoritative rule: a
|
||||
ticket's `Resolution.resolvedBy` field (`"ai"` | an `agentId`) is the single source of truth for
|
||||
whether a resolution was AI- or human-driven (014 research.md §5). This feature's Management/
|
||||
Product dashboards reuse the exact same join (`Resolution.findMany` scoped to the range,
|
||||
`resolvedBy === 'ai'` vs. not) rather than re-deriving it from `Ticket.status` transitions a
|
||||
second, potentially-inconsistent way.
|
||||
|
||||
## 5. No raw SQL
|
||||
|
||||
**Decision**: every duration average (response time, resolution time) is computed by fetching
|
||||
the bounded set of qualifying rows (ticket `createdAt` + the milestone timestamp) via Prisma and
|
||||
averaging in application code, not a raw `$queryRaw` computing `AVG(EXTRACT(EPOCH FROM ...))` in
|
||||
SQL. At the data volumes spec.md's Assumptions accept for this first cut (no pre-aggregation,
|
||||
synchronous queries), a bounded per-range fetch is simple, type-safe, and testable without
|
||||
hand-writing SQL — consistent with this codebase's near-total avoidance of `$queryRaw` elsewhere
|
||||
(confirmed by grep: no existing module uses it for reporting-shaped queries).
|
||||
|
||||
## 6. Top errors needs a durable, queryable record — a real gap 014 left open
|
||||
|
||||
014-full-observability's `supporthub_known_error_lookups_total` Prometheus counter is
|
||||
process-lifetime, in-memory, and reset on every restart — useless for "top errors in the last 30
|
||||
days." Since no durable "error code lookup" record exists anywhere in this codebase today (the
|
||||
existing `error-codes.service.ts` just reads `KnownIssue`/`ErrorCode` rows, never records that a
|
||||
lookup happened), this feature adds one small, focused piece of new state: a durable
|
||||
`ErrorCodeLookup` audit row (`errorCodeId`, `productId`, `createdAt`), written by
|
||||
`error-codes.service.ts`'s already-existing `findKnownIssuesByErrorCode` (the same call site
|
||||
014 instrumented for its own live counter — this feature adds one more line there, a durable
|
||||
write alongside the existing live-metric increment, not a replacement for it). This is the one
|
||||
schema change this feature needs; every other dashboard figure is computed from tables that
|
||||
already exist.
|
||||
|
||||
## 7. Confidence distribution uses the system default threshold, not a per-diagnosis policy
|
||||
|
||||
**Decision**: bucket every `AIDiagnosis.confidence` value in range against the env-configured
|
||||
system-wide defaults (`aiConfig.defaultHighConfidence`/`defaultLowConfidence`), the same
|
||||
`decideConfidenceBand` pure function 005-ai-support already exports — reused directly, not
|
||||
reimplemented.
|
||||
|
||||
**Why not resolve each diagnosis's actual applicable per-product/category policy** (what the
|
||||
live reasoning path itself does): `AIDiagnosis.product`/`feature` are the AI's own free-text
|
||||
classification output, not foreign keys to `Product`/`Category` — there is no reliable, existing
|
||||
join from a diagnosis row back to which `ConfidencePolicy` row actually applied to it at the time
|
||||
without speculatively string-matching free text against product names, which this codebase does
|
||||
nowhere else and which research.md declines to invent here. A dashboard-level aggregate
|
||||
distribution using the system-wide default is an honest, documented simplification (spec.md
|
||||
Assumptions) — precise enough to show a meaningful shape without fabricating a false precision
|
||||
the data doesn't actually support.
|
||||
|
||||
## 8. New configuration (Constitution Principle II — nothing hardcoded)
|
||||
|
||||
| Env var | Default | Used by |
|
||||
|---|---|---|
|
||||
| `REPORTING_DEFAULT_WINDOW_DAYS` | `30` | Every dashboard's `from`/`to` default when omitted (FR-005) |
|
||||
| `REPORTING_SLA_RISK_THRESHOLD_MINUTES` | `60` | Support dashboard's "at risk" classification (FR-003) |
|
||||
| `REPORTING_TOP_N_LIMIT` | `10` | Product dashboard's recurring-problems/top-errors ranking length |
|
||||
|
||||
## 9. Test strategy
|
||||
|
||||
Integration tests create real tickets/problems/SLA runs/escalation events/AI sessions/diagnoses/
|
||||
actions/error-code lookups directly against real Postgres (mixing real HTTP-driven setup where a
|
||||
realistic flow matters and direct repository/Prisma writes where only the aggregation math is
|
||||
under test — the same mix 014's own `business-metrics.test.ts` used), then request each
|
||||
dashboard endpoint and assert every figure against hand-computed expected values. Unit tests
|
||||
cover the "no data → null, never NaN" guard and the confidence-bucketing pure-function reuse.
|
||||
@@ -0,0 +1,257 @@
|
||||
# Feature Specification: Reporting and Analytics Dashboards
|
||||
|
||||
**Feature Branch**: `015-reporting-dashboards`
|
||||
|
||||
**Created**: 2026-09-09
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Reporting and analytics dashboards: real, read-only aggregation endpoints backing the four dashboards named in docs/09-testing-observability-cicd.md (Management, Product, Support, AI) — wiring up the pre-scaffolded but never-implemented platform/reports module into actual database-backed aggregation queries, admin-gated, with a date-range filter."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Management sees organization-wide support health (Priority: P1)
|
||||
|
||||
An admin or team lead opens a single view showing how support is doing overall for a chosen
|
||||
period: how many cases came in, how many were resolved (by AI vs. by a human), how many are
|
||||
still open, whether SLA commitments are being met, and how escalation is trending.
|
||||
|
||||
**Why this priority**: This is the one dashboard covering the whole roadmap's own top-level
|
||||
success criteria (`docs/10-implementation-roadmap.md`'s checklist) in one place — the first
|
||||
thing anyone asks about a support operation is "how are we doing," and today there is no way to
|
||||
answer that except querying the database by hand.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating a known set of tickets in various terminal
|
||||
states (AI-resolved, human-resolved, still open) plus a mix of met/breached SLA runs and
|
||||
escalations within a chosen date range, then requesting the Management dashboard for that range
|
||||
and confirming every figure matches what was actually created.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mix of tickets created within a chosen date range — some AI-resolved, some
|
||||
human-resolved, some still open — **When** the Management dashboard is requested for that
|
||||
range, **Then** total cases, AI-resolved count, human-escalated count, resolved count, and
|
||||
open count all match the actual data exactly.
|
||||
2. **Given** SLA runs that completed on time and others that breached within the range,
|
||||
**When** the dashboard is requested, **Then** SLA compliance (a rate) and SLA breach count
|
||||
both reflect the real outcomes.
|
||||
3. **Given** some tickets have a recorded first agent response and a resolution timestamp,
|
||||
**When** the dashboard is requested, **Then** average response time and average resolution
|
||||
time are computed only from tickets that actually reached those milestones within the range
|
||||
(a still-open ticket contributes to "open count" but never a fabricated resolution time).
|
||||
4. **Given** a date range with zero activity, **When** the dashboard is requested, **Then** every
|
||||
count is zero and every average is reported as "no data" rather than a computed zero or a
|
||||
division-by-zero error.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See support broken down by product (Priority: P1)
|
||||
|
||||
An admin viewing support data for a specific product (or comparing products) sees volume,
|
||||
problem-type breakdown, which problems recur most, how well AI is resolving that product's
|
||||
issues versus escalating them, and which error codes come up most often.
|
||||
|
||||
**Why this priority**: SupportHub serves multiple SaaS products (Constitution Principle I); a
|
||||
number that isn't broken out by product hides which integration actually needs attention — this
|
||||
is as fundamental as the Management view, just sliced differently.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating tickets/problems/error-code lookups across
|
||||
two distinct products within a date range, requesting the Product dashboard for each product,
|
||||
and confirming each one's figures include only its own product's data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets exist for two different products in the same date range, **When** the
|
||||
Product dashboard is requested scoped to one product, **Then** support volume and every other
|
||||
figure reflect only that product's tickets, never the other product's.
|
||||
2. **Given** problems in several categories for one product, **When** the dashboard is
|
||||
requested, **Then** the problem-type breakdown and "recurring problems" ranking both reflect
|
||||
the real category distribution, most-frequent first.
|
||||
3. **Given** a mix of AI-resolved and human-escalated tickets for one product, **When** the
|
||||
dashboard is requested, **Then** AI resolution rate and human escalation rate are both
|
||||
computed as a percentage of that product's own total, not the platform-wide total.
|
||||
4. **Given** several known-error-code lookups for one product, some codes looked up more than
|
||||
others, **When** the dashboard is requested, **Then** "top errors" lists those codes ranked by
|
||||
lookup frequency.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Support sees team workload and performance (Priority: P2)
|
||||
|
||||
An admin or team lead sees how much work is currently assigned across agents, which tickets are
|
||||
at SLA risk, how much escalation is happening, and how quickly the team is responding to and
|
||||
resolving tickets.
|
||||
|
||||
**Why this priority**: This view is about ongoing operational load, not historical trend — useful
|
||||
for day-to-day team management, but the organization can already see whether it's healthy
|
||||
overall from User Story 1 without this one; P2 reflects that it adds an operational lens rather
|
||||
than a new class of information.
|
||||
|
||||
**Independent Test**: Can be fully tested by assigning several tickets to known agents (some
|
||||
close to SLA breach, some not), then requesting the Support dashboard and confirming workload
|
||||
per agent and the SLA-risk count both match reality.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** several tickets are currently assigned across two agents, **When** the Support
|
||||
dashboard is requested, **Then** each agent's current open-assignment count matches what was
|
||||
actually assigned to them (not a stale count from a previous, now-unassigned period).
|
||||
2. **Given** a ticket's SLA run is running and past a configurable risk threshold of its
|
||||
resolution due date (but not yet breached), **When** the dashboard is requested, **Then** it
|
||||
is counted as "at risk," distinct from both "on track" and "breached."
|
||||
3. **Given** response and resolution durations for several resolved tickets in the period,
|
||||
**When** the dashboard is requested, **Then** response-performance and resolution-performance
|
||||
figures are computed only from tickets that actually reached those milestones.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - See how well the AI is performing (Priority: P2)
|
||||
|
||||
An admin sees, for a chosen period, how often the AI resolves issues on its own versus escalating
|
||||
them, how often its attempted troubleshooting fails outright, how often it finds relevant
|
||||
knowledge, how confident its diagnoses tend to be, how reliably its tools succeed, and how often
|
||||
it ultimately hands off to a human.
|
||||
|
||||
**Why this priority**: This is the dashboard that validates the AI-first design's core premise
|
||||
(Constitution Principle IV) is actually working in practice — valuable, but a narrower audience
|
||||
than the org-wide and per-product views above, hence P2.
|
||||
|
||||
**Independent Test**: Can be fully tested by running several AI sessions to different terminal
|
||||
outcomes (resolved, escalated, escalated-after-failed-troubleshooting) with a mix of tool
|
||||
successes/failures and confidence levels recorded, then requesting the AI dashboard and
|
||||
confirming every figure matches the real session data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mix of AI sessions ending resolved vs. escalated in the period, **When** the AI
|
||||
dashboard is requested, **Then** AI resolution rate and human-handoff rate both reflect the
|
||||
real outcome mix as percentages of total sessions.
|
||||
2. **Given** some AI tool invocations succeeded and others failed in the period, **When** the
|
||||
dashboard is requested, **Then** tool success/failure figures reflect the real invocation
|
||||
outcomes.
|
||||
3. **Given** diagnoses were recorded with a range of confidence values, **When** the dashboard is
|
||||
requested, **Then** the confidence distribution groups them into the same proceed/ask/escalate
|
||||
bands the AI support module's own confidence-policy service already classifies each diagnosis
|
||||
into (005-ai-support), not a newly-invented scheme.
|
||||
4. **Given** some AI sessions' knowledge-retrieval step found matching entries and others found
|
||||
none, **When** the dashboard is requested, **Then** knowledge-match rate reflects the real
|
||||
match/no-match mix.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when no `from`/`to` date range is given? Defaults to a reasonable trailing window
|
||||
(see Assumptions) rather than scanning the entire history unbounded on every request.
|
||||
- What happens when `from` is after `to`? Rejected as a validation error, not silently swapped or
|
||||
silently returning empty data.
|
||||
- What happens when a requested `productId` (Product dashboard) doesn't exist? Rejected with a
|
||||
clear not-found error, not an empty-but-200 response that looks like "this product has zero
|
||||
activity."
|
||||
- What happens when an average would divide by zero (no tickets reached that milestone in the
|
||||
range)? Reported as an explicit "no data" value, never `NaN`, `null` silently coerced to `0`,
|
||||
or a thrown error.
|
||||
- What happens when a ticket's SLA run was paused for part of the period? SLA-risk/compliance
|
||||
figures use the run's own already-durable due dates (008-sla-escalation's pause/resume
|
||||
already accounts for paused time) rather than this feature re-deriving elapsed time itself.
|
||||
- Who can see these dashboards? Same admin-only gate as every other admin configuration/reporting
|
||||
surface introduced since 010-identity-auth — no new role is introduced.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a Management dashboard summarizing, for a given date range:
|
||||
total cases created, cases resolved by AI, cases escalated to a human, total resolved
|
||||
(either path), total still open, SLA compliance rate, SLA breach count, escalation count,
|
||||
average first-response time, and average resolution time.
|
||||
- **FR-002**: System MUST provide a Product dashboard summarizing, for a given date range and a
|
||||
specific product: support volume, a breakdown by problem category, a ranked list of the most
|
||||
recurring problem categories, AI resolution rate, human escalation rate, and a ranked list of
|
||||
the most frequently looked-up error codes.
|
||||
- **FR-003**: System MUST provide a Support dashboard summarizing, for a given date range:
|
||||
current per-agent open-assignment workload, count of tickets at SLA risk (past a configurable
|
||||
risk threshold of their resolution due date but not yet breached), count of tickets already
|
||||
breached, escalation count, average response performance, and average resolution performance.
|
||||
- **FR-004**: System MUST provide an AI dashboard summarizing, for a given date range: AI
|
||||
resolution rate, rate of sessions that escalated after at least one failed troubleshooting
|
||||
attempt, knowledge-match rate, a distribution of diagnosis confidence across the existing
|
||||
proceed/ask/escalate bands, tool invocation success/failure counts, and human-handoff rate.
|
||||
- **FR-005**: Every dashboard endpoint MUST accept an optional `from`/`to` date range; when
|
||||
omitted, it MUST default to a documented trailing window rather than scanning unbounded
|
||||
history.
|
||||
- **FR-006**: The Product dashboard MUST require a valid `productId` and MUST reject an unknown
|
||||
one with a clear not-found error rather than returning an empty-but-successful response.
|
||||
- **FR-007**: Every rate/average figure MUST be computed only from tickets/sessions/runs that
|
||||
actually reached the relevant milestone within the range; a metric with no qualifying data MUST
|
||||
be reported as an explicit "no data" value, never a computed `0`, `null`, or `NaN`.
|
||||
- **FR-008**: All four dashboard endpoints MUST be admin-gated, consistent with every other
|
||||
admin-only reporting/configuration surface in this codebase.
|
||||
- **FR-009**: This feature MUST NOT alter the meaning or shape of any existing endpoint, event, or
|
||||
table — nearly every figure is derived read-only from data already durably recorded by the
|
||||
modules that own it (003 ticketing, 005 AI support, 007 orchestration, 008 SLA/escalation, 009
|
||||
problem resolution). The one exception is FR-011: a small new durable record needed only
|
||||
because no existing table can answer "which error codes are looked up most" historically.
|
||||
- **FR-011**: System MUST durably record each known-error-code lookup (product, error code,
|
||||
timestamp) at the point it already happens (the existing error-code lookup call site) so the
|
||||
Product dashboard's "top errors" ranking (FR-002) can be computed historically — the
|
||||
equivalent live, in-process counter this project already exposes on `/metrics` (014-full-
|
||||
observability) is process-lifetime and reset on every restart, unusable for a historical
|
||||
dashboard.
|
||||
- **FR-010**: This feature is backend-only; presenting these figures in a UI is a separate,
|
||||
explicitly out-of-scope follow-on (see Assumptions).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Dashboard response**: A read-only, computed JSON summary for one of the four dashboards over
|
||||
a requested date range (and, for the Product dashboard, one product) — never itself persisted;
|
||||
recomputed fresh on every request from existing durable records.
|
||||
- **Date range**: An inclusive `from`/`to` pair (calendar dates or timestamps) scoping every
|
||||
aggregation query; not a stored entity, a request parameter.
|
||||
- **Error code lookup record** (new, FR-011): a durable, append-only audit row — which product,
|
||||
which error code, when — written at the existing lookup call site; exists solely so "top
|
||||
errors" can be computed over a historical range, never read or written anywhere else.
|
||||
- **Confidence band**: The existing proceed/ask/escalate classification 005-ai-support already
|
||||
applies to a diagnosis's confidence score — reused here for the AI dashboard's distribution, not
|
||||
redefined.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: For any chosen date range, every figure on all four dashboards can be independently
|
||||
verified against the underlying ticket/session/SLA-run/escalation-event records and matches
|
||||
exactly — no discrepancy between what a dashboard reports and what actually happened.
|
||||
- **SC-002**: An admin can answer "how is support doing right now" (Management), "how is this
|
||||
specific product doing" (Product), "who's overloaded and what's at risk" (Support), and "is the
|
||||
AI actually helping" (AI) each from a single request, with no manual database query needed.
|
||||
- **SC-003**: A dashboard request for a period with no matching activity returns clean, explicit
|
||||
"no data" results in well under a second — never an error, a stall, or a misleading zero.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Presentation is out of scope for this feature.** The user's own explicit direction was to
|
||||
build the backend aggregation capability first (the established pattern this project has
|
||||
followed for every prior feature that touched both repos — identity/auth, the agent ticket
|
||||
queue, and full observability were each built backend-first). A `supporthub-web` dashboard UI
|
||||
consuming these endpoints is a natural, separate follow-on, not bundled into this spec.
|
||||
- The default trailing window when no date range is given is the last 30 days, matching common
|
||||
reporting-dashboard convention; CONFIGURABLE via the same admin-config env-driven pattern this
|
||||
project already uses for every other business-policy value (Constitution Principle II), not
|
||||
hardcoded as a magic number in application logic.
|
||||
- "SLA risk" needs a threshold (how close to the due date counts as "at risk") that the business
|
||||
has not specified — CONFIGURABLE, not invented as a hardcoded percentage, consistent with
|
||||
`docs/10-implementation-roadmap.md`'s own "never hardcode a placeholder value and ship it as
|
||||
final" instruction.
|
||||
- These endpoints compute their figures synchronously, on request, directly from the existing
|
||||
tables — no new pre-aggregation table, no scheduled batch job, and no use of the pre-scaffolded
|
||||
`ANALYTICS` queue (`src/jobs/analytics`), which remains an inert stub outside this feature's
|
||||
scope. Live query performance at current data volumes is assumed adequate; a future feature can
|
||||
introduce pre-aggregation if and when it's actually needed (load/concurrency testing, a
|
||||
separate not-yet-started Phase 11 sub-area, is where that question would be validated).
|
||||
- "Top errors"/"recurring problems" rankings return a bounded top-N list (CONFIGURABLE limit,
|
||||
defaulting to 10) rather than the full distribution, matching how a dashboard is actually
|
||||
consumed.
|
||||
- Dashboard responses are computed fresh per request (no caching layer) — acceptable given the
|
||||
assumed data volumes and consistent with not prematurely optimizing ahead of the load-testing
|
||||
phase.
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
description: 'Task list for 015-reporting-dashboards'
|
||||
---
|
||||
|
||||
# Tasks: Reporting and Analytics Dashboards
|
||||
|
||||
**Input**: Design documents from `specs/015-reporting-dashboards/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/reports-api-contract.md](./contracts/reports-api-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 Management, US2 = P1 Product,
|
||||
US3 = P2 Support, US4 = P2 AI). All four share the Foundational phase (schema, config, shared
|
||||
helpers, module scaffolding) but are otherwise independent of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `REPORTING_DEFAULT_WINDOW_DAYS` (default `30`),
|
||||
`REPORTING_SLA_RISK_THRESHOLD_MINUTES` (default `60`), and `REPORTING_TOP_N_LIMIT`
|
||||
(default `10`) to `src/config/env.ts`, exposed via a new `reportingConfig` in
|
||||
`src/config/reporting.ts` (or added to an existing config file, matching this codebase's
|
||||
own per-feature config-file convention)
|
||||
- [x] T002 Add the `ErrorCodeLookup` model to `prisma/schema.prisma` per data-model.md, generate
|
||||
the migration via `prisma migrate diff --from-url <db-url> --to-schema-datamodel
|
||||
./prisma/schema.prisma --script`, hand-write it into
|
||||
`prisma/migrations/<timestamp>_add_error_code_lookup/migration.sql`, apply via `prisma
|
||||
migrate deploy` against the throwaway test database (depends on T001 only in that both
|
||||
are Foundational — no code dependency)
|
||||
- [x] T003 Add `ai-support/knowledge/repository/error-code-lookup.repository.ts` —
|
||||
`create(errorCodeId, productId)`, exported from the knowledge module's repository index
|
||||
(depends on T002)
|
||||
- [x] T004 [P] Call the new repository's `create(...)` from
|
||||
`ai-support/knowledge/service/error-codes.service.ts`'s existing
|
||||
`findKnownIssuesByErrorCode`, alongside (not replacing) 014's own
|
||||
`knownErrorLookupsCounter.inc(...)` call at that same call site (depends on T003)
|
||||
- [x] T005 [P] Add `platform/reports/mapper/date-range.ts` — parses/validates `from`/`to` query
|
||||
params, defaulting via T001's `reportingConfig.defaultWindowDays`, throwing
|
||||
`ValidationError` when `from > to` (depends on T001)
|
||||
- [x] T006 [P] Add `platform/reports/mapper/rate.ts` — a shared `computeRate(numerator,
|
||||
denominator): number | null` and `computeAverageSeconds(durations: number[]): number |
|
||||
null` pair, both returning `null` (never `NaN`/`0`) when there's no qualifying data
|
||||
(research.md §3) — no dependency, pure functions
|
||||
- [x] T007 Scaffold `platform/reports/schema/` (query-param zod schema using T005's date-range
|
||||
parsing), `platform/reports/controller/reports.controller.ts` (empty methods to be filled
|
||||
in per user story below), `platform/reports/routes/reports.routes.ts` registering all four
|
||||
routes behind `requireRole('ADMIN')`, and update `platform/reports/index.ts` to export the
|
||||
new public surface, replacing `generateSummaryReport`'s stub entirely (depends on T005,
|
||||
T006)
|
||||
|
||||
**Checkpoint**: Config, schema, shared helpers, and module scaffolding in place. Each dashboard
|
||||
can now be built independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Management sees organization-wide support health (Priority: P1)
|
||||
|
||||
**Goal**: `GET /admin/reports/management` returns real figures per data-model.md's
|
||||
`ManagementDashboard` shape.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T008 [P] [US1] Unit tests for T006's `computeRate`/`computeAverageSeconds` (empty input ->
|
||||
`null`; a real mix -> the correct value) in
|
||||
`tests/unit/platform/reports/rate-helpers.test.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Add `platform/reports/repository/management.repository.ts` — one method per
|
||||
research.md §2's Management table row (ticket counts by status, SLA-run outcome counts,
|
||||
response/resolution duration row-fetches for T006 to average) (depends on T007)
|
||||
- [x] T010 [US1] Add `ReportsService.getManagementDashboard(range)` composing T009's repository
|
||||
calls into the `ManagementDashboard` shape, reusing the `Resolution.resolvedBy` convention
|
||||
(research.md §4) for the AI-vs-human split (depends on T009)
|
||||
- [x] T011 [US1] Wire `GET /admin/reports/management` to the controller/service (depends on T010)
|
||||
- [x] T012 [US1] Integration test covering Quickstart Scenario 1 (real tickets in various
|
||||
terminal states, a met and a breached SLA run, verified figure-by-figure; a no-activity
|
||||
range returns all-zero counts and all-null rates) in
|
||||
`tests/integration/platform-reports/management-dashboard.test.ts` (depends on T011)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - See support broken down by product (Priority: P1)
|
||||
|
||||
**Goal**: `GET /admin/reports/product/:externalProductId` returns real figures per
|
||||
`ProductDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T013 [P] [US2] Add `platform/reports/repository/product.repository.ts` — ticket/problem
|
||||
queries scoped by `productId`, plus a query against T003's `ErrorCodeLookup` table for
|
||||
the top-N ranking (`reportingConfig.topNLimit`) (depends on T007)
|
||||
- [x] T014 [US2] Add `ReportsService.getProductDashboard(externalProductId, range)`, 404-ing via
|
||||
`NotFoundError` when the product doesn't resolve (FR-006) before running any aggregation
|
||||
query (depends on T013)
|
||||
- [x] T015 [US2] Wire `GET /admin/reports/product/:externalProductId` (depends on T014)
|
||||
- [x] T016 [US2] Integration test covering Quickstart Scenario 2 (two products' data never
|
||||
cross-contaminating each other's figures; an unknown product 404s) in
|
||||
`tests/integration/platform-reports/product-dashboard.test.ts` (depends on T015)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Support sees team workload and performance (Priority: P2)
|
||||
|
||||
**Goal**: `GET /admin/reports/support` returns real figures per `SupportDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T017 [P] [US3] Add `platform/reports/repository/support.repository.ts` — current
|
||||
`Assignment` workload-by-agent query, `SLARun` at-risk/breached queries (`resolutionDueAt`
|
||||
within `reportingConfig.slaRiskThresholdMinutes` of now, per research.md §2) (depends on
|
||||
T007)
|
||||
- [x] T018 [US3] Add `ReportsService.getSupportDashboard(range)` (depends on T017)
|
||||
- [x] T019 [US3] Wire `GET /admin/reports/support` (depends on T018)
|
||||
- [x] T020 [US3] Integration test covering Quickstart Scenario 3 (real per-agent assignment
|
||||
counts; a near-due-but-not-breached run counted as at-risk, distinct from breached) in
|
||||
`tests/integration/platform-reports/support-dashboard.test.ts` (depends on T019)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - See how well the AI is performing (Priority: P2)
|
||||
|
||||
**Goal**: `GET /admin/reports/ai` returns real figures per `AiDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T021 [P] [US4] Unit test: the confidence-distribution bucketing reuses
|
||||
`decideConfidenceBand` (005-ai-support) against `aiConfig` defaults, not a reimplemented
|
||||
threshold check, in `tests/unit/platform/reports/confidence-distribution.test.ts`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T022 [US4] Add `platform/reports/repository/ai.repository.ts` — `AISupportSession` outcome
|
||||
counts, `AIDiagnosis` confidence fetch, `AIKnowledgeReference` presence-per-session query,
|
||||
`AIAction`/`AIActionResult` outcome counts (depends on T007)
|
||||
- [x] T023 [US4] Add `ReportsService.getAiDashboard(range)`, bucketing confidence via
|
||||
`decideConfidenceBand` + `aiConfig.defaultHighConfidence`/`defaultLowConfidence`
|
||||
(research.md §7) (depends on T022, T021)
|
||||
- [x] T024 [US4] Wire `GET /admin/reports/ai` (depends on T023)
|
||||
- [x] T025 [US4] Integration test covering Quickstart Scenario 4 (real AI sessions to mixed
|
||||
outcomes, mixed tool results, a spread of diagnosis confidence values) in
|
||||
`tests/integration/platform-reports/ai-dashboard.test.ts` (depends on T024)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes. All four dashboards work independently and
|
||||
together — this feature's full scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T026 [P] Update `specs/015-reporting-dashboards/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T027 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T028 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
(particularly `error-codes.service.ts`'s own existing tests, now touched by T004)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS all four user stories
|
||||
- **User Story 1 (Phase 2)**: Depends on Foundational — independent of US2/US3/US4
|
||||
- **User Story 2 (Phase 3)**: Depends on Foundational — independent of US1/US3/US4
|
||||
- **User Story 3 (Phase 4)**: Depends on Foundational — independent of US1/US2/US4
|
||||
- **User Story 4 (Phase 5)**: Depends on Foundational — independent of US1/US2/US3
|
||||
- **Polish (Phase 6)**: Depends on all four user stories
|
||||
@@ -0,0 +1,80 @@
|
||||
# Specification Quality Checklist: Load and Concurrency Testing
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-09
|
||||
**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
|
||||
|
||||
- This feature was scoped from a targeted codebase audit (not guesswork) confirming which
|
||||
concurrency guarantees already exist untested (ticket optimistic concurrency) versus which
|
||||
have no protection at all today (assignment double-assignment, SLA pause/resume, escalation
|
||||
idempotency) — see spec.md's own Assumptions section.
|
||||
- Per this project's own roadmap convention, exact load-test pass/fail thresholds are left as an
|
||||
explicit `OPEN BUSINESS DECISION` (FR-009) rather than invented — this is intentional, not a
|
||||
gap requiring [NEEDS CLARIFICATION].
|
||||
- All items pass; no revision iterations were needed.
|
||||
|
||||
## Implementation-time findings
|
||||
|
||||
- **All three suspected real races were confirmed real, then fixed.** Before the fix, firing 20
|
||||
genuinely concurrent assignment attempts at the same ticket reliably threw an unhandled
|
||||
Postgres unique-constraint error once the new `assignments_one_current_per_ticket` partial
|
||||
index was in place (proving the race existed even before the retry logic was added) — after
|
||||
the fix (bounded retry with jitter in `AssignmentRepository.createAssignment`), it holds
|
||||
consistently across 10 repeated runs. Escalation idempotency was proven the same way: the
|
||||
database-level unique-violation is visibly caught and absorbed in the logs during the test,
|
||||
confirming the fix actually engages under a genuine race rather than sitting untested.
|
||||
- **The ticket-status optimistic-concurrency mechanism (User Story 4) needed no fix** — proven
|
||||
correct on the first run, exactly as research.md's Assumptions predicted.
|
||||
- **A real, pre-existing test-infrastructure issue was found and resolved along the way**: the
|
||||
throwaway integration-test Postgres database had accumulated a very large number of tickets
|
||||
over this project's long development history, and the ticket-code generator's own
|
||||
documented "rare race between two concurrent creates" (a read-then-increment sequence number
|
||||
scoped by code prefix) became a frequent occurrence at that accumulated volume — manifesting
|
||||
as dozens of unrelated integration-test failures when the full suite ran, unrelated to any
|
||||
change in this feature. Confirmed by direct reproduction (a debug run showing the literal
|
||||
`Unique constraint failed on the fields: (code)` error) and by re-running the exact same
|
||||
suite cleanly (122/124 passing, matching the project's known accepted baseline) after
|
||||
dropping and recreating the throwaway database and replaying its full migration history
|
||||
(`prisma migrate deploy`, 12 migrations including this feature's own). This is a test-
|
||||
infrastructure hygiene finding, not a defect in this feature's own code.
|
||||
- **Two additional integration-test failures seen only in the full-suite run (never in
|
||||
isolation)** were confirmed to be pre-existing cross-file contamination inherent to this
|
||||
suite's shared-database, non-fully-isolated hierarchy/agent scoping (already acknowledged in
|
||||
comments elsewhere in the suite, e.g. sla-escalation-flow.test.ts's own note about a
|
||||
wildcard SLA policy leaking across concurrently-running files) — re-running the two affected
|
||||
files together in isolation passed cleanly (13/13), ruling out this feature's own changes as
|
||||
the cause.
|
||||
- The autocannon-based load-test tooling (User Story 5) surfaced a real, non-obvious cost
|
||||
consideration: ticket creation asynchronously triggers a real, billed Anthropic API call for
|
||||
that ticket's first AI diagnosis turn (005-ai-support) — this applies to both the
|
||||
ticket-creation and AI-support-flow load scripts, not only the latter as initially assumed.
|
||||
All three scripts were run once at a small, explicitly bounded scale (confirmed with the
|
||||
project owner beforehand) rather than an open-ended duration, specifically to keep this real
|
||||
cost small and predictable.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Data Model: Load and Concurrency Testing
|
||||
|
||||
All changes below are additive to existing models — no existing column is removed or
|
||||
retyped, and no existing consumer (012-admin-list-views, 015-reporting-dashboards) needs any
|
||||
change, since none of them write to `Assignment`/`SLARun`/`EscalationEvent` directly (all writes
|
||||
already go through the repositories being changed here).
|
||||
|
||||
## `SLARun` (existing model, one new field)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `version` | `Int @default(0)` | NEW. Optimistic-concurrency counter, identical convention to `Ticket.version` (003-ticketing). Incremented on every successful `updateWithVersion` call. |
|
||||
|
||||
Migration: additive `ALTER TABLE sla_runs ADD COLUMN version INTEGER NOT NULL DEFAULT 0;` —
|
||||
every existing row defaults to `0`, which is exactly the version any in-flight or future
|
||||
`updateWithVersion` call expects for a run nobody has updated since this migration ran.
|
||||
|
||||
## `Assignment` (existing model, no column change — one new index)
|
||||
|
||||
New raw partial unique index (Prisma schema DSL cannot express a partial predicate directly, so
|
||||
this is added via a raw-SQL migration step, same approach already used elsewhere in this
|
||||
project for Postgres-specific constraints):
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX assignments_one_current_per_ticket
|
||||
ON assignments (ticket_id)
|
||||
WHERE is_current = true;
|
||||
```
|
||||
|
||||
Enforces at the database level: a ticket may have at most one `Assignment` row with
|
||||
`isCurrent = true` at any moment, closing the race research.md §1 describes. The existing
|
||||
non-unique `@@index([ticketId, isCurrent])` is unaffected and stays for the repository's own
|
||||
`findCurrent` lookup.
|
||||
|
||||
## `EscalationEvent` (existing model, no column change — one new index)
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX escalation_events_ticket_rule_unique
|
||||
ON escalation_events (ticket_id, rule_id)
|
||||
WHERE rule_id IS NOT NULL;
|
||||
```
|
||||
|
||||
Enforces at the database level: a given rule may fire at most once per ticket over that
|
||||
ticket's lifetime (manual escalations, where `rule_id IS NULL`, are explicitly excluded and
|
||||
remain repeatable). Closes the race research.md §3 describes.
|
||||
|
||||
## Repository contract changes
|
||||
|
||||
### `SlaRunRepository`
|
||||
|
||||
- `update(id, data)` → **replaced** by `updateWithVersion(id, expectedVersion, data): Promise<SLARun | null>`, mirroring `TicketsRepository.updateStatus`'s exact shape: an atomic `updateMany({where:{id, version: expectedVersion}, data:{...data, version:{increment:1}}})`, returning the fresh row on success (count === 1) or `null` on a stale-version mismatch. Every existing call site (`pause`, `resume`, `complete`, `runBreachDetectionSweep`) is updated to pass its own last-read `version` and to retry (re-read + recompute + re-call) up to 3 times on a `null` result before giving up silently (matching the sweep's own existing no-throw, best-effort style — these are internal transitions with no HTTP caller waiting on a 409).
|
||||
|
||||
### `AssignmentRepository`
|
||||
|
||||
- `createAssignment(data)` — same signature and return type; internally catches a Prisma `P2002` on the new `assignments_one_current_per_ticket` index and retries the entire transaction (bounded to 3 attempts) before rethrowing.
|
||||
|
||||
### `EscalationEventRepository`
|
||||
|
||||
- `create(data)` — same signature; internally catches a Prisma `P2002` on the new `escalation_events_ticket_rule_unique` index and returns the pre-existing row for that `(ticketId, ruleId)` pair (a `findFirst({where:{ticketId, ruleId}})` fallback) instead of throwing, so `EscalationService.fire`'s caller sees a normal `EscalationEvent` either way — a duplicate trigger is invisible to the caller, not an error.
|
||||
|
||||
## Test-only entities (not persisted — in-memory test scaffolding)
|
||||
|
||||
- **Load test report** (`tests/load/`): `{ endpoint: string; connections: number; durationSec: number; requestsPerSec: number; latencyP50Ms: number; latencyP90Ms: number; latencyP99Ms: number; non2xxCount: number; rateLimitedCount: number }` — printed to console and written as JSON under `tests/load/reports/<endpoint>-<timestamp>.json` (gitignored) for each run, satisfying FR-008's separation of rate-limited responses from genuine failures.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Implementation Plan: Load and Concurrency Testing
|
||||
|
||||
**Branch**: `016-load-concurrency-testing` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `/specs/016-load-concurrency-testing/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Prove — with real, genuinely-concurrent requests against real Postgres/Redis, never mocked
|
||||
timers — three concurrency guarantees that a prior codebase audit found are NOT currently held
|
||||
(assignment double-assignment, SLA pause/resume/sweep races, escalation duplicate-event risk),
|
||||
fix each real race the tests reveal with a minimal, idiomatic DB-level guard consistent with
|
||||
this codebase's existing patterns, add one new concurrency test proving the existing ticket
|
||||
optimistic-concurrency guarantee holds under genuine concurrency, and add repeatable
|
||||
`autocannon`-based HTTP load-test tooling for the three named critical endpoint groups.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js >=20
|
||||
|
||||
**Primary Dependencies**: Fastify 4.26, Prisma, ioredis/BullMQ, Vitest (existing stack — no new
|
||||
runtime dependency for the concurrency tests); `autocannon` added as a new devDependency for the
|
||||
load-test tooling (pure npm package, no external binary, scriptable in TS, matches this
|
||||
project's existing Node-native toolchain rather than introducing a separate Go binary like k6)
|
||||
|
||||
**Storage**: PostgreSQL via Prisma (existing `Assignment`, `SLARun`, `EscalationEvent` models —
|
||||
one additive schema change per race fix, see data-model.md), Redis (existing, unchanged)
|
||||
|
||||
**Testing**: Vitest, run against the existing throwaway Docker Postgres/Redis
|
||||
(`supporthub-test-pg`/`supporthub-test-redis`) already used by `tests/concurrency/`; load tests
|
||||
run with `autocannon` against a real running instance of the dev server
|
||||
|
||||
**Target Platform**: Linux/Windows server (existing deployment target, unchanged)
|
||||
|
||||
**Project Type**: Backend service (existing modular monolith, unchanged)
|
||||
|
||||
**Performance Goals**: NEEDS CLARIFICATION resolved in research.md — no business-specified
|
||||
throughput/latency targets exist yet; FR-009 requires these be marked `OPEN BUSINESS DECISION`
|
||||
rather than invented, so this feature ships tooling + a baseline report, not a numeric SLA
|
||||
|
||||
**Constraints**: Every fix must be additive/backward-compatible (no breaking change to existing
|
||||
Assignment/SLARun/EscalationEvent consumers — 012-admin-list-views and 015-reporting-dashboards
|
||||
both already query these tables); every concurrency claim must be proven against real
|
||||
Docker-provisioned infrastructure per this project's standing verification discipline, never
|
||||
asserted from code review alone
|
||||
|
||||
**Scale/Scope**: 3 real races to prove-and-fix (assignment, SLA, escalation), 1 race to prove
|
||||
already-safe (ticket status), 3 endpoint groups to load-test (ticket creation, AI support flow,
|
||||
admin reporting) — entirely within `supporthub-api`, no `supporthub-web` changes
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|---|---|---|
|
||||
| I. SaaS Is Sole Identity Authority | N/A — no identity/tenant/product-access logic touched | PASS |
|
||||
| II. Configuration Over Hardcoding | Load-test pass/fail thresholds are NOT hardcoded — explicitly marked `OPEN BUSINESS DECISION` per FR-009, matching roadmap convention | PASS |
|
||||
| III. Layered Architecture / Module Boundaries | All three fixes stay inside their owning module (`orchestration/assignments`, `orchestration/sla`, `orchestration/escalation`) — repository-layer changes only, no new cross-module imports | PASS |
|
||||
| IV. AI Recommends, Policy Decides | N/A — no AI/tool-permission logic touched | PASS |
|
||||
| V. Evidence-Based Verification | This entire feature IS evidence-based verification — every claimed guarantee must be proven by a real concurrency test against real infra before being considered fixed | PASS (this principle is the feature's own thesis) |
|
||||
| VI. Durable Audit & History | No audit-log shape changes; EscalationEvent's idempotency fix preserves the existing audit row for the winning attempt, silently no-ops the loser rather than deleting anything | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling (NON-NEGOTIABLE) | This feature directly implements this principle's own stated requirement ("Assignment and escalation logic MUST be tested under concurrency... job handlers MUST be idempotent") — it is the principle's own overdue test coverage | PASS — this feature exists to close this exact gap |
|
||||
| VIII. Ticket/Problem Separation | N/A — no Ticket/Problem model changes | PASS |
|
||||
|
||||
No violations. No Complexity Tracking entries needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-load-concurrency-testing/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — not yet created)
|
||||
```
|
||||
|
||||
No `contracts/` directory: this feature adds no new HTTP endpoints or request/response
|
||||
contracts — it hardens existing internal behavior and adds test/tooling infrastructure only.
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
prisma/
|
||||
└── schema.prisma # +1 field (SLARun.version), +2 raw partial
|
||||
# unique indexes (migration SQL)
|
||||
|
||||
src/modules/orchestration/assignments/
|
||||
├── repository/assignment.repository.ts # createAssignment: catch+retry on the new
|
||||
# partial-unique-index conflict
|
||||
└── ... # (engine/service unchanged)
|
||||
|
||||
src/modules/orchestration/sla/
|
||||
├── repository/sla-run.repository.ts # update() becomes version-checked; add
|
||||
│ updateWithVersion(id, expectedVersion, data)
|
||||
└── service/sla.service.ts # pause/resume/complete: read-modify-retry
|
||||
loop on version conflict (bounded attempts)
|
||||
|
||||
src/modules/orchestration/escalation/
|
||||
├── repository/escalation-event.repository.ts # create(): catch the new partial-unique
|
||||
│ -index conflict, return existing row
|
||||
└── service/escalation.service.ts # fire(): treat a duplicate-conflict as a
|
||||
no-op, not an error
|
||||
|
||||
tests/concurrency/
|
||||
├── round-robin.test.ts # existing — untouched
|
||||
├── queue.test.ts # existing — untouched
|
||||
├── assignment-race.test.ts # NEW — User Story 1 / FR-001
|
||||
├── sla-race.test.ts # NEW — User Story 2 / FR-002
|
||||
├── escalation-idempotency.test.ts # NEW — User Story 3 / FR-003
|
||||
└── ticket-status-race.test.ts # NEW — User Story 4 / FR-004
|
||||
|
||||
tests/load/
|
||||
├── autocannon.config.ts # NEW — shared runner + report shape
|
||||
├── ticket-creation.load.ts # NEW — User Story 5 / FR-007, FR-008
|
||||
├── ai-support-flow.load.ts # NEW
|
||||
└── admin-reporting.load.ts # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Single backend project (existing `supporthub-api` modular monolith).
|
||||
Fixes live inside their owning module's existing `repository`/`service` files (Principle III);
|
||||
new tests live in the existing `tests/concurrency/` directory (already established by
|
||||
round-robin.test.ts) plus a new `tests/load/` directory for the load-test tooling, mirroring the
|
||||
existing `tests/{unit,integration,e2e,concurrency}` layout with one new sibling rather than
|
||||
overloading `tests/concurrency/` with non-correctness-proving load scripts.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No violations — table omitted.*
|
||||
@@ -0,0 +1,85 @@
|
||||
# Quickstart: Load and Concurrency Testing
|
||||
|
||||
Manual + automated verification steps for each user story, against real Docker-provisioned
|
||||
Postgres/Redis — this project's standing rule that a concurrency claim is never accepted from
|
||||
code review alone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Throwaway test infra up: `supporthub-test-pg` (host port 5433), `supporthub-test-redis` (host
|
||||
port 6380) — the same containers `tests/concurrency/round-robin.test.ts` already uses.
|
||||
- For the load tests (User Story 5) only: a real running instance of the API against the real
|
||||
dev infra (`postgres-development`/`redis-development`), reachable at
|
||||
`http://localhost:4501`, plus an ADMIN session token for the reporting endpoints.
|
||||
|
||||
## Scenario 1 — Assignment double-assignment race (User Story 1)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/assignment-race.test.ts`
|
||||
2. The test creates one ticket, then fires >=20 concurrent `assignmentEngine.assignToSpecificNode`
|
||||
(or the equivalent orchestration entry point) calls at it against real Postgres.
|
||||
3. **Expected**: the test itself queries `assignments` directly afterward and asserts exactly
|
||||
one row has `is_current = true` for that ticket — not just that one HTTP/service call
|
||||
"won." Repeat the run at least 10 times (or use the test's own internal repeat loop) to
|
||||
confirm SC-001's "zero exceptions across 10 repeated runs."
|
||||
4. Before the fix (research.md §1), this test is expected to fail intermittently; after the
|
||||
fix, it must pass every time.
|
||||
|
||||
## Scenario 2 — SLA pause/resume/sweep race (User Story 2)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/sla-race.test.ts`
|
||||
2. The test creates a ticket with an active SLA run, then fires concurrent `pause`/`resume`
|
||||
calls and a `runBreachDetectionSweep()` pass against the same run.
|
||||
3. **Expected**: the run's final DB state (`status`, `pausedAt`, `resumedAt`, `breachedAt`,
|
||||
`firstResponseDueAt`, `resolutionDueAt`) is queried directly and asserted internally
|
||||
consistent — e.g. never `status: 'paused'` with `pausedAt: null`, never a `breached` run
|
||||
silently reverted to `running` by a racing `resume`. Repeat per SC-002.
|
||||
|
||||
## Scenario 3 — Escalation idempotency (User Story 3)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/escalation-idempotency.test.ts`
|
||||
2. The test creates a ticket eligible for a specific escalation rule, then calls
|
||||
`escalationService.handleBreach` (or `fire` via its real trigger path) twice concurrently for
|
||||
the identical trigger.
|
||||
3. **Expected**: exactly one `EscalationEvent` row exists afterward for that `(ticketId,
|
||||
ruleId)` pair, and exactly one `Assignment` row resulted from it (cross-checking Scenario 1's
|
||||
own guarantee). Repeat per SC-003.
|
||||
|
||||
## Scenario 4 — Ticket status optimistic concurrency proof (User Story 4)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/ticket-status-race.test.ts`
|
||||
2. The test creates a ticket at a known status/version, then fires >=20 concurrent
|
||||
`ticketsRepository.updateStatus` calls all starting from that same version.
|
||||
3. **Expected**: exactly one call returns the updated ticket; every other call returns `null`
|
||||
(stale-version signal); the ticket's final DB status matches the one call that succeeded.
|
||||
This is expected to pass on the very first run (spec.md Assumptions) — a failure here would
|
||||
mean the existing mechanism has a real gap, not that this quickstart step is wrong.
|
||||
|
||||
## Scenario 5 — Load/throughput baseline (User Story 5)
|
||||
|
||||
1. Ensure the real dev API is running (`npm run dev` against `.env.development`) and reachable.
|
||||
2. `npx tsx tests/load/ticket-creation.load.ts`
|
||||
3. `npx tsx tests/load/ai-support-flow.load.ts`
|
||||
4. `npx tsx tests/load/admin-reporting.load.ts` (needs an ADMIN token — the script signs in
|
||||
itself using the same seeded admin credentials this project's E2E suite already uses)
|
||||
5. **Expected**: each script prints a report (requests/sec, `p50`/`p90`/`p99` latency, non-2xx
|
||||
count, rate-limited count) and writes it to `tests/load/reports/`. There is no pass/fail
|
||||
assertion on the numbers themselves (FR-009, `OPEN BUSINESS DECISION`) — the check here is
|
||||
that the tooling runs cleanly end-to-end and produces a comparable, re-runnable report, not
|
||||
that any specific number is hit.
|
||||
6. Run the same script twice in a row and confirm the two reports are comparable in shape
|
||||
(same fields, plausible numbers) — proving SC-005's "consistent-shape output for comparison
|
||||
across runs."
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
- All four new `tests/concurrency/*.test.ts` files pass consistently (not flakily) against real
|
||||
Postgres/Redis, each proving its own user story's guarantee with a direct database assertion,
|
||||
not just an HTTP response check.
|
||||
- Every race the audit found (assignment, SLA, escalation) is fixed in the actual repository
|
||||
code per data-model.md, not merely detected and left alone.
|
||||
- All three `tests/load/*.load.ts` scripts run cleanly against a real running dev server and
|
||||
produce a report.
|
||||
- Full existing quality gate (typecheck, lint, architecture check, full unit + integration
|
||||
suite) stays green — these fixes touch shared repositories (`Assignment`, `SLARun`,
|
||||
`EscalationEvent`) already exercised by 007-orchestration-assignment's, 008-sla-escalation's,
|
||||
012-admin-list-views's, and 015-reporting-dashboards's own existing tests.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Research: Load and Concurrency Testing
|
||||
|
||||
## 1. Assignment double-assignment race
|
||||
|
||||
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
|
||||
assignments_one_current_per_ticket ON assignments (ticket_id) WHERE is_current = true;` — and
|
||||
change `AssignmentRepository.createAssignment` to catch the resulting unique-violation (Prisma
|
||||
`P2002`) and retry the whole supersede-then-create transaction (bounded to 3 attempts, matching
|
||||
this codebase's existing small-bounded-retry convention), rather than surfacing a raw 500.
|
||||
|
||||
**Rationale**: `createAssignment`'s existing transaction (`updateMany({isCurrent:false}) +
|
||||
create({isCurrent:true})`) is correct in isolation but Postgres's default `READ COMMITTED`
|
||||
isolation lets two concurrent transactions each see "no current row to supersede" and both
|
||||
successfully `create` their own `isCurrent:true` row — there is no read-modify-write cycle a
|
||||
version field could guard here (unlike Ticket/SLARun below), because the operation is a
|
||||
create, not an update, and a create can't be conditioned on "no matching row exists" atomically
|
||||
without a DB-level constraint. A partial unique index is the standard, minimal Postgres pattern
|
||||
for "at most one row matching a predicate" and requires no application-level locking. Retrying
|
||||
on conflict (rather than failing the second caller outright) preserves current behavior for the
|
||||
common, non-racing case and correctly resolves the race by making the loser's request apply
|
||||
*after* the winner's, superseding it — exactly the same "last write wins, but exactly once"
|
||||
semantics `createAssignment`'s own docstring already promises for the non-concurrent case.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *Explicit `SERIALIZABLE` transaction isolation*: would also detect the race (as a
|
||||
serialization failure) but requires the exact same catch-and-retry handling as the unique
|
||||
index approach, adds latency to every assignment (not just racing ones), and does nothing to
|
||||
prevent the row from ever being duplicated if a future code path creates an Assignment outside
|
||||
this transaction — a DB constraint is a stronger, more future-proof guarantee.
|
||||
- *Row-level lock (`SELECT ... FOR UPDATE`) on a per-ticket lock row*: works, but requires
|
||||
inventing a new lock-row concept for a case Postgres's own partial unique index already solves
|
||||
natively.
|
||||
|
||||
## 2. SLA pause/resume/sweep race
|
||||
|
||||
**Decision**: Add `version Int @default(0)` to `SLARun`. Replace `SlaRunRepository.update(id,
|
||||
data)` with `updateWithVersion(id, expectedVersion, data)`, mirroring
|
||||
`TicketsRepository.updateStatus`'s existing atomic `updateMany({where:{id, version:
|
||||
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly. `SlaService.pause`,
|
||||
`resume`, `complete`, and `runBreachDetectionSweep` each move to a small
|
||||
read-compute-write-retry loop (bounded to 3 attempts): re-read the run fresh on a version
|
||||
conflict, recompute the operation's own delta (e.g. resume's `pausedMs` shift) against the fresh
|
||||
state, and retry the versioned write.
|
||||
|
||||
**Rationale**: Every one of `pause`/`resume`/`complete`/the sweep does an unconditional
|
||||
read-then-`update(run.id, {...})` with no guard — two of these racing (e.g. `resume` and the
|
||||
sweep evaluating the same run at once) can silently clobber each other: the sweep's own
|
||||
`update(run.id, {status:'breached', breachedAt: now})` could be overwritten moments later by a
|
||||
`resume` that read the run *before* the sweep's write and still thinks it's `paused`, un-breaching
|
||||
a run that was legitimately breached and permanently losing that breach from SLA-compliance
|
||||
figures — a real, silent correctness bug, not a hypothetical one. `Ticket` already has exactly
|
||||
this problem solved for its own status field with a `version` counter and an atomic
|
||||
conditional-update; reusing that identical mechanism (rather than inventing a new one) keeps the
|
||||
codebase's concurrency idiom singular and matches Principle III's spirit even though it isn't
|
||||
a cross-module boundary concern.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *Wrap each operation in a Postgres advisory lock keyed by run ID*: works but adds a new
|
||||
locking primitive to the codebase for a problem the existing version-counter idiom already
|
||||
solves; rejected for consistency, not because it wouldn't work.
|
||||
- *A single DB transaction spanning the sweep's read+write for all runs at once*: would only
|
||||
protect the sweep against itself, not against `pause`/`resume` racing it from an unrelated
|
||||
request path — doesn't close the actual gap.
|
||||
|
||||
## 3. Escalation idempotency
|
||||
|
||||
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
|
||||
escalation_events_ticket_rule_unique ON escalation_events (ticket_id, rule_id) WHERE rule_id IS
|
||||
NOT NULL;` — and change `EscalationEventRepository.create` (called from
|
||||
`EscalationService.fire`) to catch the resulting `P2002` and return the already-existing event
|
||||
for that `(ticketId, ruleId)` pair instead of creating a duplicate or throwing.
|
||||
|
||||
**Rationale**: `SLARun.ticketId` is `@unique` and "no reopen-cycle support" (existing schema
|
||||
comment) means a given rule can only ever legitimately fire once per ticket's lifetime for a
|
||||
rule-triggered breach (`handleBreach`'s `ruleId` is always a real rule ID scoped to one specific
|
||||
`triggerType`; `resolution_breach` and `first_response_breach` runs are naturally different
|
||||
rules, so this constraint doesn't conflate the two). Manual escalation
|
||||
(`escalateManually`/`fire(ticketId, ruleId: null, ...)`) is deliberately excluded from the
|
||||
constraint (`WHERE rule_id IS NOT NULL`) because an admin legitimately re-escalating the same
|
||||
ticket manually more than once must keep working exactly as it does today. This directly closes
|
||||
the gap the audit found: `runBreachDetectionSweep`'s `findRunningPastResolutionDueAt` can return
|
||||
the same still-`running` row to two overlapping sweep passes (e.g. a slow sweep still finishing
|
||||
when the next scheduled tick fires, or a duplicate BullMQ job delivery calling `handleBreach`
|
||||
directly) before either pass's own `update(run.id, {status:'breached', ...})` commits — without
|
||||
this constraint, both passes independently call `fire` and each successfully creates its own
|
||||
`EscalationEvent` plus its own `assignToSpecificNode`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *A dedicated idempotency-key column populated by the caller (e.g. a sweep-run ID)*: more
|
||||
general, but overkill here — the natural, already-unique business key for a rule-triggered
|
||||
escalation genuinely is `(ticketId, ruleId)` given the "no reopen-cycle" constraint already in
|
||||
place; inventing a separate key would duplicate information the schema already expresses.
|
||||
- *Making the sweep single-flight via a Redis lock around the whole sweep function*: would
|
||||
prevent two sweep passes from overlapping, but does not protect against a duplicate BullMQ job
|
||||
calling `handleBreach` directly for the same trigger outside the sweep's own loop — the DB
|
||||
constraint protects the actual invariant regardless of caller, which is the correct place per
|
||||
Principle VII ("job handlers MUST be idempotent").
|
||||
|
||||
## 4. Ticket optimistic-concurrency proof
|
||||
|
||||
**Decision**: No implementation change. Add `tests/concurrency/ticket-status-race.test.ts`
|
||||
firing a batch of genuinely concurrent `TicketsRepository.updateStatus` calls at the same
|
||||
ticket, all from the same starting version, against the real throwaway Postgres, and asserting
|
||||
exactly one succeeds (returns the updated ticket) while every other call returns `null` (the
|
||||
existing stale-version-mismatch signal) — proving FR-004/SC-004 against the mechanism that
|
||||
already exists (see `tests/concurrency/round-robin.test.ts:12`'s own reference to "003-ticketing's
|
||||
optimistic ticket-status concurrency" as prior art that was never itself concurrency-tested).
|
||||
|
||||
**Rationale**: The existing `updateMany({where:{id, version: expectedVersion}, ...})` is a
|
||||
single atomic SQL statement — Postgres itself guarantees only one concurrent `UPDATE` matching
|
||||
that `WHERE` clause can succeed before the row's `version` changes underneath the others. This
|
||||
is sound by construction; the gap is purely "never proven under real concurrency," which this
|
||||
research assumes will simply confirm the existing guarantee (per spec.md's own Assumptions) —
|
||||
but the test is still written to fail loudly if that assumption turns out to be wrong.
|
||||
|
||||
## 5. Load-test tooling choice
|
||||
|
||||
**Decision**: `autocannon` (npm devDependency), invoked via small TypeScript runner scripts
|
||||
under `tests/load/`, one per named endpoint group (ticket creation, AI support flow, admin
|
||||
reporting), each producing a JSON report (`autocannon`'s own `Result` shape: requests/sec,
|
||||
latency `p50`/`p90`/`p99`, non-2xx count) written to `tests/load/reports/` (gitignored — these
|
||||
are run artifacts, not fixtures) plus a printed console summary.
|
||||
|
||||
**Rationale**: `autocannon` is a pure Node.js package (no separate binary to install, unlike
|
||||
k6), is TypeScript-friendly, and its programmatic API (`autocannon({url, connections, duration,
|
||||
requests: [...]}, callback)`) fits scripting multi-step flows (e.g. sign-in once, then hammer an
|
||||
authenticated endpoint) far more naturally than k6's separate-runtime JS dialect — keeping this
|
||||
feature's new tooling inside the same Node/TS toolchain as the rest of the project (Technical
|
||||
Context), consistent with this project's existing minimal-new-tooling bias.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *k6*: the industry-standard load-testing tool with richer scripting and threshold
|
||||
assertions, but ships as a separate Go binary requiring its own install/Docker image outside
|
||||
npm — heavier footprint for a project whose stack is otherwise 100% npm-managed.
|
||||
Reconsider if this project later needs distributed/cloud load generation, which `autocannon`
|
||||
does not support and k6 does.
|
||||
- *artillery*: also npm-native and closer to k6 in scripting richness, but pulls in a much
|
||||
larger dependency tree for YAML-driven scenario files this feature doesn't need — `autocannon`
|
||||
is a lighter fit for three hand-written TS scripts.
|
||||
|
||||
## 6. Load-test pass/fail thresholds
|
||||
|
||||
**Decision**: Per FR-009, no numeric throughput/latency/error-rate threshold is hardcoded as
|
||||
pass/fail. Each load-test report prints its own measured numbers and the tooling exits `0`
|
||||
regardless of the numbers observed (this is a measurement tool, not a gate) — a comment in each
|
||||
script marks the threshold question as `OPEN BUSINESS DECISION` and links back to spec.md
|
||||
Assumptions, so a future feature can wire an explicit pass/fail gate into CI once the business
|
||||
sets a real target.
|
||||
|
||||
**Rationale**: Inventing an arbitrary "must handle 500 req/s at p99 < 200ms" number would
|
||||
violate the roadmap's own explicit rule ("Never hardcode a placeholder value for any of the
|
||||
[open business decisions] and ship it as if it were final") — throughput/latency targets are
|
||||
exactly this kind of business-owned number, not an engineering default.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Feature Specification: Load and Concurrency Testing
|
||||
|
||||
**Feature Branch**: `016-load-concurrency-testing`
|
||||
|
||||
**Created**: 2026-09-09
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Load and concurrency testing (Phase 11): exercise the concurrency-safety guarantees docs/09-testing-observability-cicd.md's own testing strategy already calls for — assignment race conditions, SLA pause/resume durability, escalation idempotency, ticket optimistic concurrency — under genuinely concurrent requests against real infrastructure, fixing any real race a test reveals; and add real HTTP load/throughput testing against the API's own critical endpoints."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - A ticket is never assigned to two agents at once under concurrent escalation (Priority: P1)
|
||||
|
||||
An operator needs confidence that when a ticket is escalated to a human (or reassigned) from
|
||||
more than one trigger at nearly the same moment — for example, a manual reassignment landing at
|
||||
the same instant as an automatic escalation-rule firing — the ticket ends up with exactly one
|
||||
current assignment, never two agents both believing they own the same case.
|
||||
|
||||
**Why this priority**: A double-assignment is a customer- and agent-facing correctness failure
|
||||
(two agents work the same ticket, or the SLA/workload dashboards silently double-count it) and
|
||||
undermines every dashboard and workload figure already shipped in this system. This is the most
|
||||
severe class of bug this feature can find.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing many genuinely concurrent assignment
|
||||
requests at the same ticket against a real running instance of the API and a real Postgres
|
||||
database, then confirming exactly one `Assignment` row is marked current for that ticket
|
||||
afterward — no reliance on timing assumptions or sequential calls.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket eligible for assignment, **When** many concurrent assignment attempts are
|
||||
made against it at once, **Then** exactly one assignment ends up marked as the ticket's
|
||||
current assignment, and the database itself (not just the last response received) confirms
|
||||
this.
|
||||
2. **Given** the race in Scenario 1 is exercised repeatedly, **When** the test is run multiple
|
||||
times, **Then** the result is consistent every time — the protection does not depend on
|
||||
lucky timing.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An SLA clock is never corrupted by overlapping pause/resume activity (Priority: P1)
|
||||
|
||||
An operator needs confidence that when a ticket's SLA clock is paused and resumed by more than
|
||||
one concurrent trigger — for example, a customer-reply webhook resuming the clock at the same
|
||||
moment the scheduled breach-detection sweep is evaluating that same ticket — the SLA run ends up
|
||||
in one coherent, correct state, never a state where the clock is simultaneously "paused" and
|
||||
"counting toward breach," and never a state that silently drops a pause/resume event.
|
||||
|
||||
**Why this priority**: SLA correctness is a contractual promise to customers and already backs
|
||||
the Management and Support dashboards shipped in 015-reporting-dashboards; a corrupted SLA clock
|
||||
produces wrong compliance figures and wrong breach alerts without any visible error.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing concurrent pause and resume operations at
|
||||
the same SLA run against a real running instance of the API and a real Postgres database, then
|
||||
confirming the run's final stored state (paused/active, due-at timestamps) is internally
|
||||
consistent and matches one coherent ordering of the operations — not a mix of both.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active SLA run, **When** a pause and a resume are triggered concurrently,
|
||||
**Then** the run's final state is exactly one of "paused" or "active" — never a state with
|
||||
contradictory fields (e.g., marked paused with no pause timestamp recorded, or marked active
|
||||
with a stale due-at that never accounted for the pause).
|
||||
2. **Given** the breach-detection sweep is evaluating a run at the same moment a resume is
|
||||
requested for it, **When** both complete, **Then** the run is not double-processed (no
|
||||
duplicate breach event, no lost resume).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An escalation rule firing twice never creates two escalation events (Priority: P1)
|
||||
|
||||
An operator needs confidence that if the same escalation trigger is delivered more than once —
|
||||
for example, a retried background job or a re-processed event — the ticket is escalated exactly
|
||||
once, not reassigned and re-notified redundantly.
|
||||
|
||||
**Why this priority**: Duplicate escalations would double-notify agents, double-count in the
|
||||
Support and Management dashboards, and could re-trigger reassignment away from an agent who has
|
||||
already started work — a direct regression of work already done in this session.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing the same escalation trigger concurrently
|
||||
more than once for the same ticket against a real running instance of the API and a real
|
||||
Postgres database, then confirming only one `EscalationEvent` row exists for that trigger
|
||||
afterward.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket eligible for escalation, **When** the same escalation trigger is delivered
|
||||
twice at nearly the same moment, **Then** exactly one escalation event is recorded for it.
|
||||
2. **Given** Scenario 1's duplicate delivery, **When** the escalation event is created,
|
||||
**Then** the ticket is reassigned exactly once, not twice.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - A ticket's status can never be corrupted by two simultaneous updates (Priority: P2)
|
||||
|
||||
An operator needs confidence that the ticket status-transition safeguard already built for this
|
||||
system actually holds under real concurrent load, not just in isolated sequential tests — this
|
||||
is existing protection, but has never been proven under genuine concurrency.
|
||||
|
||||
**Why this priority**: Lower priority than User Stories 1-3 because a real defensive mechanism
|
||||
already exists here (see Assumptions); this story exists to convert an untested assumption into
|
||||
a proven guarantee, and is valuable but lower-risk than the three unguarded races above.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing multiple concurrent status-update attempts
|
||||
at the same ticket, each based on the same starting version, against a real running API and
|
||||
database, then confirming exactly one update succeeds and every other attempt receives a clear
|
||||
conflict response rather than silently corrupting or skipping the ticket's state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket at a known status and version, **When** multiple concurrent status-update
|
||||
requests are made from that same version, **Then** exactly one succeeds and the rest are
|
||||
rejected with a conflict response, and the ticket's final status matches the one update that
|
||||
succeeded.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - The API's critical endpoints hold up under realistic concurrent traffic (Priority: P2)
|
||||
|
||||
An operator needs a documented, repeatable measurement of how the system's most important
|
||||
endpoints — new support requests coming in, the AI support flow, and the admin reporting
|
||||
dashboards — behave under sustained concurrent load, so that a future capacity or performance
|
||||
regression can be caught by comparing against this baseline rather than guessed at.
|
||||
|
||||
**Why this priority**: This is about establishing a measurable baseline and repeatable tooling
|
||||
rather than proving or fixing a specific correctness bug (unlike User Stories 1-4), so it is
|
||||
valuable but not blocking for the correctness guarantees above.
|
||||
|
||||
**Independent Test**: Can be fully tested by running a load-test tool against a real running
|
||||
instance of the API for each of the three named endpoint groups and producing a report of
|
||||
throughput, latency percentiles, and error rate, independent of whether any other user story in
|
||||
this feature has been completed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is running against real infrastructure, **When** a defined concurrent load
|
||||
is sent to the ticket-creation endpoint for a sustained period, **Then** a report is produced
|
||||
showing throughput, latency percentiles, and error rate for that run.
|
||||
2. **Given** the same setup, **When** the same load profile is sent to the AI support flow and
|
||||
to the admin reporting endpoints, **Then** an equivalent report is produced for each,
|
||||
allowing the three to be compared against each other and against future runs.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a concurrent assignment race includes a ticket that is simultaneously being
|
||||
closed or reopened? The assignment/reassignment safeguard must not be bypassable by a
|
||||
status change racing the same window.
|
||||
- What happens when a pause and a breach both become due at the exact same instant? The final
|
||||
state must reflect one coherent, auditable outcome, not an unresolvable both-happened state.
|
||||
- What happens when the load test itself pushes an endpoint into its own rate limiter (e.g. the
|
||||
013-auth-hardening login rate limit, or the SaaS integration per-minute rate limits)? The
|
||||
report must distinguish "rejected by design (rate limit)" from "failed under load" rather than
|
||||
counting both as the same kind of failure.
|
||||
- What happens when two of these races are exercised back-to-back against the same throwaway
|
||||
database without cleanup? Each test must use its own uniquely-identified fixtures so repeated
|
||||
runs (and CI re-runs) don't produce false positives or false negatives from leftover state.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST guarantee that a ticket never has more than one assignment marked
|
||||
as current, even when multiple assignment operations are attempted concurrently against it.
|
||||
- **FR-002**: The system MUST guarantee that an SLA run's paused/active state and its associated
|
||||
timestamps remain internally consistent when pause, resume, and the breach-detection sweep are
|
||||
triggered concurrently against the same run.
|
||||
- **FR-003**: The system MUST guarantee that the same escalation trigger delivered more than
|
||||
once for the same ticket produces exactly one escalation event and exactly one resulting
|
||||
reassignment.
|
||||
- **FR-004**: The system MUST reject a ticket status update whose expected starting version no
|
||||
longer matches the ticket's actual current version, even when the conflicting updates are
|
||||
concurrent, and MUST leave the ticket in the state produced by whichever single update
|
||||
actually succeeded.
|
||||
- **FR-005**: The system's automated test suite MUST include a dedicated concurrency test for
|
||||
each of FR-001 through FR-004, each exercising genuinely concurrent requests against real,
|
||||
live infrastructure (not mocked timers or sequential calls standing in for concurrency).
|
||||
- **FR-006**: Where a concurrency test written for this feature reveals that a guarantee in
|
||||
FR-001, FR-002, or FR-003 does not currently hold, the underlying race MUST be fixed as part
|
||||
of this feature, not merely documented.
|
||||
- **FR-007**: The system MUST provide repeatable load-test tooling covering, at minimum: new
|
||||
support request submission, the AI support flow, and the admin reporting dashboard endpoints.
|
||||
- **FR-008**: Each load test run MUST produce a report including throughput, latency
|
||||
percentiles, and error rate, with rate-limited responses reported separately from failures.
|
||||
- **FR-009**: Pass/fail thresholds for the load tests (target throughput, acceptable latency,
|
||||
acceptable error rate) MUST be explicitly marked as `OPEN BUSINESS DECISION` wherever the
|
||||
business has not already specified a number, per this project's own roadmap convention —
|
||||
never hardcoded as if final.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Assignment race scenario**: A reusable test setup representing "many concurrent attempts to
|
||||
assign or reassign the same ticket," used to exercise FR-001.
|
||||
- **SLA race scenario**: A reusable test setup representing "concurrent pause, resume, and sweep
|
||||
activity against the same SLA run," used to exercise FR-002.
|
||||
- **Escalation race scenario**: A reusable test setup representing "the same escalation trigger
|
||||
delivered more than once for the same ticket," used to exercise FR-003.
|
||||
- **Load test report**: The recorded output of a load-test run against one endpoint group —
|
||||
throughput, latency percentiles, error rate, and rate-limited-response count — kept so a
|
||||
future run can be compared against it.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A test run that fires at least 20 genuinely concurrent assignment attempts at the
|
||||
same ticket always results in exactly one current assignment, with zero exceptions across at
|
||||
least 10 repeated runs.
|
||||
- **SC-002**: A test run that fires concurrent pause/resume/sweep activity against the same SLA
|
||||
run always leaves that run in one internally-consistent, auditable state, with zero
|
||||
contradictory-state outcomes across at least 10 repeated runs.
|
||||
- **SC-003**: A test run that delivers the same escalation trigger twice for the same ticket
|
||||
always results in exactly one escalation event and exactly one reassignment, with zero
|
||||
duplicate outcomes across at least 10 repeated runs.
|
||||
- **SC-004**: A test run that fires at least 20 genuinely concurrent status-update attempts from
|
||||
the same starting version against the same ticket always results in exactly one success and
|
||||
the ticket left in that one succeeding state.
|
||||
- **SC-005**: A load-test report exists for each of the three named endpoint groups (ticket
|
||||
creation, AI support flow, admin reporting), each independently re-runnable on demand and
|
||||
producing consistent-shape output for comparison across runs.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Ticket status optimistic concurrency (User Story 4) already has a real defensive mechanism in
|
||||
the codebase (a version-checked atomic update) — this feature's job for that story is to prove
|
||||
it under genuine concurrency with a new test, not to build new protection, unless that test
|
||||
surprises this assumption and reveals a real gap.
|
||||
- Assignment double-assignment, SLA pause/resume races, and escalation duplicate-event risk (User
|
||||
Stories 1-3) are NOT currently guarded against — this feature's job for those stories is both
|
||||
to prove the gap with a real concurrency test and to implement the fix, per FR-006.
|
||||
- "Genuinely concurrent" means real parallel requests issued against a real running instance of
|
||||
the API backed by real Postgres/Redis (this project's standing verification discipline
|
||||
throughout every prior feature), not fake-timer or mocked-clock simulations.
|
||||
- Load testing (User Story 5) targets the existing dev/throwaway infrastructure already used for
|
||||
this project's own manual verification, not a separate staging or production environment —
|
||||
provisioning a dedicated load-test environment is out of scope.
|
||||
- Specific throughput/latency/error-rate thresholds for "pass" are an `OPEN BUSINESS DECISION`
|
||||
per FR-009; this feature delivers the tooling and a baseline report, not a final SLA number.
|
||||
- Round-robin assignment-selection counter safety is already covered by an existing genuine
|
||||
concurrency test and is explicitly out of scope for this feature.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
description: "Task list for 016-load-concurrency-testing"
|
||||
---
|
||||
|
||||
# Tasks: Load and Concurrency Testing
|
||||
|
||||
**Input**: Design documents from `specs/016-load-concurrency-testing/`
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = assignment race, US2 = SLA race, US3 =
|
||||
escalation idempotency, US4 = ticket-status race proof, US5 = load-test tooling). US1-US4 share
|
||||
one Foundational phase (the schema migration all four rely on); US5 has no schema dependency and
|
||||
can proceed independently of it.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Add `autocannon` as a devDependency (`package.json`) and add
|
||||
`tests/load/reports/` to `.gitignore` (run artifacts, not fixtures)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites for US1-US4)
|
||||
|
||||
**Purpose**: The one shared schema migration US1, US2, and US3's fixes each depend on. US4 (no
|
||||
schema change, see research.md §4) and US5 (no schema dependency) do not need this phase and can
|
||||
proceed in parallel with it.
|
||||
|
||||
- [x] T002 In `prisma/schema.prisma`, add `version Int @default(0)` to `SLARun`; generate one
|
||||
migration (`npx prisma migrate dev --name concurrency_guards`) that also includes, as raw
|
||||
SQL, `CREATE UNIQUE INDEX assignments_one_current_per_ticket ON assignments (ticket_id)
|
||||
WHERE is_current = true;` and `CREATE UNIQUE INDEX escalation_events_ticket_rule_unique ON
|
||||
escalation_events (ticket_id, rule_id) WHERE rule_id IS NOT NULL;` (data-model.md); apply
|
||||
to the throwaway test Postgres (`supporthub-test-pg`, port 5433) and the real dev Postgres
|
||||
(`postgres-development`, port 5434, via `prisma migrate diff` + direct `psql` per this
|
||||
project's own established non-destructive dev-sync approach); regenerate the Prisma client
|
||||
|
||||
**Checkpoint**: Schema ready — US1, US2, US3 implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Assignment never double-assigned under concurrency (Priority: P1)
|
||||
|
||||
**Goal**: Two concurrent assignment attempts on the same ticket always leave exactly one current
|
||||
assignment.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/assignment-race.test.ts` alone against the
|
||||
throwaway Postgres — it creates its own ticket and needs nothing from US2-US5.
|
||||
|
||||
- [x] T003 [US1] Write `tests/concurrency/assignment-race.test.ts`: create one ticket, fire
|
||||
>=20 genuinely concurrent assignment attempts at it (via the real assignment
|
||||
engine/service entry point, not the repository directly), then query `assignments`
|
||||
directly and assert exactly one row has `is_current = true` for that ticket (depends on
|
||||
T002)
|
||||
- [x] T004 [US1] Fix `AssignmentRepository.createAssignment` in
|
||||
`src/modules/orchestration/assignments/repository/assignment.repository.ts` to catch the
|
||||
`assignments_one_current_per_ticket` unique-violation (Prisma `P2002`) and retry the whole
|
||||
supersede-then-create transaction, bounded to 3 attempts, per research.md §1 (depends on
|
||||
T002)
|
||||
- [x] T005 [US1] Re-run `assignment-race.test.ts` at least 10 times in a row (or extend the test
|
||||
with its own internal repeat loop) confirming zero failures — SC-001 (depends on T003, T004)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - SLA clock never corrupted by overlapping pause/resume/sweep (Priority: P1)
|
||||
|
||||
**Goal**: Concurrent pause/resume/breach-sweep activity against the same SLA run always leaves
|
||||
it in one internally-consistent state.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/sla-race.test.ts` alone against the throwaway
|
||||
Postgres — it creates its own ticket + SLA run and needs nothing from US1/US3/US4/US5.
|
||||
|
||||
- [x] T006 [US2] Replace `SlaRunRepository.update` with `updateWithVersion(id, expectedVersion,
|
||||
data)` in `src/modules/orchestration/sla/repository/sla-run.repository.ts`, mirroring
|
||||
`TicketsRepository.updateStatus`'s atomic `updateMany({where:{id, version:
|
||||
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly (depends on T002)
|
||||
- [x] T007 [US2] Update `pause`, `resume`, `complete`, and `runBreachDetectionSweep` in
|
||||
`src/modules/orchestration/sla/service/sla.service.ts` to call `updateWithVersion` with
|
||||
each run's last-read version, and to re-read + recompute + retry (bounded to 3 attempts)
|
||||
on a version-conflict `null` result, per research.md §2 (depends on T006)
|
||||
- [x] T008 [US2] Write `tests/concurrency/sla-race.test.ts`: create a ticket with an active SLA
|
||||
run, fire concurrent `pause`/`resume` calls and a `runBreachDetectionSweep()` pass against
|
||||
it, then query the run directly and assert its final state is internally consistent (never
|
||||
`paused` with `pausedAt: null`, never a legitimately `breached` run silently reverted to
|
||||
`running`) (depends on T007)
|
||||
- [x] T009 [US2] Re-run `sla-race.test.ts` at least 10 times confirming zero
|
||||
contradictory-state outcomes — SC-002 (depends on T008)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - An escalation trigger fired twice never duplicates (Priority: P1)
|
||||
|
||||
**Goal**: The same escalation trigger delivered twice for the same ticket always results in
|
||||
exactly one escalation event and one reassignment.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/escalation-idempotency.test.ts` alone against the
|
||||
throwaway Postgres — it creates its own ticket + escalation rule and needs nothing from
|
||||
US1/US2/US4/US5 (though it exercises the same `Assignment` table US1 protects, as a
|
||||
cross-check).
|
||||
|
||||
- [x] T010 [US3] Fix `EscalationEventRepository.create` in
|
||||
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` to catch
|
||||
the `escalation_events_ticket_rule_unique` unique-violation (Prisma `P2002`) and return
|
||||
the pre-existing row for that `(ticketId, ruleId)` pair via a `findFirst` fallback instead
|
||||
of throwing, per research.md §3 (depends on T002)
|
||||
- [x] T011 [US3] Confirm `EscalationService.fire` in
|
||||
`src/modules/orchestration/escalation/service/escalation.service.ts` behaves correctly
|
||||
when `create` returns a pre-existing event (it must not also re-run
|
||||
`assignToSpecificNode` for a duplicate trigger) — adjust `fire` if needed so a
|
||||
duplicate-conflict short-circuits before reassignment (depends on T010)
|
||||
- [x] T012 [US3] Write `tests/concurrency/escalation-idempotency.test.ts`: create a ticket
|
||||
eligible for a specific escalation rule, call the real trigger path (e.g.
|
||||
`escalationService.handleBreach`) twice concurrently for the identical trigger, then query
|
||||
`escalation_events` and `assignments` directly and assert exactly one of each resulted
|
||||
(depends on T011)
|
||||
- [x] T013 [US3] Re-run `escalation-idempotency.test.ts` at least 10 times confirming zero
|
||||
duplicate outcomes — SC-003 (depends on T012)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Ticket status optimistic concurrency, proven (Priority: P2)
|
||||
|
||||
**Goal**: Prove the existing version-checked ticket-status update holds under genuine
|
||||
concurrency.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/ticket-status-race.test.ts` alone against the
|
||||
throwaway Postgres — no dependency on T002 or any other user story (research.md §4: no
|
||||
implementation change expected).
|
||||
|
||||
- [x] T014 [US4] Write `tests/concurrency/ticket-status-race.test.ts`: create a ticket at a
|
||||
known status/version, fire >=20 genuinely concurrent `ticketsRepository.updateStatus`
|
||||
calls all starting from that same version, and assert exactly one returns the updated
|
||||
ticket while every other call returns `null` — SC-004
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes, confirming the existing mechanism (no fix
|
||||
expected; a failure here would mean research.md's assumption was wrong and needs revisiting).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Repeatable load/throughput baseline (Priority: P2)
|
||||
|
||||
**Goal**: Repeatable `autocannon`-based load-test tooling and a baseline report for the three
|
||||
named critical endpoint groups.
|
||||
|
||||
**Independent Test**: Run each `tests/load/*.load.ts` script alone against a real running dev
|
||||
server — no dependency on T002 or any other user story.
|
||||
|
||||
- [x] T015 [P] [US5] Create `tests/load/autocannon.config.ts`: a shared runner helper wrapping
|
||||
`autocannon`'s programmatic API, producing the report shape from data-model.md
|
||||
(`requestsPerSec`, `latencyP50Ms`/`P90Ms`/`P99Ms`, `non2xxCount`, `rateLimitedCount`),
|
||||
printing a console summary and writing JSON to `tests/load/reports/` (depends on T001)
|
||||
- [x] T016 [P] [US5] Create `tests/load/ticket-creation.load.ts` using the T015 helper against
|
||||
`POST /v1/support/requests` (depends on T015)
|
||||
- [x] T017 [P] [US5] Create `tests/load/ai-support-flow.load.ts` using the T015 helper against
|
||||
the AI support flow's own endpoints (depends on T015)
|
||||
- [x] T018 [P] [US5] Create `tests/load/admin-reporting.load.ts` using the T015 helper, signing
|
||||
in as the seeded admin first, against the 015-reporting-dashboards endpoints (depends on
|
||||
T015)
|
||||
- [x] T019 [US5] Run all three scripts against a real running dev server, confirm each produces
|
||||
a report, and run each twice to confirm consistent-shape output for comparison — SC-005
|
||||
(depends on T016, T017, T018)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 5 passes; a baseline report exists for each endpoint group.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T020 Update `specs/016-load-concurrency-testing/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T021 `npx tsc --noEmit` / `npm run lint` / `npx tsx scripts/check-architecture.ts` clean
|
||||
- [x] T022 Full existing unit + integration + concurrency suite re-run (throwaway DB), confirming
|
||||
no regression in 007-orchestration-assignment's, 008-sla-escalation's,
|
||||
012-admin-list-views's, and 015-reporting-dashboards's own existing coverage of
|
||||
`Assignment`/`SLARun`/`EscalationEvent`
|
||||
- [x] T023 Mark all of this file's checkboxes complete once verified
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **Foundational (Phase 2)**: No dependencies — BLOCKS User Stories 1, 2, 3 only
|
||||
- **User Story 4**: No dependency on Phase 2 or any other story — can start immediately
|
||||
- **User Story 5**: No dependency on Phase 2 or any other story — can start immediately (only
|
||||
needs Phase 1's `autocannon` devDependency)
|
||||
- **User Stories 1, 2, 3**: Each depends only on Phase 2 — independent of each other and of
|
||||
User Stories 4/5
|
||||
- **Polish (Phase 8)**: Depends on all five user stories
|
||||
|
||||
## Parallel Example: Foundational-independent stories
|
||||
|
||||
```text
|
||||
# Once Phase 1 completes, these can start immediately in parallel, without waiting on Phase 2:
|
||||
Task: "Write tests/concurrency/ticket-status-race.test.ts" (US4, T014)
|
||||
Task: "Create tests/load/autocannon.config.ts" (US5, T015)
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested order
|
||||
|
||||
1. Phase 1 (Setup) and Phase 2 (Foundational) — Phase 2 unblocks the three highest-severity
|
||||
real-bug fixes (US1, US2, US3)
|
||||
2. User Stories 1, 2, 3 (all P1) — each is a real, currently-unguarded race; fix and prove each
|
||||
in turn, or in parallel across files since they touch different modules
|
||||
3. User Story 4 (P2) — quick to add, proves existing protection, can be done any time after
|
||||
Phase 1
|
||||
4. User Story 5 (P2) — independent tooling work, can be done any time after Phase 1, in parallel
|
||||
with 1-4
|
||||
5. Phase 8 (Polish) once all five stories are verified
|
||||
@@ -15,9 +15,20 @@ 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';
|
||||
import { investigationRoutes } from '@/modules/problem-management/investigation';
|
||||
import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
|
||||
import { solutionsRoutes } from '@/modules/problem-management/solutions';
|
||||
import { verificationRoutes } from '@/modules/problem-management/verification';
|
||||
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
|
||||
import { authRoutes } from '@/modules/identity/auth';
|
||||
import { reportsRoutes } from '@/modules/platform/reports';
|
||||
|
||||
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(metricsRoutes);
|
||||
await app.register(productsRoutes);
|
||||
await app.register(inboundRequestRoutes);
|
||||
@@ -31,5 +42,14 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
|
||||
await app.register(agentsRoutes);
|
||||
await app.register(hierarchyRoutes);
|
||||
await app.register(assignmentsRoutes);
|
||||
await app.register(businessCalendarsRoutes);
|
||||
await app.register(slaRoutes);
|
||||
await app.register(escalationRoutes);
|
||||
await app.register(investigationRoutes);
|
||||
await app.register(rootCausesRoutes);
|
||||
await app.register(solutionsRoutes);
|
||||
await app.register(verificationRoutes);
|
||||
await app.register(resolutionsRoutes);
|
||||
await app.register(reportsRoutes);
|
||||
// Further domain module routes will be registered here as feature modules are wired up
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { logger } from '@/infrastructure/observability';
|
||||
import { registerAttachmentWorker } from '@/jobs/attachments';
|
||||
import { registerAiSessionWorker } from '@/jobs/ai-session';
|
||||
import { registerSlaWorker } from '@/jobs/sla';
|
||||
import { registerCleanupWorker } from '@/jobs/cleanup';
|
||||
|
||||
export async function bootstrapQueue(): Promise<void> {
|
||||
registerAttachmentWorker();
|
||||
registerAiSessionWorker();
|
||||
registerSlaWorker();
|
||||
registerCleanupWorker();
|
||||
logger.info('Queue Manager initialized.');
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface JwtPayload {
|
||||
email: string;
|
||||
role: string;
|
||||
actorType: ActorType;
|
||||
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const authConfig = {
|
||||
jwtSecret: env.JWT_SECRET,
|
||||
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
|
||||
passwordMinLength: env.PASSWORD_MIN_LENGTH,
|
||||
passwordResetTokenLifetimeMinutes: env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
|
||||
loginRateLimitMaxAttempts: env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
loginRateLimitWindowSeconds: env.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
};
|
||||
@@ -55,6 +55,38 @@ const envSchema = z.object({
|
||||
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
|
||||
// II), consistent with every other policy default in this codebase.
|
||||
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
|
||||
|
||||
// Problem Resolution (009) — how long a ticket waits in RESOLUTION_PENDING_CUSTOMER with no
|
||||
// explicit customer confirmation before the auto-close sweep resolves it — see
|
||||
// specs/009-problem-resolution/research.md "auto-close waiting period".
|
||||
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
|
||||
|
||||
// Identity and Authentication (010) — token lifetime; signing itself reuses the existing,
|
||||
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
|
||||
// until now) — see specs/010-identity-auth/research.md.
|
||||
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
|
||||
|
||||
// Authentication Hardening (013) — password-strength policy, reset-token lifetime, and
|
||||
// login rate-limiting, all CONFIGURABLE per docs/10-implementation-roadmap.md's own
|
||||
// "never hardcode a placeholder value and ship it as final" instruction — see
|
||||
// specs/013-auth-hardening/research.md.
|
||||
PASSWORD_MIN_LENGTH: z.coerce.number().default(10),
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().default(30),
|
||||
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.coerce.number().default(5),
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().default(300),
|
||||
|
||||
// Full Observability (014) — the OpenTelemetry project's own standard env var name (not
|
||||
// invented here) for the collector endpoint spans are exported to. Unset means "no collector
|
||||
// configured" — tracing still runs, just exports to the console instead (never a startup
|
||||
// requirement) — see specs/014-full-observability/research.md "Distributed tracing".
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||||
|
||||
// Reporting and Analytics Dashboards (015) — CONFIGURABLE per docs/10-implementation-
|
||||
// roadmap.md's own "never hardcode a placeholder value and ship it as final" instruction —
|
||||
// see specs/015-reporting-dashboards/research.md §8.
|
||||
REPORTING_DEFAULT_WINDOW_DAYS: z.coerce.number().default(30),
|
||||
REPORTING_SLA_RISK_THRESHOLD_MINUTES: z.coerce.number().default(60),
|
||||
REPORTING_TOP_N_LIMIT: z.coerce.number().default(10),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -5,3 +5,6 @@ export * from './queue';
|
||||
export * from './storage';
|
||||
export * from './ai';
|
||||
export * from './orchestration';
|
||||
export * from './problem-resolution';
|
||||
export * from './auth';
|
||||
export * from './reporting';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const problemResolutionConfig = {
|
||||
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const reportingConfig = {
|
||||
defaultWindowDays: env.REPORTING_DEFAULT_WINDOW_DAYS,
|
||||
slaRiskThresholdMinutes: env.REPORTING_SLA_RISK_THRESHOLD_MINUTES,
|
||||
topNLimit: env.REPORTING_TOP_N_LIMIT,
|
||||
};
|
||||
@@ -1,8 +1,18 @@
|
||||
import { SpanStatusCode } from '@opentelemetry/api';
|
||||
import { eventBus } from '../event-bus';
|
||||
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';
|
||||
import { ticketsService } from '@/modules/ticketing/tickets';
|
||||
import { resolutionRepository } from '@/modules/problem-management/resolutions';
|
||||
import {
|
||||
getTracer,
|
||||
ticketResolutionsCounter,
|
||||
ticketResolutionDurationHistogram,
|
||||
escalationsCounter,
|
||||
} from '@/infrastructure/observability';
|
||||
|
||||
interface TicketUpdatedPayload {
|
||||
ticketId: string;
|
||||
@@ -11,6 +21,21 @@ interface TicketUpdatedPayload {
|
||||
newStatus: string;
|
||||
}
|
||||
|
||||
interface TicketAssignedPayload {
|
||||
ticketId: string;
|
||||
agentId: string;
|
||||
strategy: string;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
interface EscalationTriggeredPayload {
|
||||
ticketId: string;
|
||||
ruleId: string;
|
||||
targetNodeId: string;
|
||||
actor: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
@@ -42,7 +67,88 @@ export function registerDomainEventHandlers(): void {
|
||||
DomainEventName.TICKET_UPDATED,
|
||||
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||
if (event.payload.newStatus !== 'HUMAN_ESCALATION') return;
|
||||
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
|
||||
// 014-full-observability data-model.md: nests under session.service.ts's `ai.escalation`
|
||||
// span when this fired from that same await chain (an escalation triggered some other way
|
||||
// — e.g. a direct admin action — still gets its own root span here, never left untraced).
|
||||
await getTracer().startActiveSpan(
|
||||
'orchestration.assignment',
|
||||
{ attributes: { 'ticket.id': event.payload.ticketId } },
|
||||
async (span) => {
|
||||
try {
|
||||
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 014-full-observability data-model.md #3/#4: human-vs-AI resolution and resolution-time,
|
||||
// read off the Resolution row's own resolvedBy ("ai" | agentId — see prisma/schema.prisma)
|
||||
// rather than duplicating that distinction here.
|
||||
eventBus.subscribe(
|
||||
DomainEventName.TICKET_UPDATED,
|
||||
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||
if (event.payload.newStatus !== 'RESOLVED') return;
|
||||
const [ticket, resolution] = await Promise.all([
|
||||
ticketsService.getById(event.payload.ticketId),
|
||||
resolutionRepository.findByTicketId(event.payload.ticketId),
|
||||
]);
|
||||
if (!resolution) return;
|
||||
|
||||
ticketResolutionsCounter.inc({
|
||||
resolved_by: resolution.resolvedBy === 'ai' ? 'ai' : 'human',
|
||||
});
|
||||
ticketResolutionDurationHistogram.observe((Date.now() - ticket.createdAt.getTime()) / 1000);
|
||||
},
|
||||
);
|
||||
|
||||
// 008-sla-escalation research.md "SLA-run lifecycle is wired entirely through the existing
|
||||
// domain-event bus": TICKET_ASSIGNED was defined since 007-orchestration-assignment but never
|
||||
// published until now (assignment.engine.ts's persistAndTransition). Idempotent — no-ops if
|
||||
// the ticket already has a run (SLARun.ticketId @unique).
|
||||
eventBus.subscribe(
|
||||
DomainEventName.TICKET_ASSIGNED,
|
||||
async (event: BaseDomainEvent<TicketAssignedPayload>) => {
|
||||
await slaService.handleTicketAssigned(event.payload.ticketId);
|
||||
},
|
||||
);
|
||||
|
||||
// 008-sla-escalation FR-007/FR-008: pause on entering WAITING_FOR_CUSTOMER, resume on leaving
|
||||
// it — durable (a DB timestamp shift), never an in-memory timer (Constitution Principle VII).
|
||||
eventBus.subscribe(
|
||||
DomainEventName.TICKET_UPDATED,
|
||||
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||
if (event.payload.newStatus === 'WAITING_FOR_CUSTOMER') {
|
||||
await slaService.pause(event.payload.ticketId);
|
||||
} else if (event.payload.previousStatus === 'WAITING_FOR_CUSTOMER') {
|
||||
await slaService.resume(event.payload.ticketId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 008-sla-escalation FR-010: a ticket reaching RESOLVED (003-ticketing's terminal status before
|
||||
// CLOSED/REOPENED) completes its SLA run — never later marked breached.
|
||||
eventBus.subscribe(
|
||||
DomainEventName.TICKET_UPDATED,
|
||||
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||
if (event.payload.newStatus !== 'RESOLVED') return;
|
||||
await slaService.complete(event.payload.ticketId);
|
||||
},
|
||||
);
|
||||
|
||||
// 014-full-observability data-model.md #7: ESCALATION_TRIGGERED has been published
|
||||
// unconditionally on every escalation since 008-sla-escalation ("for audit, not for logic" —
|
||||
// escalation.service.ts's own comment) but had zero subscribers until now.
|
||||
eventBus.subscribe(
|
||||
DomainEventName.ESCALATION_TRIGGERED,
|
||||
async (event: BaseDomainEvent<EscalationTriggeredPayload>) => {
|
||||
escalationsCounter.inc({ reason: event.payload.reason });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { cacheService } from './cache.service';
|
||||
|
||||
const REVOKED_KEY_PREFIX = 'auth:revoked:';
|
||||
|
||||
/**
|
||||
* Explicit-logout revocation for staff session tokens (specs/010-identity-auth/research.md
|
||||
* "Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism"). A jti is
|
||||
* denylisted only until its own token would have expired anyway, so the set never grows
|
||||
* unbounded — the same shape as replay-guard.ts's hasSeenJti/markJtiSeen.
|
||||
*/
|
||||
export async function isTokenRevoked(jti: string): Promise<boolean> {
|
||||
return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`);
|
||||
}
|
||||
|
||||
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
|
||||
await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds);
|
||||
}
|
||||
Vendored
+1
@@ -2,3 +2,4 @@ export * from './redis.client';
|
||||
export * from './cache.service';
|
||||
export * from './replay-guard';
|
||||
export * from './rate-limiter';
|
||||
export * from './auth-revocation';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user