Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700daf4104 | ||
|
|
249e7cd0ce | ||
|
|
2034966d6d | ||
|
|
7948182988 | ||
|
|
3bd068b031 | ||
|
|
43158ff0c4 | ||
|
|
2b00b6d6a1 | ||
|
|
49db40d7c1 | ||
|
|
fb9606b6aa | ||
|
|
d574af087a | ||
|
|
23fadebb5c | ||
|
|
d58bc98c7f | ||
|
|
40687f68fa | ||
|
|
8327fafac2 | ||
|
|
3b4c220a45 | ||
|
|
a49389dc2c | ||
|
|
4e0c90e2cf | ||
|
|
d9bd970910 | ||
|
|
16daf8d32d | ||
|
|
aaa51ef475 | ||
|
|
9ce34d8ca4 | ||
|
|
14c6793460 | ||
|
|
7e3d2ae29f | ||
|
|
9357f03e1d | ||
|
|
bb31e9d641 | ||
|
|
199bd4eb4e | ||
|
|
1ad9007e79 | ||
|
|
10f59a7d5b |
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,66 @@
|
|||||||
### Development
|
# SupportHub API
|
||||||
- docker compose --env-file .env.development -f docker-compose.development.yml up -d --build
|
|
||||||
|
|
||||||
### Test
|
### Development (Docker)
|
||||||
- docker compose --env-file .env.test -f docker-compose.test.yml up --build
|
- Start all services: `docker compose --env-file .env.development -f docker-compose.development.yml up -d --build`
|
||||||
|
- Start only database & cache (for local app development): `docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis`
|
||||||
|
|
||||||
### Production
|
### Test (Docker)
|
||||||
- docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
|
- `docker compose --env-file .env.test -f docker-compose.test.yml up --build`
|
||||||
|
|
||||||
### Stop
|
### Production (Docker)
|
||||||
- docker compose -f docker-compose.prod.yml down
|
- `docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d`
|
||||||
|
|
||||||
### list containers
|
### Stop / Down
|
||||||
- docker compose --env-file .env.development -f docker-compose.development.yml ps
|
- Stop production: `docker compose -f docker-compose.prod.yml down`
|
||||||
|
- Stop development: `docker compose -f docker-compose.development.yml down`
|
||||||
|
- Stop development & wipe volumes: `docker compose --env-file .env.development -f docker-compose.development.yml down -v`
|
||||||
|
|
||||||
|
### List Containers & Logs
|
||||||
|
- List containers: `docker compose --env-file .env.development -f docker-compose.development.yml ps`
|
||||||
|
- Follow logs: `docker compose --env-file .env.development -f docker-compose.development.yml logs -f`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Local Development (Host)
|
||||||
|
1. Start database & cache in Docker:
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis
|
||||||
|
```
|
||||||
|
2. Start API server in watch mode:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database Migrations & Prisma
|
||||||
|
|
||||||
|
- **Generate Prisma Client**:
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Run / Apply Dev Migrations**:
|
||||||
|
```bash
|
||||||
|
npx dotenv-cli -e .env.development -- npm run prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Deploy Migrations (Production/CI)**:
|
||||||
|
```bash
|
||||||
|
npx dotenv-cli -e .env.development -- npm run prisma:deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Push Schema directly (Sync schema without migration files)**:
|
||||||
|
```bash
|
||||||
|
npx dotenv-cli -e .env.development -- npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database Seeding
|
||||||
|
|
||||||
|
- **Seed Database (Roles, Products, Categories, Hierarchy & Demo data)**:
|
||||||
|
```bash
|
||||||
|
npx dotenv-cli -e .env.development -- npm run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
### logs
|
|
||||||
- docker compose --env-file .env.development -f docker-compose.development.yml logs -f
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -43,6 +41,9 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_development_data:/var/lib/postgresql
|
- postgres_development_data:/var/lib/postgresql
|
||||||
|
|
||||||
@@ -68,6 +69,9 @@ services:
|
|||||||
- --requirepass
|
- --requirepass
|
||||||
- ${REDIS_PASSWORD}
|
- ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_development_data:/data
|
- redis_development_data:/data
|
||||||
|
|
||||||
@@ -86,33 +90,6 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_development_data:
|
postgres_development_data:
|
||||||
redis_development_data:
|
redis_development_data:
|
||||||
minio_development_data:
|
|
||||||
+7
-24
@@ -27,8 +27,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -41,6 +39,9 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_test_data:/var/lib/postgresql
|
- postgres_test_data:/var/lib/postgresql
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ services:
|
|||||||
- --requirepass
|
- --requirepass
|
||||||
- ${REDIS_PASSWORD}
|
- ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_test_data:/data
|
- redis_test_data:/data
|
||||||
|
|
||||||
@@ -82,27 +86,6 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_test_data:
|
postgres_test_data:
|
||||||
redis_test_data:
|
redis_test_data:
|
||||||
minio_test_data:
|
|
||||||
Generated
+147
@@ -19,17 +19,23 @@
|
|||||||
"@opentelemetry/api": "^1.8.0",
|
"@opentelemetry/api": "^1.8.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||||
"@prisma/client": "^5.12.1",
|
"@prisma/client": "^5.12.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.7.1",
|
"bullmq": "^5.7.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.26.2",
|
"fastify": "^4.26.2",
|
||||||
"fastify-plugin": "^4.5.1",
|
"fastify-plugin": "^4.5.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"pino": "^8.20.0",
|
"pino": "^8.20.0",
|
||||||
"pino-pretty": "^11.0.0",
|
"pino-pretty": "^11.0.0",
|
||||||
"prom-client": "^15.1.1",
|
"prom-client": "^15.1.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/luxon": "^3.7.5",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||||
"@typescript-eslint/parser": "^7.6.0",
|
"@typescript-eslint/parser": "^7.6.0",
|
||||||
@@ -1966,6 +1972,13 @@
|
|||||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1973,6 +1986,31 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
|
||||||
|
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
@@ -2538,6 +2576,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -2609,6 +2656,12 @@
|
|||||||
"ieee754": "^1.2.1"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.81.3",
|
"version": "5.81.3",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
|
||||||
@@ -3061,6 +3114,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@@ -4310,6 +4372,49 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4535,6 +4640,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -4542,6 +4683,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-update": {
|
"node_modules/log-update": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||||
|
|||||||
@@ -56,17 +56,23 @@
|
|||||||
"@opentelemetry/api": "^1.8.0",
|
"@opentelemetry/api": "^1.8.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||||
"@prisma/client": "^5.12.1",
|
"@prisma/client": "^5.12.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.7.1",
|
"bullmq": "^5.7.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.26.2",
|
"fastify": "^4.26.2",
|
||||||
"fastify-plugin": "^4.5.1",
|
"fastify-plugin": "^4.5.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"pino": "^8.20.0",
|
"pino": "^8.20.0",
|
||||||
"pino-pretty": "^11.0.0",
|
"pino-pretty": "^11.0.0",
|
||||||
"prom-client": "^15.1.1",
|
"prom-client": "^15.1.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/luxon": "^3.7.5",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||||
"@typescript-eslint/parser": "^7.6.0",
|
"@typescript-eslint/parser": "^7.6.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");
|
||||||
+238
-9
@@ -14,12 +14,17 @@ enum UserRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
role UserRole @default(CUSTOMER)
|
role UserRole @default(CUSTOMER)
|
||||||
createdAt DateTime @default(now())
|
passwordHash String // bcryptjs hash — never the plaintext password; see
|
||||||
updatedAt DateTime @updatedAt
|
// specs/010-identity-auth/data-model.md
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
agent Agent?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -43,6 +48,8 @@ model Product {
|
|||||||
knownIssues KnownIssue[]
|
knownIssues KnownIssue[]
|
||||||
runbooks Runbook[]
|
runbooks Runbook[]
|
||||||
aiConfidencePolicies AIConfidencePolicy[]
|
aiConfidencePolicies AIConfidencePolicy[]
|
||||||
|
slaPolicies SLAPolicy[]
|
||||||
|
escalationPolicies EscalationPolicy[]
|
||||||
|
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
@@ -90,9 +97,10 @@ model Category {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
problems Problem[]
|
problems Problem[]
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
|
slaPolicies SLAPolicy[]
|
||||||
|
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
@@ -114,6 +122,10 @@ model Problem {
|
|||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
|
|
||||||
|
investigations Investigation[]
|
||||||
|
rootCauses RootCause[]
|
||||||
|
solutions Solution[]
|
||||||
|
|
||||||
@@map("problems")
|
@@map("problems")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +157,9 @@ model Ticket {
|
|||||||
aiSessions AISupportSession[]
|
aiSessions AISupportSession[]
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
assignmentHistory AssignmentHistory[]
|
assignmentHistory AssignmentHistory[]
|
||||||
|
slaRun SLARun?
|
||||||
|
escalationEvents EscalationEvent[]
|
||||||
|
resolution Resolution?
|
||||||
|
|
||||||
@@unique([productId, idempotencyKey])
|
@@unique([productId, idempotencyKey])
|
||||||
@@index([productId, status])
|
@@index([productId, status])
|
||||||
@@ -407,6 +422,11 @@ model Agent {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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[]
|
skills AgentSkill[]
|
||||||
availability AgentAvailability?
|
availability AgentAvailability?
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
@@ -465,6 +485,8 @@ model HierarchyNode {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
escalationRules EscalationRule[]
|
||||||
|
|
||||||
@@index([parentId, order])
|
@@index([parentId, order])
|
||||||
@@index([active])
|
@@index([active])
|
||||||
@@map("hierarchy_nodes")
|
@@map("hierarchy_nodes")
|
||||||
@@ -485,6 +507,7 @@ model Assignment {
|
|||||||
unassignedAt DateTime?
|
unassignedAt DateTime?
|
||||||
|
|
||||||
@@index([ticketId, isCurrent])
|
@@index([ticketId, isCurrent])
|
||||||
|
@@index([agentId, isCurrent])
|
||||||
@@map("assignments")
|
@@map("assignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,3 +525,209 @@ model AssignmentHistory {
|
|||||||
@@index([ticketId, createdAt])
|
@@index([ticketId, createdAt])
|
||||||
@@map("assignment_history")
|
@@map("assignment_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SLAPolicy {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
productId String? // wildcard when null — see data-model.md "Resolution"
|
||||||
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
|
categoryId String?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
problemTypeId String? // free-text — no ProblemType table exists in this codebase
|
||||||
|
priority String? // free-text, matches Ticket.priority
|
||||||
|
|
||||||
|
firstResponseMinutes Int
|
||||||
|
investigationMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
|
||||||
|
resolutionMinutes Int
|
||||||
|
customerResponseMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
|
||||||
|
|
||||||
|
businessCalendarId String? // null = 24/7, no exclusions — an explicit policy choice
|
||||||
|
businessCalendar BusinessCalendar? @relation(fields: [businessCalendarId], references: [id])
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
slaRuns SLARun[]
|
||||||
|
|
||||||
|
@@index([productId, categoryId, active])
|
||||||
|
@@map("sla_policies")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SLARun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ticketId String @unique // one run per ticket — no reopen-cycle support (spec.md Assumptions)
|
||||||
|
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||||
|
policyId String
|
||||||
|
policy SLAPolicy @relation(fields: [policyId], references: [id])
|
||||||
|
|
||||||
|
firstResponseDueAt DateTime?
|
||||||
|
resolutionDueAt DateTime?
|
||||||
|
status String // running | paused | warning | breached | completed
|
||||||
|
|
||||||
|
pausedAt DateTime?
|
||||||
|
resumedAt DateTime?
|
||||||
|
|
||||||
|
breachedAt DateTime?
|
||||||
|
// Additive refinement beyond doc06 (research.md/data-model.md): records a first-response
|
||||||
|
// breach separately from the resolution-timer breach status above, and doubles as the
|
||||||
|
// idempotency guard for the breach-detection sweep (never re-fires on the same run).
|
||||||
|
firstResponseBreachedAt DateTime?
|
||||||
|
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
@@index([status, resolutionDueAt])
|
||||||
|
@@index([status, firstResponseDueAt])
|
||||||
|
@@map("sla_runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessCalendar {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
timezone String // IANA zone name, e.g. "America/New_York"
|
||||||
|
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
|
||||||
|
|
||||||
|
holidays Holiday[]
|
||||||
|
policies SLAPolicy[]
|
||||||
|
|
||||||
|
@@map("business_calendars")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Holiday {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
calendarId String
|
||||||
|
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime // compared by calendar date only, in the calendar's own timezone
|
||||||
|
description String?
|
||||||
|
|
||||||
|
@@index([calendarId, date])
|
||||||
|
@@map("holidays")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EscalationPolicy {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
|
||||||
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
rules EscalationRule[]
|
||||||
|
|
||||||
|
@@index([productId, active])
|
||||||
|
@@map("escalation_policies")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EscalationRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
policyId String
|
||||||
|
policy EscalationPolicy @relation(fields: [policyId], references: [id])
|
||||||
|
|
||||||
|
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
|
||||||
|
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
|
||||||
|
// (research.md)
|
||||||
|
condition Json // stored, not evaluated, by this feature (research.md)
|
||||||
|
|
||||||
|
targetNodeId String
|
||||||
|
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
|
||||||
|
|
||||||
|
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([policyId, triggerType, active])
|
||||||
|
@@map("escalation_rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EscalationEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ticketId String
|
||||||
|
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||||
|
|
||||||
|
ruleId String? // null for a manual escalation or a breach with no matching rule
|
||||||
|
fromNodeId String?
|
||||||
|
toNodeId String?
|
||||||
|
|
||||||
|
reason String
|
||||||
|
triggeredBy String // system | <agentId> | <adminId>
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([ticketId, createdAt])
|
||||||
|
@@map("escalation_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { PrismaClient, UserRole } from '@prisma/client';
|
import { PrismaClient, UserRole } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(' -> Seeding demo environment data...');
|
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({
|
await prisma.user.upsert({
|
||||||
where: { email: 'john.doe@example.com' },
|
where: { email: 'john.doe@example.com' },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
|||||||
email: 'john.doe@example.com',
|
email: 'john.doe@example.com',
|
||||||
name: 'John Doe (Demo Customer)',
|
name: 'John Doe (Demo Customer)',
|
||||||
role: UserRole.CUSTOMER,
|
role: UserRole.CUSTOMER,
|
||||||
|
passwordHash: await bcrypt.hash(randomUUID(), 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { PrismaClient, UserRole } from '@prisma/client';
|
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> {
|
export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
|||||||
email: 'admin@supporthub.internal',
|
email: 'admin@supporthub.internal',
|
||||||
name: 'System Admin',
|
name: 'System Admin',
|
||||||
role: UserRole.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',
|
email: 'agent@supporthub.internal',
|
||||||
name: 'Default Support Agent',
|
name: 'Default Support Agent',
|
||||||
role: UserRole.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
|
||||||
@@ -15,9 +15,19 @@ import { teamsRoutes } from '@/modules/identity/teams';
|
|||||||
import { agentsRoutes } from '@/modules/identity/agents';
|
import { agentsRoutes } from '@/modules/identity/agents';
|
||||||
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
|
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
|
||||||
import { assignmentsRoutes } from '@/modules/orchestration/assignments';
|
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';
|
||||||
|
|
||||||
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
|
await app.register(authRoutes);
|
||||||
await app.register(metricsRoutes);
|
await app.register(metricsRoutes);
|
||||||
await app.register(productsRoutes);
|
await app.register(productsRoutes);
|
||||||
await app.register(inboundRequestRoutes);
|
await app.register(inboundRequestRoutes);
|
||||||
@@ -31,5 +41,13 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
|
|||||||
await app.register(agentsRoutes);
|
await app.register(agentsRoutes);
|
||||||
await app.register(hierarchyRoutes);
|
await app.register(hierarchyRoutes);
|
||||||
await app.register(assignmentsRoutes);
|
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);
|
||||||
// Further domain module routes will be registered here as feature modules are wired up
|
// Further domain module routes will be registered here as feature modules are wired up
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { logger } from '@/infrastructure/observability';
|
import { logger } from '@/infrastructure/observability';
|
||||||
import { registerAttachmentWorker } from '@/jobs/attachments';
|
import { registerAttachmentWorker } from '@/jobs/attachments';
|
||||||
import { registerAiSessionWorker } from '@/jobs/ai-session';
|
import { registerAiSessionWorker } from '@/jobs/ai-session';
|
||||||
|
import { registerSlaWorker } from '@/jobs/sla';
|
||||||
|
import { registerCleanupWorker } from '@/jobs/cleanup';
|
||||||
|
|
||||||
export async function bootstrapQueue(): Promise<void> {
|
export async function bootstrapQueue(): Promise<void> {
|
||||||
registerAttachmentWorker();
|
registerAttachmentWorker();
|
||||||
registerAiSessionWorker();
|
registerAiSessionWorker();
|
||||||
|
registerSlaWorker();
|
||||||
|
registerCleanupWorker();
|
||||||
logger.info('Queue Manager initialized.');
|
logger.info('Queue Manager initialized.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface JwtPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
actorType: ActorType;
|
actorType: ActorType;
|
||||||
|
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
jwtSecret: env.JWT_SECRET,
|
||||||
|
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
|
||||||
|
};
|
||||||
@@ -55,6 +55,16 @@ const envSchema = z.object({
|
|||||||
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
|
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
|
||||||
// II), consistent with every other policy default in this codebase.
|
// II), consistent with every other policy default in this codebase.
|
||||||
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
|
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),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EnvConfig = z.infer<typeof envSchema>;
|
export type EnvConfig = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export * from './queue';
|
|||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './ai';
|
export * from './ai';
|
||||||
export * from './orchestration';
|
export * from './orchestration';
|
||||||
|
export * from './problem-resolution';
|
||||||
|
export * from './auth';
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const problemResolutionConfig = {
|
||||||
|
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { DomainEventName } from '../domain-events';
|
|||||||
import { BaseDomainEvent } from '../event-types';
|
import { BaseDomainEvent } from '../event-types';
|
||||||
import { sessionsService } from '@/modules/ai-support/sessions';
|
import { sessionsService } from '@/modules/ai-support/sessions';
|
||||||
import { orchestrationService } from '@/modules/orchestration/orchestration';
|
import { orchestrationService } from '@/modules/orchestration/orchestration';
|
||||||
|
import { slaService } from '@/modules/orchestration/sla';
|
||||||
|
|
||||||
interface TicketUpdatedPayload {
|
interface TicketUpdatedPayload {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
@@ -11,6 +12,13 @@ interface TicketUpdatedPayload {
|
|||||||
newStatus: string;
|
newStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TicketAssignedPayload {
|
||||||
|
ticketId: string;
|
||||||
|
agentId: string;
|
||||||
|
strategy: string;
|
||||||
|
actor: string;
|
||||||
|
}
|
||||||
|
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,4 +53,38 @@ export function registerDomainEventHandlers(): void {
|
|||||||
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
|
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 008-sla-escalation research.md "SLA-run lifecycle is wired entirely through the existing
|
||||||
|
// domain-event bus": TICKET_ASSIGNED was defined since 007-orchestration-assignment but never
|
||||||
|
// published until now (assignment.engine.ts's persistAndTransition). Idempotent — no-ops if
|
||||||
|
// the ticket already has a run (SLARun.ticketId @unique).
|
||||||
|
eventBus.subscribe(
|
||||||
|
DomainEventName.TICKET_ASSIGNED,
|
||||||
|
async (event: BaseDomainEvent<TicketAssignedPayload>) => {
|
||||||
|
await slaService.handleTicketAssigned(event.payload.ticketId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 008-sla-escalation FR-007/FR-008: pause on entering WAITING_FOR_CUSTOMER, resume on leaving
|
||||||
|
// it — durable (a DB timestamp shift), never an in-memory timer (Constitution Principle VII).
|
||||||
|
eventBus.subscribe(
|
||||||
|
DomainEventName.TICKET_UPDATED,
|
||||||
|
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||||
|
if (event.payload.newStatus === 'WAITING_FOR_CUSTOMER') {
|
||||||
|
await slaService.pause(event.payload.ticketId);
|
||||||
|
} else if (event.payload.previousStatus === 'WAITING_FOR_CUSTOMER') {
|
||||||
|
await slaService.resume(event.payload.ticketId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 008-sla-escalation FR-010: a ticket reaching RESOLVED (003-ticketing's terminal status before
|
||||||
|
// CLOSED/REOPENED) completes its SLA run — never later marked breached.
|
||||||
|
eventBus.subscribe(
|
||||||
|
DomainEventName.TICKET_UPDATED,
|
||||||
|
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
|
||||||
|
if (event.payload.newStatus !== 'RESOLVED') return;
|
||||||
|
await slaService.complete(event.payload.ticketId);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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 './cache.service';
|
||||||
export * from './replay-guard';
|
export * from './replay-guard';
|
||||||
export * from './rate-limiter';
|
export * from './rate-limiter';
|
||||||
|
export * from './auth-revocation';
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
import { logger } from '@/infrastructure/observability';
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
import { resolutionsService } from '@/modules/problem-management/resolutions';
|
||||||
|
|
||||||
|
const AUTO_CLOSE_SWEEP_INTERVAL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 009-problem-resolution research.md "Auto-close is a repeatable BullMQ job on the existing,
|
||||||
|
* unclaimed CLEANUP queue" — mirrors 008's SLA breach-detection job registration exactly: a
|
||||||
|
* repeatable job whose processor calls a directly-callable sweep method containing all the real
|
||||||
|
* logic, durable via BullMQ's own persisted repeatable-job state (Constitution Principle VII).
|
||||||
|
*/
|
||||||
export function registerCleanupWorker(): void {
|
export function registerCleanupWorker(): void {
|
||||||
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
|
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
|
||||||
logger.info({ jobId: job.id, data: job.data }, 'Processing Cleanup Job');
|
logger.info({ jobId: job.id }, 'Running resolution auto-close sweep');
|
||||||
|
await resolutionsService.runAutoCloseSweep();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void queueManager.getQueue(QueueName.CLEANUP).add(
|
||||||
|
'auto-close-resolutions',
|
||||||
|
{
|
||||||
|
jobId: 'auto-close-resolutions',
|
||||||
|
type: 'auto-close-resolutions',
|
||||||
|
payload: {},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ repeat: { every: AUTO_CLOSE_SWEEP_INTERVAL_MS } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -1,8 +1,30 @@
|
|||||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
import { logger } from '@/infrastructure/observability';
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
import { slaService } from '@/modules/orchestration/sla';
|
||||||
|
|
||||||
|
const BREACH_DETECTION_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* research.md "Breach detection — one repeatable BullMQ job, not one delayed job per run": a
|
||||||
|
* single job scheduled to repeat every minute, whose processor calls SlaService's directly-
|
||||||
|
* callable sweep — the sweep itself contains 100% of the actual logic, so this registration is
|
||||||
|
* pure scheduling (Constitution Principle VII — durable, survives a restart via BullMQ's own
|
||||||
|
* persisted repeatable-job state, never an in-memory setInterval).
|
||||||
|
*/
|
||||||
export function registerSlaWorker(): void {
|
export function registerSlaWorker(): void {
|
||||||
queueManager.registerWorker(QueueName.SLA, async (job) => {
|
queueManager.registerWorker(QueueName.SLA, async (job) => {
|
||||||
logger.info({ jobId: job.id, data: job.data }, 'Processing SLA Job');
|
logger.info({ jobId: job.id }, 'Running SLA breach-detection sweep');
|
||||||
|
await slaService.runBreachDetectionSweep();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void queueManager.getQueue(QueueName.SLA).add(
|
||||||
|
'detect-breaches',
|
||||||
|
{
|
||||||
|
jobId: 'detect-breaches',
|
||||||
|
type: 'detect-breaches',
|
||||||
|
payload: {},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ repeat: { every: BREACH_DETECTION_INTERVAL_MS } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export class KnowledgeController {
|
|||||||
return reply.status(201).send({ success: true, data: entry, meta: null });
|
return reply.status(201).send({ success: true, data: entry, meta: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
|
||||||
|
* unlike GET /knowledge/retrieve which is published-only). */
|
||||||
|
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const { externalProductId } = request.params as { externalProductId: string };
|
||||||
|
const productId = await resolveProductId(externalProductId);
|
||||||
|
const entries = await this.service.listForGovernance(productId);
|
||||||
|
return reply.status(200).send({ success: true, data: entries, meta: null });
|
||||||
|
}
|
||||||
|
|
||||||
async publish(request: FastifyRequest, reply: FastifyReply) {
|
async publish(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const { code } = request.params as { code: string };
|
const { code } = request.params as { code: string };
|
||||||
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
|
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
|
||||||
|
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
|
||||||
|
* governance screen (which must see drafts to publish them) needs its own query. */
|
||||||
|
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
|
||||||
|
return this.prisma.knowledgeEntry.findMany({
|
||||||
|
where: { productId, isCurrentVersion: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
|
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
|
||||||
* is validated-first, then most-recently-effective. */
|
* is validated-first, then most-recently-effective. */
|
||||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||||
|
|||||||
@@ -1,35 +1,44 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { knowledgeController, errorCodesController, runbooksController } from '../controller';
|
import { knowledgeController, errorCodesController, runbooksController } from '../controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin routes gated by fastify.authenticate (research.md — known limitation inherited from
|
* Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real
|
||||||
* 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future
|
* (010-identity-auth). Reads stay agent-usable (fastify.authenticate only).
|
||||||
* AI-support feature will call, not an admin surface (research.md "Admin endpoint
|
* /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support
|
||||||
* authentication").
|
* feature will call, not an admin surface (research.md "Admin endpoint authentication").
|
||||||
*/
|
*/
|
||||||
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/products/:externalProductId/knowledge',
|
'/admin/products/:externalProductId/knowledge',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => knowledgeController.create(req, reply),
|
(req, reply) => knowledgeController.create(req, reply),
|
||||||
);
|
);
|
||||||
|
// 012-admin-list-views follow-up: the governance screen's own data source (every status).
|
||||||
|
fastify.get(
|
||||||
|
'/admin/products/:externalProductId/knowledge',
|
||||||
|
{ preHandler: fastify.authenticate },
|
||||||
|
(req, reply) => knowledgeController.listForGovernance(req, reply),
|
||||||
|
);
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/admin/knowledge/:code/publish',
|
'/admin/knowledge/:code/publish',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => knowledgeController.publish(req, reply),
|
(req, reply) => knowledgeController.publish(req, reply),
|
||||||
);
|
);
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/admin/knowledge/:code/unpublish',
|
'/admin/knowledge/:code/unpublish',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => knowledgeController.unpublish(req, reply),
|
(req, reply) => knowledgeController.unpublish(req, reply),
|
||||||
);
|
);
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/admin/knowledge/:code/validate',
|
'/admin/knowledge/:code/validate',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => knowledgeController.validate(req, reply),
|
(req, reply) => knowledgeController.validate(req, reply),
|
||||||
);
|
);
|
||||||
fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) =>
|
fastify.put(
|
||||||
knowledgeController.edit(req, reply),
|
'/admin/knowledge/:code',
|
||||||
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
|
(req, reply) => knowledgeController.edit(req, reply),
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/admin/knowledge/:code/versions',
|
'/admin/knowledge/:code/versions',
|
||||||
@@ -39,12 +48,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/products/:externalProductId/error-codes',
|
'/admin/products/:externalProductId/error-codes',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => errorCodesController.createErrorCode(req, reply),
|
(req, reply) => errorCodesController.createErrorCode(req, reply),
|
||||||
);
|
);
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/products/:externalProductId/known-issues',
|
'/admin/products/:externalProductId/known-issues',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => errorCodesController.createKnownIssue(req, reply),
|
(req, reply) => errorCodesController.createKnownIssue(req, reply),
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
@@ -55,7 +64,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/products/:externalProductId/runbooks',
|
'/admin/products/:externalProductId/runbooks',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => runbooksController.create(req, reply),
|
(req, reply) => runbooksController.create(req, reply),
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
@@ -65,12 +74,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
|||||||
);
|
);
|
||||||
fastify.put(
|
fastify.put(
|
||||||
'/admin/products/:externalProductId/runbooks/:key',
|
'/admin/products/:externalProductId/runbooks/:key',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => runbooksController.edit(req, reply),
|
(req, reply) => runbooksController.edit(req, reply),
|
||||||
);
|
);
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/admin/products/:externalProductId/runbooks/:key/deactivate',
|
'/admin/products/:externalProductId/runbooks/:key/deactivate',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => runbooksController.deactivate(req, reply),
|
(req, reply) => runbooksController.deactivate(req, reply),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export class KnowledgeService {
|
|||||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||||
return this.repo.retrieve(filters);
|
return this.repo.retrieve(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views follow-up: every entry for a product, any status — the governance
|
||||||
|
* screen's own data source (unlike `retrieve`, which is published-only). */
|
||||||
|
async listForGovernance(productId: string): Promise<KnowledgeEntry[]> {
|
||||||
|
return this.repo.findAllForProduct(productId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const knowledgeService = new KnowledgeService();
|
export const knowledgeService = new KnowledgeService();
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { confidencePolicyController, sessionController } from '../controller';
|
import { confidencePolicyController, sessionController } from '../controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contracts/ai-support-contract.md: admin confidence-policy routes gated by
|
* contracts/ai-support-contract.md: the admin confidence-policy write route is gated by
|
||||||
* fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are
|
* fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn
|
||||||
* not admin routes — called by the ticket-owning caller, same as 003-ticketing's
|
* routes are not admin routes — called by the ticket-owning caller, same as 003-ticketing's
|
||||||
* POST/GET .../messages, and carry no additional gate of their own in this feature.
|
* POST/GET .../messages, and carry no additional gate of their own in this feature.
|
||||||
*/
|
*/
|
||||||
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.put(
|
fastify.put(
|
||||||
'/admin/products/:externalProductId/ai-policy',
|
'/admin/products/:externalProductId/ai-policy',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => confidencePolicyController.upsert(req, reply),
|
(req, reply) => confidencePolicyController.upsert(req, reply),
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { categoriesRoutes } from './routes';
|
export { categoriesRoutes } from './routes';
|
||||||
export { CategoriesService, categoriesService } from './service';
|
export { CategoriesService, categoriesService } from './service';
|
||||||
export type { CategoryDTO } from './types';
|
export type { CategoryDTO } from './types';
|
||||||
|
export { categoriesRepository, CategoriesRepository } from './repository';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Category } from '@prisma/client';
|
||||||
import { prismaClient } from '@/infrastructure/database';
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
|
||||||
export class CategoriesRepository {
|
export class CategoriesRepository {
|
||||||
@@ -6,6 +7,13 @@ export class CategoriesRepository {
|
|||||||
async findAllCategories(): Promise<unknown[]> {
|
async findAllCategories(): Promise<unknown[]> {
|
||||||
return this.prisma.category.findMany();
|
return this.prisma.category.findMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 008-sla-escalation: existence check for SLAPolicy.categoryId — this stub had no findById
|
||||||
|
* at all before, a leftover gap from the original scaffold (same class of gap this codebase
|
||||||
|
* has closed in every prior feature that needed one). */
|
||||||
|
async findById(id: string): Promise<Category | null> {
|
||||||
|
return this.prisma.category.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const categoriesRepository = new CategoriesRepository();
|
export const categoriesRepository = new CategoriesRepository();
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ export class ProductsController {
|
|||||||
meta: null,
|
meta: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views: admin catalog screen — never returns the full ProductIntegration
|
||||||
|
* row, only its derived status (research.md). */
|
||||||
|
async getProductsWithIntegrationStatus(_request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const products = await this.service.listWithIntegrationStatus();
|
||||||
|
const data = products.map((product) => ({
|
||||||
|
id: product.id,
|
||||||
|
externalProductId: product.externalProductId,
|
||||||
|
name: product.name,
|
||||||
|
status: product.status,
|
||||||
|
supportEnabled: product.supportEnabled,
|
||||||
|
integrationStatus: product.integration?.status ?? null,
|
||||||
|
}));
|
||||||
|
return reply.status(200).send({ success: true, data, meta: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const productsController = new ProductsController();
|
export const productsController = new ProductsController();
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export type { ProductIntegrationWithProduct } from './repository';
|
|||||||
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
|
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
|
||||||
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
|
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
|
||||||
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
|
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
|
||||||
export { inboundRequestSchema } from './schema';
|
export { inboundRequestSchema, identityOnlyRequestSchema } from './schema';
|
||||||
export type { InboundRequest } from './schema';
|
export type { InboundRequest, IdentityOnlyRequest } from './schema';
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ export class ProductsRepository {
|
|||||||
return this.prisma.product.findMany();
|
return this.prisma.product.findMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views: the product catalog with each product's integration status joined
|
||||||
|
* in — never the full ProductIntegration row (its credentialRef is a secret at rest). */
|
||||||
|
async findAllWithIntegrationStatus(): Promise<
|
||||||
|
(Product & { integration: { status: string } | null })[]
|
||||||
|
> {
|
||||||
|
return this.prisma.product.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: { integration: { select: { status: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
|
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
|
||||||
return this.prisma.product.findUnique({ where: { externalProductId } });
|
return this.prisma.product.findUnique({ where: { externalProductId } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { productIntegrationsController } from '../controller';
|
import { productIntegrationsController } from '../controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
|
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
|
||||||
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see
|
* Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see
|
||||||
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle
|
* specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known
|
||||||
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet.
|
* limitation this decorator previously didn't perform real verification).
|
||||||
*/
|
*/
|
||||||
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/products/:externalProductId/integration',
|
'/admin/products/:externalProductId/integration',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => productIntegrationsController.register(req, reply),
|
(req, reply) => productIntegrationsController.register(req, reply),
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/integrations/:integrationId/rotate',
|
'/admin/integrations/:integrationId/rotate',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => productIntegrationsController.rotate(req, reply),
|
(req, reply) => productIntegrationsController.rotate(req, reply),
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/admin/integrations/:integrationId/revoke',
|
'/admin/integrations/:integrationId/revoke',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => productIntegrationsController.revoke(req, reply),
|
(req, reply) => productIntegrationsController.revoke(req, reply),
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/admin/integrations/:integrationId/status',
|
'/admin/integrations/:integrationId/status',
|
||||||
{ preHandler: fastify.authenticate },
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
(req, reply) => productIntegrationsController.updateStatus(req, reply),
|
(req, reply) => productIntegrationsController.updateStatus(req, reply),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { productsController } from '../controller';
|
import { productsController } from '../controller';
|
||||||
|
|
||||||
export async function productsRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function productsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get('/products', (req, reply) => productsController.getProducts(req, reply));
|
fastify.get('/products', (req, reply) => productsController.getProducts(req, reply));
|
||||||
|
|
||||||
|
// 012-admin-list-views: admin-only — a separate route rather than a query flag on the public
|
||||||
|
// /products above, so the auth gate stays unconditional (research.md).
|
||||||
|
fastify.get(
|
||||||
|
'/admin/products',
|
||||||
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
|
(req, reply) => productsController.getProductsWithIntegrationStatus(req, reply),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,20 @@ export const inboundRequestSchema = z
|
|||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
|
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 009-problem-resolution: the identity-only subset of the inbound contract — for a caller
|
||||||
|
* already acting on an existing ticket (confirm-resolution, reopen) rather than creating one, so
|
||||||
|
* `source`/`problem` (ticket-creation-specific) aren't required. Every other verification step
|
||||||
|
* (token validity, replay, scope, revocation) is identical — see
|
||||||
|
* product-integration-auth.plugin.ts's shared verifyIntegrationIdentity.
|
||||||
|
*/
|
||||||
|
export const identityOnlyRequestSchema = z
|
||||||
|
.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
tenantId: z.string().min(1),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type IdentityOnlyRequest = z.infer<typeof identityOnlyRequestSchema>;
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export class ProductsService {
|
|||||||
async listProducts(): Promise<unknown[]> {
|
async listProducts(): Promise<unknown[]> {
|
||||||
return this.repo.findAllProducts();
|
return this.repo.findAllProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 012-admin-list-views: the product catalog with integration status, for the admin catalog
|
||||||
|
* screen. */
|
||||||
|
async listWithIntegrationStatus() {
|
||||||
|
return this.repo.findAllWithIntegrationStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const productsService = new ProductsService();
|
export const productsService = new ProductsService();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
AgentSkillsService,
|
AgentSkillsService,
|
||||||
agentAvailabilityService,
|
agentAvailabilityService,
|
||||||
AgentAvailabilityService,
|
AgentAvailabilityService,
|
||||||
|
usersService,
|
||||||
|
UsersService,
|
||||||
} from '../service';
|
} from '../service';
|
||||||
import {
|
import {
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
listAgentsQuerySchema,
|
listAgentsQuerySchema,
|
||||||
upsertAgentSkillSchema,
|
upsertAgentSkillSchema,
|
||||||
upsertAgentAvailabilitySchema,
|
upsertAgentAvailabilitySchema,
|
||||||
|
createUserSchema,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
|
|
||||||
export class AgentsController {
|
export class AgentsController {
|
||||||
@@ -20,6 +23,7 @@ export class AgentsController {
|
|||||||
private readonly service: AgentsService = agentsService,
|
private readonly service: AgentsService = agentsService,
|
||||||
private readonly skills: AgentSkillsService = agentSkillsService,
|
private readonly skills: AgentSkillsService = agentSkillsService,
|
||||||
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
|
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
|
||||||
|
private readonly users: UsersService = usersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(request: FastifyRequest, reply: FastifyReply) {
|
async create(request: FastifyRequest, reply: FastifyReply) {
|
||||||
@@ -73,6 +77,13 @@ export class AgentsController {
|
|||||||
const record = await this.availability.getForAgent(agentId);
|
const record = await this.availability.getForAgent(agentId);
|
||||||
return reply.status(200).send({ success: true, data: record, meta: null });
|
return reply.status(200).send({ success: true, data: record, meta: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 010-identity-auth User Story 4: admin-only account creation. */
|
||||||
|
async createUser(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const body = createUserSchema.parse(request.body);
|
||||||
|
const user = await this.users.create(body);
|
||||||
|
return reply.status(201).send({ success: true, data: user, meta: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsController = new AgentsController();
|
export const agentsController = new AgentsController();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface UpdateAgentData {
|
|||||||
name?: string | undefined;
|
name?: string | undefined;
|
||||||
teamId?: string | undefined;
|
teamId?: string | undefined;
|
||||||
active?: boolean | undefined;
|
active?: boolean | undefined;
|
||||||
|
userId?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindAgentsFilter {
|
export interface FindAgentsFilter {
|
||||||
@@ -44,6 +45,11 @@ export class AgentsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 011-agent-ticket-queue: resolves a logged-in session to its agent roster row. */
|
||||||
|
async findByUserId(userId: string): Promise<Agent | null> {
|
||||||
|
return this.prisma.agent.findUnique({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||||
return this.prisma.agent.findMany({
|
return this.prisma.agent.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './agents.repository';
|
export * from './agents.repository';
|
||||||
export * from './agent-skills.repository';
|
export * from './agent-skills.repository';
|
||||||
export * from './agent-availability.repository';
|
export * from './agent-availability.repository';
|
||||||
|
export * from './users.repository';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
|
||||||
|
export interface CreateUserData {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: 'ADMIN' | 'AGENT';
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsersRepository {
|
||||||
|
constructor(private readonly prisma = prismaClient) {}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
return this.prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateUserData): Promise<User> {
|
||||||
|
return this.prisma.user.create({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersRepository = new UsersRepository();
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { agentsController } from '../controller';
|
import { agentsController } from '../controller';
|
||||||
|
|
||||||
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
|
/** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a
|
||||||
* (research.md "Admin endpoint authentication"). */
|
* no-op stub, per that feature's own research.md). `POST /admin/users` additionally requires
|
||||||
|
* the ADMIN role (010's own User Story 4) since account creation is more sensitive than
|
||||||
|
* agent-roster management. */
|
||||||
export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.post(
|
||||||
|
'/admin/users',
|
||||||
|
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||||
|
(req, reply) => agentsController.createUser(req, reply),
|
||||||
|
);
|
||||||
fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
|
fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||||
agentsController.create(req, reply),
|
agentsController.create(req, reply),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const updateAgentSchema = z
|
|||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
teamId: z.string().min(1).optional(),
|
teamId: z.string().min(1).optional(),
|
||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
|
// 011-agent-ticket-queue: links this agent to the User account it authenticates as.
|
||||||
|
// null explicitly unlinks; omitting the field leaves the existing link unchanged.
|
||||||
|
userId: z.string().min(1).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './agents.schema';
|
export * from './agents.schema';
|
||||||
export * from './agent-skills.schema';
|
export * from './agent-skills.schema';
|
||||||
export * from './agent-availability.schema';
|
export * from './agent-availability.schema';
|
||||||
|
export * from './users.schema';
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const createUserSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
role: z.enum(['ADMIN', 'AGENT']),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateUserBody = z.infer<typeof createUserSchema>;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Agent } from '@prisma/client';
|
import { Agent } from '@prisma/client';
|
||||||
import { NotFoundError } from '@/common/errors';
|
import { ConflictError, NotFoundError, ValidationError } from '@/common/errors';
|
||||||
import { teamsRepository } from '@/modules/identity/teams';
|
import { teamsRepository } from '@/modules/identity/teams';
|
||||||
import {
|
import {
|
||||||
agentsRepository,
|
agentsRepository,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CreateAgentData,
|
CreateAgentData,
|
||||||
UpdateAgentData,
|
UpdateAgentData,
|
||||||
FindAgentsFilter,
|
FindAgentsFilter,
|
||||||
|
usersRepository,
|
||||||
} from '../repository';
|
} from '../repository';
|
||||||
|
|
||||||
export class AgentsService {
|
export class AgentsService {
|
||||||
@@ -26,6 +27,20 @@ export class AgentsService {
|
|||||||
const team = await teamsRepository.findById(data.teamId);
|
const team = await teamsRepository.findById(data.teamId);
|
||||||
if (!team) throw new NotFoundError('Team not found.');
|
if (!team) throw new NotFoundError('Team not found.');
|
||||||
}
|
}
|
||||||
|
// 011-agent-ticket-queue FR-001: proactive existence/role/duplicate-link checks, mirroring
|
||||||
|
// UsersService.create's own pre-check style, rather than translating a raw unique-
|
||||||
|
// constraint error after the fact.
|
||||||
|
if (data.userId !== undefined && data.userId !== null) {
|
||||||
|
const user = await usersRepository.findById(data.userId);
|
||||||
|
if (!user) throw new NotFoundError('User not found.');
|
||||||
|
if (user.role !== 'AGENT') {
|
||||||
|
throw new ValidationError('Only a User with role AGENT can be linked to an agent.');
|
||||||
|
}
|
||||||
|
const existingLink = await this.repo.findByUserId(data.userId);
|
||||||
|
if (existingLink && existingLink.id !== agentId) {
|
||||||
|
throw new ConflictError('This account is already linked to a different agent.');
|
||||||
|
}
|
||||||
|
}
|
||||||
const updated = await this.repo.update(agentId, data);
|
const updated = await this.repo.update(agentId, data);
|
||||||
if (!updated) throw new NotFoundError('Agent not found.');
|
if (!updated) throw new NotFoundError('Agent not found.');
|
||||||
return updated;
|
return updated;
|
||||||
@@ -37,6 +52,15 @@ export class AgentsService {
|
|||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 011-agent-ticket-queue FR-006: resolves a logged-in session to its own agent roster row,
|
||||||
|
* throwing a specific, distinguishable error rather than letting a caller mistake "no linked
|
||||||
|
* agent" for "an agent with zero results." */
|
||||||
|
async requireAgentForUser(userId: string): Promise<Agent> {
|
||||||
|
const agent = await this.repo.findByUserId(userId);
|
||||||
|
if (!agent) throw new NotFoundError('No agent profile is linked to this account.');
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||||
return this.repo.findAll(filter);
|
return this.repo.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './agents.service';
|
export * from './agents.service';
|
||||||
export * from './agent-skills.service';
|
export * from './agent-skills.service';
|
||||||
export * from './agent-availability.service';
|
export * from './agent-availability.service';
|
||||||
|
export * from './users.service';
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { ConflictError } from '@/common/errors';
|
||||||
|
import { hashPassword } from '@/modules/identity/auth';
|
||||||
|
import { usersRepository, UsersRepository } from '../repository';
|
||||||
|
import { CreateUserBody } from '../schema';
|
||||||
|
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly repo: UsersRepository = usersRepository) {}
|
||||||
|
|
||||||
|
/** FR-008: rejects a duplicate email — never a second account silently sharing one. */
|
||||||
|
async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> {
|
||||||
|
const existing = await this.repo.findByEmail(body.email);
|
||||||
|
if (existing) throw new ConflictError('An account with this email already exists.');
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(body.password);
|
||||||
|
const user = await this.repo.create({
|
||||||
|
email: body.email,
|
||||||
|
name: body.name,
|
||||||
|
role: body.role,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
active: user.active,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersService = new UsersService();
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { AuthenticationError } from '@/common/errors';
|
||||||
import { authService, AuthService } from '../service';
|
import { authService, AuthService } from '../service';
|
||||||
import { AuthCredentialsInput } from '../types';
|
import { loginSchema } from '../schema';
|
||||||
|
|
||||||
|
function bearerToken(request: FastifyRequest): string {
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly service: AuthService = authService) {}
|
constructor(private readonly service: AuthService = authService) {}
|
||||||
|
|
||||||
async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) {
|
async handleLogin(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const user = await this.service.validateCredentials(request.body);
|
const body = loginSchema.parse(request.body);
|
||||||
return reply.status(200).send({
|
const result = await this.service.login(body);
|
||||||
success: true,
|
return reply.status(200).send({ success: true, data: result, meta: null });
|
||||||
data: user,
|
}
|
||||||
meta: null,
|
|
||||||
});
|
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
// Unreachable in practice: this route is only ever registered behind fastify.authenticate,
|
||||||
|
// which always sets request.user on success.
|
||||||
|
if (!request.user) throw new AuthenticationError('Session is no longer valid.');
|
||||||
|
const user = await this.service.getCurrentUser(request.user.id);
|
||||||
|
return reply.status(200).send({ success: true, data: user, meta: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogout(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
await this.service.logout(bearerToken(request));
|
||||||
|
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export { authRoutes } from './routes';
|
export { authRoutes } from './routes';
|
||||||
export { AuthService, authService } from './service';
|
export { AuthService, authService } from './service';
|
||||||
export type { AuthCredentialsInput } from './types';
|
export { requireRole } from './service';
|
||||||
|
export type { LoginBody } from './schema';
|
||||||
|
export type { LoginResult } from './service';
|
||||||
|
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
|
||||||
|
export { AUTH_CONSTANTS } from './constants';
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
export class AuthMapper {
|
import { randomUUID } from 'crypto';
|
||||||
static toResponse(user: Record<string, unknown>): Record<string, unknown> {
|
import bcrypt from 'bcryptjs';
|
||||||
return { ...user };
|
import jwt from 'jsonwebtoken';
|
||||||
}
|
import { AuthUser, JwtPayload } from '@/common/types';
|
||||||
|
import { ActorType } from '@/common/enums';
|
||||||
|
import { authConfig } from '@/config';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
|
// research.md "byte-identical failure response": compared against when no user is found at
|
||||||
|
// all, so a login's timing/shape never reveals whether the email itself was valid.
|
||||||
|
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', SALT_ROUNDS);
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash ?? DUMMY_HASH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(user: { id: string; email: string; role: string }): {
|
||||||
|
token: string;
|
||||||
|
jti: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
} {
|
||||||
|
const jti = randomUUID();
|
||||||
|
const expiresInSeconds = authConfig.tokenLifetimeHours * 3600;
|
||||||
|
const payload: Omit<JwtPayload, 'iat' | 'exp'> = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
actorType: ActorType.USER,
|
||||||
|
jti,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = jwt.sign(payload, authConfig.jwtSecret, { expiresIn: expiresInSeconds });
|
||||||
|
return { token, jti, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): JwtPayload {
|
||||||
|
return jwt.verify(token, authConfig.jwtSecret) as JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuthUser(payload: JwtPayload): AuthUser {
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
role: payload.role,
|
||||||
|
actorType: payload.actorType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import { User } from '@prisma/client';
|
||||||
import { prismaClient } from '@/infrastructure/database';
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
|
||||||
export class AuthRepository {
|
export class AuthRepository {
|
||||||
constructor(private readonly prisma = prismaClient) {}
|
constructor(private readonly prisma = prismaClient) {}
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<unknown> {
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
where: { email },
|
}
|
||||||
});
|
|
||||||
|
/** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */
|
||||||
|
async findActiveById(id: string): Promise<User | null> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user || !user.active) return null;
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { authController } from '../controller';
|
import { authController } from '../controller';
|
||||||
import { AuthCredentialsInput } from '../types';
|
|
||||||
|
|
||||||
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) =>
|
fastify.post('/auth/login', (req, reply) => authController.handleLogin(req, reply));
|
||||||
authController.handleLogin(req, reply),
|
|
||||||
|
fastify.get('/auth/me', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||||
|
authController.getCurrentUser(req, reply),
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||||
|
authController.handleLogout(req, reply),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const authCredentialsSchema = z.object({
|
export const loginSchema = z
|
||||||
email: z.string().email(),
|
.object({
|
||||||
});
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type LoginBody = z.infer<typeof loginSchema>;
|
||||||
|
|||||||
@@ -1,11 +1,50 @@
|
|||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { AuthenticationError } from '@/common/errors';
|
||||||
|
import { revokeToken } from '@/infrastructure/cache';
|
||||||
import { authRepository, AuthRepository } from '../repository';
|
import { authRepository, AuthRepository } from '../repository';
|
||||||
import { AuthCredentialsInput } from '../types';
|
import { verifyPassword, signToken, verifyToken } from '../mapper';
|
||||||
|
import { LoginBody } from '../schema';
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
user: { id: string; email: string; name: string; role: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublicUser(user: User): LoginResult['user'] {
|
||||||
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private readonly repo: AuthRepository = authRepository) {}
|
constructor(private readonly repo: AuthRepository = authRepository) {}
|
||||||
|
|
||||||
async validateCredentials(input: AuthCredentialsInput): Promise<unknown> {
|
/**
|
||||||
return this.repo.findByEmail(input.email);
|
* FR-002/SC-003: every failure branch (no such email, inactive account, wrong password)
|
||||||
|
* throws the identical AuthenticationError — bcrypt.compare always runs exactly once,
|
||||||
|
* against a fixed dummy hash when no user is found, so timing never leaks which branch fired.
|
||||||
|
*/
|
||||||
|
async login(body: LoginBody): Promise<LoginResult> {
|
||||||
|
const user = await this.repo.findByEmail(body.email);
|
||||||
|
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
|
||||||
|
|
||||||
|
if (!user || !user.active || !passwordMatches) {
|
||||||
|
throw new AuthenticationError('Invalid email or password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = signToken(user);
|
||||||
|
return { token, user: toPublicUser(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User Story 3: re-validated against current account state, not just the token's claims. */
|
||||||
|
async getCurrentUser(userId: string): Promise<LoginResult['user']> {
|
||||||
|
const user = await this.repo.findActiveById(userId);
|
||||||
|
if (!user) throw new AuthenticationError('Session is no longer valid.');
|
||||||
|
return toPublicUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(token: string): Promise<void> {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
|
||||||
|
await revokeToken(payload.jti, remainingSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
|
export * from './require-role';
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { AuthorizationError } from '@/common/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* research.md "Role-gating via a requireRole(...roles) preHandler factory": composes with
|
||||||
|
* fastify.authenticate as a second preHandler — `{ preHandler: [fastify.authenticate,
|
||||||
|
* requireRole('ADMIN')] }` — rather than a fixed decorator per role.
|
||||||
|
*/
|
||||||
|
export function requireRole(...allowedRoles: string[]) {
|
||||||
|
return async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||||
|
if (!request.user || !allowedRoles.includes(request.user.role)) {
|
||||||
|
throw new AuthorizationError('You do not have permission to perform this action.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1 @@
|
|||||||
export interface AuthCredentialsInput {
|
export {};
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user