Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da11dbc961 | ||
|
|
d78bf52182 | ||
|
|
20e5493798 | ||
|
|
8400a8db84 | ||
|
|
cd8e408d7e | ||
|
|
c7aac460b7 | ||
|
|
93d6fe8b94 | ||
|
|
794e16f349 | ||
|
|
0e3543bb01 | ||
|
|
58d0a98134 | ||
|
|
1e332143bd | ||
|
|
2fb5b7ac73 | ||
|
|
015ef62b71 | ||
|
|
9f52d51003 | ||
|
|
7106753ed3 | ||
|
|
d65683641a | ||
|
|
814d9d7b17 | ||
|
|
4a159725c2 | ||
|
|
c4a2faa6e3 | ||
|
|
051ee88974 | ||
|
|
177488caf8 | ||
|
|
5607fdfcd5 | ||
|
|
ca14d45ff1 | ||
|
|
a5650b1089 | ||
|
|
7e92a1679b | ||
|
|
d902f6b26d | ||
|
|
6a7c6a493e | ||
|
|
6beef9584b | ||
|
|
71520e2423 | ||
|
|
f468dcb6f8 | ||
|
|
fc438d552b | ||
|
|
97c954c6fa | ||
|
|
b44f3d1dd2 | ||
|
|
8ba8cbe4e1 |
@@ -0,0 +1,31 @@
|
||||
NODE_ENV=development
|
||||
PORT=4501
|
||||
|
||||
# Nest build
|
||||
BUILD_COMMAND=npm run build:development
|
||||
|
||||
# Database
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_DB=support_dev
|
||||
POSTGRES_USER=support_user
|
||||
POSTGRES_PASSWORD=z1F3tKF1JNDBQmMq95Up
|
||||
# DATABASE_URL=postgresql://support_user:z1F3tKF1JNDBQmMq95Up@postgres:5432/support_dev
|
||||
|
||||
DATABASE_URL=postgresql://support_user:SupportDev123@localhost:5432/support_dev
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=redis
|
||||
# REDIS_PORT=6379
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=D7FJ7QDKo5gF9KQAO1GL
|
||||
|
||||
# Security & CORS
|
||||
# JWT_SECRET=super-secret-development-jwt-key-32-chars-long
|
||||
# CORS_ORIGINS=https://support-dev.maskantech.in
|
||||
|
||||
# Security & CORS
|
||||
JWT_SECRET=super-secret-development-jwt-key-32-chars-long
|
||||
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=c2e444fe8cc19eb7465e2f8a05f7384628de7a879fcc812766e093c9182fcd58
|
||||
CORS_ORIGINS=https://support-dev.maskantech.in
|
||||
@@ -43,3 +43,6 @@ docker/minio/data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Load-test run reports — measurement artifacts, not fixtures (016-load-concurrency-testing)
|
||||
tests/load/reports/
|
||||
|
||||
@@ -1,66 +1,31 @@
|
||||
# SupportHub API
|
||||
### Development
|
||||
- docker compose --env-file .env.development -f docker-compose.development.yml up -d --build
|
||||
|
||||
### Development (Docker)
|
||||
- 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`
|
||||
### Test
|
||||
- docker compose --env-file .env.test -f docker-compose.test.yml up --build
|
||||
|
||||
### Test (Docker)
|
||||
- `docker compose --env-file .env.test -f docker-compose.test.yml up --build`
|
||||
### Production
|
||||
- docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
|
||||
|
||||
### Production (Docker)
|
||||
- `docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d`
|
||||
### Stop
|
||||
- docker compose -f docker-compose.prod.yml down
|
||||
|
||||
### Stop / Down
|
||||
- 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
|
||||
- docker compose --env-file .env.development -f docker-compose.development.yml ps
|
||||
|
||||
### 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`
|
||||
### 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 Migrations
|
||||
- **Local (using .env.development):**
|
||||
- Create/apply new migration: `npx prisma migrate dev --name <name>`
|
||||
- Push schema directly (prototype/sync): `npx prisma db push`
|
||||
- Deploy pending migrations: `npm run prisma:deploy`
|
||||
- **Inside Docker Container:**
|
||||
- `docker exec -it support-api-development npx prisma migrate deploy`
|
||||
|
||||
### Database Seeding
|
||||
|
||||
- **Seed Database (Roles, Products, Categories, Hierarchy & Demo data)**:
|
||||
```bash
|
||||
npx dotenv-cli -e .env.development -- npm run prisma:seed
|
||||
```
|
||||
|
||||
- **Local:**
|
||||
- `npm run prisma:seed` (or `npx tsx --env-file=.env.development prisma/seed/index.ts`)
|
||||
- **Inside Docker Container:**
|
||||
- `docker exec -it support-api-development npm run prisma:seed`
|
||||
|
||||
Generated
+602
-10
@@ -36,12 +36,14 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/autocannon": "^7.12.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/luxon": "^3.7.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"autocannon": "^8.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.0.11",
|
||||
@@ -49,7 +51,7 @@
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.12.1",
|
||||
"tsc-alias": "^1.9.2",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.0"
|
||||
},
|
||||
@@ -78,6 +80,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@assemblyscript/loader": {
|
||||
"version": "0.19.23",
|
||||
"resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.19.23.tgz",
|
||||
"integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.28",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",
|
||||
@@ -412,6 +421,17 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -1248,6 +1268,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@minimistjs/subarg": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@minimistjs/subarg/-/subarg-1.0.0.tgz",
|
||||
"integrity": "sha512-Q/ONBiM2zNeYUy0mVSO44mWWKYM3UHuEK43PKIOzJCbvUnPoMH1K+gk3cf1kgnCVJFlWmddahQQCmrmBGlk9jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
@@ -1614,14 +1644,14 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1635,14 +1665,14 @@
|
||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0",
|
||||
@@ -1654,7 +1684,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0"
|
||||
@@ -2104,6 +2134,16 @@
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/autocannon": {
|
||||
"version": "7.12.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/autocannon/-/autocannon-7.12.7.tgz",
|
||||
"integrity": "sha512-Pd4nPf7wRpacULa6D/EC9x3CwzFQXwA0z5WFuik/fvJjW44V3WzBTM3jtt8nSBoflUNgswPiMCtgrr1bwnAcMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
@@ -2663,6 +2703,13 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -2672,6 +2719,41 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autocannon": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/autocannon/-/autocannon-8.0.0.tgz",
|
||||
"integrity": "sha512-fMMcWc2JPFcUaqHeR6+PbmEpTxCrPZyBUM95oG4w3ngJ8NfBNas/ZXA+pTHXLqJ0UlFVTcy05GC25WxKx/M20A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@minimistjs/subarg": "^1.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"char-spinner": "^1.0.1",
|
||||
"cli-table3": "^0.6.0",
|
||||
"color-support": "^1.1.1",
|
||||
"cross-argv": "^2.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"has-async-hooks": "^1.0.0",
|
||||
"hdr-histogram-js": "^3.0.0",
|
||||
"hdr-histogram-percentiles-obj": "^3.0.0",
|
||||
"http-parser-js": "^0.5.2",
|
||||
"hyperid": "^3.0.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"manage-path": "^2.0.0",
|
||||
"on-net-listen": "^1.1.1",
|
||||
"pretty-bytes": "^5.4.1",
|
||||
"progress": "^2.0.3",
|
||||
"reinterval": "^1.1.0",
|
||||
"retimer": "^3.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"timestring": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"autocannon": "autocannon.js"
|
||||
}
|
||||
},
|
||||
"node_modules/avvio": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
|
||||
@@ -2829,6 +2911,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2875,6 +2971,13 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/char-spinner": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/char-spinner/-/char-spinner-1.0.1.tgz",
|
||||
"integrity": "sha512-acv43vqJ0+N0rD+Uw3pDHSxP30FHrywu2NO6/wBaHChJIizpDeBUd6NjqhNhy9LGaEAhZAXn46QzmlAvIWd16g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
||||
@@ -2942,6 +3045,54 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
|
||||
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "10.* || >= 12.*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@colors/colors": "1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-table3/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
|
||||
@@ -3040,12 +3191,35 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
@@ -3104,6 +3278,13 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-argv": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-argv/-/cross-argv-2.0.0.tgz",
|
||||
"integrity": "sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3164,6 +3345,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -3240,6 +3431,21 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -3283,6 +3489,55 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -3980,6 +4235,23 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -4011,6 +4283,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
@@ -4034,6 +4316,45 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
|
||||
@@ -4131,6 +4452,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
@@ -4138,6 +4472,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-async-hooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-async-hooks/-/has-async-hooks-1.0.0.tgz",
|
||||
"integrity": "sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -4148,6 +4489,70 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hdr-histogram-js": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-3.0.1.tgz",
|
||||
"integrity": "sha512-l3GSdZL1Jr1C0kyb461tUjEdrRPZr8Qry7jByltf5JGrA0xvqOSrxRBfcrJqqV/AMEtqqhHhC6w8HW0gn76tRQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@assemblyscript/loader": "^0.19.21",
|
||||
"base64-js": "^1.2.0",
|
||||
"pako": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/hdr-histogram-percentiles-obj": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz",
|
||||
"integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
||||
@@ -4179,6 +4584,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
||||
@@ -4205,6 +4617,43 @@
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/hyperid": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz",
|
||||
"integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.2.1",
|
||||
"uuid": "^8.3.2",
|
||||
"uuid-parse": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hyperid/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -4772,6 +5221,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.chunk": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz",
|
||||
"integrity": "sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@@ -4994,6 +5464,23 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/manage-path": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/manage-path/-/manage-path-2.0.0.tgz",
|
||||
"integrity": "sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -5037,6 +5524,29 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
||||
@@ -5277,6 +5787,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-net-listen": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-net-listen/-/on-net-listen-1.1.2.tgz",
|
||||
"integrity": "sha512-y1HRYy8s/RlcBvDUwKXSmkODMdx4KSuIvloCnQYJ2LdBBC1asY4HtfhXwe3UWknLakATZDnbzht2Ijw3M1EqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=9.4.0 || ^8.9.4"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -5364,6 +5884,13 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -5682,6 +6209,19 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@@ -5714,7 +6254,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -5745,6 +6285,16 @@
|
||||
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prom-client": {
|
||||
"version": "15.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
|
||||
@@ -5886,6 +6436,13 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reinterval": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz",
|
||||
"integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -5957,6 +6514,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/retimer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/retimer/-/retimer-3.0.0.tgz",
|
||||
"integrity": "sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -6515,6 +7079,16 @@
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/timestring": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/timestring/-/timestring-6.0.0.tgz",
|
||||
"integrity": "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6631,9 +7205,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6722,6 +7296,24 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz",
|
||||
"integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
+3
-1
@@ -73,12 +73,14 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/autocannon": "^7.12.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/luxon": "^3.7.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"autocannon": "^8.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.0.11",
|
||||
@@ -86,7 +88,7 @@
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.12.1",
|
||||
"tsc-alias": "^1.9.2",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "error_code_lookups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"errorCodeId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "error_code_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "error_code_lookups_productId_createdAt_idx" ON "error_code_lookups"("productId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sla_runs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 016-load-concurrency-testing research.md §1: at most one current assignment per ticket,
|
||||
-- enforced at the database level (a partial unique index, since Prisma's schema DSL cannot
|
||||
-- express a WHERE-predicated unique constraint directly).
|
||||
CREATE UNIQUE INDEX "assignments_one_current_per_ticket" ON "assignments"("ticketId") WHERE "isCurrent" = true;
|
||||
|
||||
-- 016-load-concurrency-testing research.md §3: a given escalation rule may fire at most once
|
||||
-- per ticket over that ticket's lifetime (SLARun.ticketId is already @unique — no reopen-cycle
|
||||
-- support, so a rule-triggered breach genuinely cannot recur for the same ticket). Manual
|
||||
-- escalations (rule_id IS NULL) are excluded and remain repeatable.
|
||||
CREATE UNIQUE INDEX "escalation_events_ticket_rule_unique" ON "escalation_events"("ticketId", "ruleId") WHERE "ruleId" IS NOT NULL;
|
||||
+25
-1
@@ -45,6 +45,7 @@ model Product {
|
||||
tickets Ticket[]
|
||||
knowledgeEntries KnowledgeEntry[]
|
||||
errorCodes ErrorCode[]
|
||||
errorCodeLookups ErrorCodeLookup[]
|
||||
knownIssues KnownIssue[]
|
||||
runbooks Runbook[]
|
||||
aiConfidencePolicies AIConfidencePolicy[]
|
||||
@@ -239,13 +240,31 @@ model ErrorCode {
|
||||
productId String
|
||||
description String
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
knownIssues KnownIssue[]
|
||||
lookups ErrorCodeLookup[]
|
||||
|
||||
@@unique([productId, code])
|
||||
@@map("error_codes")
|
||||
}
|
||||
|
||||
// 015-reporting-dashboards research.md §6: a durable, append-only audit row recording that a
|
||||
// known-error-code lookup happened — 014-full-observability's own equivalent
|
||||
// (supporthub_known_error_lookups_total) is a process-lifetime Prometheus counter, unusable for
|
||||
// a historical "top errors" report. productId is denormalized from errorCode.productId so the
|
||||
// Product dashboard's range query never needs to join back through ErrorCode just to filter.
|
||||
model ErrorCodeLookup {
|
||||
id String @id @default(cuid())
|
||||
errorCodeId String
|
||||
errorCode ErrorCode @relation(fields: [errorCodeId], references: [id])
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([productId, createdAt])
|
||||
@@map("error_code_lookups")
|
||||
}
|
||||
|
||||
model KnownIssue {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
@@ -576,6 +595,11 @@ model SLARun {
|
||||
|
||||
completedAt DateTime?
|
||||
|
||||
// 016-load-concurrency-testing: optimistic-concurrency counter, identical convention to
|
||||
// Ticket.version (003-ticketing) — guards pause/resume/complete/the breach sweep against
|
||||
// racing each other and silently clobbering this run's state (research.md §2).
|
||||
version Int @default(0)
|
||||
|
||||
@@index([status, resolutionDueAt])
|
||||
@@index([status, firstResponseDueAt])
|
||||
@@map("sla_runs")
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export async function seedCategories(prisma: PrismaClient): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(' -> Seeding baseline product categories...');
|
||||
console.log(' Seeding baseline product categories...');
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { externalProductId: 'CORE_PLATFORM' },
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Specification Quality Checklist: Reporting and Analytics Dashboards
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- This is `docs/10-implementation-roadmap.md`'s own Phase 11, third sub-area, per explicit user
|
||||
direction (013 was the security pass, 014 was full observability). Backend-first scope
|
||||
(Assumptions) follows the same pattern already established three times this session
|
||||
(010-identity-auth, 011-agent-ticket-queue, and 014-full-observability's own frontend-free
|
||||
scope) — a `supporthub-web` dashboard UI is a natural, separate follow-on, not re-litigated
|
||||
here via a fresh question.
|
||||
- The pre-scaffolded-but-inert `platform/reports` module (`ReportsService.generateSummaryReport`
|
||||
currently returns `{}`) and the `ANALYTICS` queue stub (`src/jobs/analytics`, logs only) were
|
||||
both confirmed via direct code inspection before writing this spec — the same
|
||||
"provisioned before this session's rebuild but never wired up" pattern found repeatedly this
|
||||
session. This feature wires up the former; the Assumptions section explicitly keeps the latter
|
||||
out of scope (synchronous queries, no pre-aggregation job, for this first cut).
|
||||
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
|
||||
required — every open question (default date window, SLA-risk threshold, top-N limit) had a
|
||||
reasonable, documented, CONFIGURABLE default (see Assumptions), matching the roadmap's own
|
||||
"never hardcode a placeholder value and ship it as final" instruction.
|
||||
|
||||
## Implementation Notes (post-build)
|
||||
|
||||
- Named the Product dashboard's own repository class `ProductReportRepository` (not
|
||||
`ProductRepository`) once it became clear resolving `externalProductId -> Product` should
|
||||
reuse `catalog/products`' own already-public `productsRepository.findByExternalProductId`
|
||||
rather than duplicating that lookup — avoids a name collision and keeps "one authority per
|
||||
concern" (Constitution Principle I's spirit) for product resolution.
|
||||
- `ManagementRepository` and `SupportRepository` both needed byte-identical
|
||||
first-response-duration and resolution-duration queries. Extracted into a shared
|
||||
`SharedReportRepository` both compose, rather than duplicating the Prisma query (or the
|
||||
averaging helper alone) twice — discovered while writing the second repository and seeing the
|
||||
copy-paste, not planned upfront in research.md.
|
||||
- "Top errors"/"most common errors" resolution-back-to-`code` logic moved into
|
||||
`ErrorCodesService.getTopErrorCodesForProduct` (a new method on the module that already owns
|
||||
`ErrorCode`), rather than the reports module reaching into `errorCodesRepository`/
|
||||
`errorCodeLookupRepository` directly — cleaner module-boundary ownership than research.md's
|
||||
original per-repository sketch implied.
|
||||
- The AI dashboard's "failed troubleshooting then escalated" figure (spec.md User Story 4) has
|
||||
no single stored flag anywhere in this codebase — `classifyStepOutcome`'s per-step verdicts are
|
||||
never persisted as their own durable record. Implemented as a documented proxy instead: an
|
||||
escalated session with `toolCallCount > 0` attempted troubleshooting before giving up, one with
|
||||
zero attempts escalated immediately. Documented directly in `ai.repository.ts`'s own code
|
||||
comment, the same "honest, documented simplification" precedent research.md §7 already set for
|
||||
the confidence-distribution bucketing.
|
||||
- Three of this module's public exports needed adding to their owning modules' top-level
|
||||
`index.ts` (not previously exposed): `decideConfidenceBand`/`ConfidenceBand` and
|
||||
`knowledgeReferenceRepository` from `ai-support/sessions`, matching the "extend an existing
|
||||
module's public surface for a later feature" precedent already used repeatedly this session
|
||||
(004's `productsRepository`, 009's `problemsRepository`).
|
||||
- Found a real regression during T028's full regression pass: `known-issues.test.ts` (004-
|
||||
product-knowledge, pre-existing) calls `findKnownIssuesByErrorCode` and its own `afterAll`
|
||||
deleted `ErrorCode` rows before this feature's new `ErrorCodeLookup` FK (RESTRICT) existed —
|
||||
once T004 started writing a lookup row on every call, that cleanup order started failing with
|
||||
an FK violation. Fixed by deleting `ErrorCodeLookup` rows first in that test's own `afterAll`.
|
||||
This feature's own new test files never delete `ErrorCode` rows at all, so they weren't
|
||||
affected the same way (leftover rows there are the same accepted throwaway-data tradeoff
|
||||
already established elsewhere this session).
|
||||
- Confirmed (not caused by this feature — the exact pre-existing issue 014-full-observability's
|
||||
own checklist already documented and root-caused via `git checkout` comparison) that this
|
||||
feature's own new integration test files, which also name their test products `TEST_*`,
|
||||
occasionally hit the same shared `deriveProductCode` "TEST" prefix collision under vitest's
|
||||
concurrent file execution when run alongside other `TEST_*`-prefixed files. Every dashboard
|
||||
test passes reliably run individually or in small groups; the intermittent 500 in a full
|
||||
combined run is the same known, out-of-scope, 003-ticketing concern.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Contract: Reporting API
|
||||
|
||||
All four routes require a valid staff session with role `ADMIN` (`requireRole('ADMIN')`), the
|
||||
same gate every admin-only surface uses since 010-identity-auth. All return the standard
|
||||
envelope: `{ success: true, data: <shape>, meta: null }` on success, `{ success: false, error:
|
||||
{code, message, details} }` on failure — no change to this codebase's existing response
|
||||
convention.
|
||||
|
||||
## `GET /admin/reports/management`
|
||||
|
||||
**Query**: `from?`, `to?` (ISO dates).
|
||||
|
||||
**200**: `ManagementDashboard` (data-model.md).
|
||||
|
||||
**400** `VALIDATION_ERROR`: `from` is after `to`.
|
||||
|
||||
**401/403**: missing/invalid session, or a non-`ADMIN` role.
|
||||
|
||||
## `GET /admin/reports/product/:externalProductId`
|
||||
|
||||
**Path**: `externalProductId` — the SaaS-facing product identifier (same convention every other
|
||||
admin product-scoped route already uses, e.g. `GET /admin/products/:externalProductId/knowledge`
|
||||
from 004-product-knowledge).
|
||||
|
||||
**Query**: `from?`, `to?`.
|
||||
|
||||
**200**: `ProductDashboard`.
|
||||
|
||||
**404** `NOT_FOUND`: no product with that `externalProductId` (FR-006 — never an empty-but-200
|
||||
response for an unknown product).
|
||||
|
||||
**400** `VALIDATION_ERROR`: `from` is after `to`.
|
||||
|
||||
## `GET /admin/reports/support`
|
||||
|
||||
**Query**: `from?`, `to?` (applies only to the performance figures — workload/SLA-risk/breached
|
||||
are always current, per data-model.md's `SupportDashboard.generatedAt`).
|
||||
|
||||
**200**: `SupportDashboard`.
|
||||
|
||||
## `GET /admin/reports/ai`
|
||||
|
||||
**Query**: `from?`, `to?`.
|
||||
|
||||
**200**: `AiDashboard`.
|
||||
|
||||
## Guarantees
|
||||
|
||||
1. Every rate/average field is `number | null` — `null` means no qualifying data existed in the
|
||||
requested range (FR-007). A consumer must never see `NaN` or a silently-substituted `0` for
|
||||
"no data."
|
||||
2. Every count field is a plain `number`, always present, `0` is a legitimate, meaningful value
|
||||
for a count (distinct from the `null`-for-no-data rule above, which applies only to
|
||||
rates/averages).
|
||||
3. `from`/`to` in every response echo the *resolved* range actually used (including the default,
|
||||
when omitted) — a caller never has to separately know what "the default" was.
|
||||
4. No route in this contract mutates any data — a repeated identical request returns the same
|
||||
shape (though not necessarily identical figures, since the underlying data can change between
|
||||
requests) with no side effect.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Data Model: Reporting and Analytics Dashboards
|
||||
|
||||
## New Prisma Model
|
||||
|
||||
### `ErrorCodeLookup`
|
||||
|
||||
Append-only audit record — see research.md §6 for why this is the one new table this feature
|
||||
needs.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `String @id @default(cuid())` | |
|
||||
| `errorCodeId` | `String` | FK → `ErrorCode.id` |
|
||||
| `productId` | `String` | FK → `Product.id` — denormalized from `errorCode.productId` so the Product dashboard's range query never needs to join back through `ErrorCode` just to filter by product |
|
||||
| `createdAt` | `DateTime @default(now())` | |
|
||||
|
||||
Indexes: `@@index([productId, createdAt])` (the Product dashboard's own access pattern).
|
||||
|
||||
No `updatedAt`, no soft-delete, no unique constraint — every lookup is its own row, duplicates
|
||||
across time are the entire point (frequency is what "top errors" measures).
|
||||
|
||||
## Response Shapes (not persisted — computed per request)
|
||||
|
||||
### Management dashboard — `GET /admin/reports/management`
|
||||
|
||||
```ts
|
||||
interface ManagementDashboard {
|
||||
range: { from: string; to: string }; // ISO 8601, echoes the resolved (possibly defaulted) range
|
||||
totalCases: number;
|
||||
aiResolved: number;
|
||||
humanEscalated: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
slaCompliance: { met: number; breached: number; rate: number | null }; // rate = met / (met + breached)
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Product dashboard — `GET /admin/reports/product/:externalProductId`
|
||||
|
||||
```ts
|
||||
interface ProductDashboard {
|
||||
productId: string; // externalProductId, echoed back
|
||||
range: { from: string; to: string };
|
||||
supportVolume: number;
|
||||
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
|
||||
recurringProblems: Array<{ categoryId: string | null; count: number }>; // same data, top N, descending
|
||||
aiResolutionRate: number | null;
|
||||
humanEscalationRate: number | null;
|
||||
topErrors: Array<{ code: string; count: number }>; // top N, descending
|
||||
}
|
||||
```
|
||||
|
||||
### Support dashboard — `GET /admin/reports/support`
|
||||
|
||||
```ts
|
||||
interface SupportDashboard {
|
||||
generatedAt: string; // workload/risk are point-in-time, not range-scoped (research.md §2)
|
||||
range: { from: string; to: string }; // still applies to the performance figures below
|
||||
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
|
||||
slaAtRisk: number;
|
||||
slaBreached: number;
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### AI dashboard — `GET /admin/reports/ai`
|
||||
|
||||
```ts
|
||||
interface AiDashboard {
|
||||
range: { from: string; to: string };
|
||||
totalSessions: number;
|
||||
aiResolutionRate: number | null;
|
||||
humanHandoffRate: number | null;
|
||||
failedTroubleshootingEscalationRate: number | null;
|
||||
knowledgeMatchRate: number | null;
|
||||
confidenceDistribution: { proceed: number; ask: number; escalate: number };
|
||||
toolInvocations: { success: number; failed: number };
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters (all four routes)
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `from` | ISO date, optional | Defaults to `to - REPORTING_DEFAULT_WINDOW_DAYS` |
|
||||
| `to` | ISO date, optional | Defaults to now |
|
||||
|
||||
`from > to` is a 400 `VALIDATION_ERROR` (spec.md Edge Cases), not silently swapped.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Implementation Plan: Reporting and Analytics Dashboards
|
||||
|
||||
**Branch**: `015-reporting-dashboards` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/015-reporting-dashboards/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Wires the pre-scaffolded, unused `platform/reports` module into four real, admin-gated,
|
||||
read-only aggregation endpoints (Management, Product, Support, AI) matching
|
||||
`docs/09-testing-observability-cicd.md`'s own dashboard table — each computed synchronously,
|
||||
on request, directly from existing durable tables (Ticket, Problem, SLARun, EscalationEvent,
|
||||
AISupportSession, AIDiagnosis, AIAction, Resolution, Assignment). The one new piece of state is
|
||||
a small durable `ErrorCodeLookup` audit table, needed only because no existing record lets "top
|
||||
errors" be computed historically (014-full-observability's own equivalent is a process-lifetime
|
||||
Prometheus counter, unusable for a dated report). No presentation layer — see spec.md's
|
||||
Assumptions for why `supporthub-web` work is a separate follow-on.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — Prisma's own `groupBy`/`count`/`aggregate`/`findMany`, no
|
||||
raw SQL (research.md §5), reusing `decideConfidenceBand` (005-ai-support) and the
|
||||
`Resolution.resolvedBy` convention (014-full-observability) rather than reimplementing either.
|
||||
|
||||
**Storage**: One new table, `ErrorCodeLookup` (`id`, `errorCodeId` FK, `productId` FK,
|
||||
`createdAt`) — append-only, no update/delete path, indexed `(productId, createdAt)` for the
|
||||
Product dashboard's range-scoped ranking query. No change to any existing table.
|
||||
|
||||
**Testing**: Vitest — unit tests for the "no data → `null`, never `NaN`" averaging helper and the
|
||||
confidence-bucketing reuse; integration tests against real Postgres/Redis driving each
|
||||
dashboard's real underlying data (tickets in various terminal states, SLA runs met/breached,
|
||||
escalation events, AI sessions/diagnoses/actions, error-code lookups) and asserting every
|
||||
returned figure against hand-computed expected values — the same rigor and mixed
|
||||
HTTP-driven/direct-repository setup style as 014's `business-metrics.test.ts`.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Rewrites `platform/reports` (service,
|
||||
new controller, new routes, new schema for the date-range/product-id query params) from its
|
||||
current one-stub-method state into the real module. Adds one line to
|
||||
`ai-support/knowledge/service/error-codes.service.ts`'s existing `findKnownIssuesByErrorCode`
|
||||
(the same call site 014 already instrumented) to also write the new durable audit row.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Every dashboard query is bounded by the requested date range (default 30
|
||||
days, config) and, where a full-row fetch is needed for in-application averaging (research.md
|
||||
§5), only the two timestamp columns needed for that specific average — never a full-table scan
|
||||
with no range filter. Acceptable at current data volumes per spec.md's own Assumptions;
|
||||
pre-aggregation is explicitly deferred to if/when load testing (a separate, not-yet-started
|
||||
Phase 11 sub-area) shows it's actually needed.
|
||||
|
||||
**Constraints**: FR-006 — an unknown `productId` on the Product dashboard is a 404, never an
|
||||
empty-but-200 response. FR-007 — every rate/average is `number | null`, `null` meaning "no
|
||||
qualifying data," computed by checking the qualifying count before ever dividing. FR-008 — every
|
||||
route requires `requireRole('ADMIN')`, the same gate every admin surface uses since
|
||||
010-identity-auth.
|
||||
|
||||
**Scale/Scope**: Four new `GET` routes, one new Prisma model + migration, four new service
|
||||
methods (one per dashboard) replacing the single stub method, one new schema file for query-param
|
||||
validation, three new env-configured values (Constitution Principle II). No new module — this
|
||||
extends `platform/reports`, already the correct architectural home.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | Not applicable — no identity/access surface touched; every figure is derived from SupportHub's own domain data (tickets, problems, SLA, escalation, AI sessions), squarely inside SupportHub's own sole-authority domain per this principle's own second sentence. | PASS |
|
||||
| II. Configuration Over Hardcoding | The default reporting window, the SLA-risk threshold, and the top-N ranking limit are all new env-configured values (`REPORTING_DEFAULT_WINDOW_DAYS`, `REPORTING_SLA_RISK_THRESHOLD_MINUTES`, `REPORTING_TOP_N_LIMIT`), never hardcoded — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | All new code lives inside `platform/reports` (already its correct home) following Route → Schema → Controller → Service → Repository → Prisma; cross-module reads (tickets, AI support, orchestration, SLA/escalation, problem resolution) go through each owning module's own public `index.ts`, the same precedent every prior feature this session established (e.g. `tool-executor.ts` reading `ticketsService` from `@/modules/ticketing/tickets`). | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI tool-execution or decision logic changed; the AI dashboard only reports on outcomes the existing, already-deterministic confidence-band/tool-policy code already produced. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable — no resolution-recording logic changed. | PASS — N/A |
|
||||
| VI. Durable Audit & History | The one new table (`ErrorCodeLookup`) is itself an append-only audit record, directly in this principle's spirit — "which error codes came up, when" becomes durably answerable for the first time. | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Not applicable — read-only aggregation queries, no job handlers, no assignment/SLA state mutated. | PASS — N/A |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Respected — the Product dashboard's problem-type breakdown queries `Problem` directly, never conflating it with `Ticket`. | PASS |
|
||||
| Technology & Platform Constraints | No new dependencies; one new Prisma model via the established non-interactive migration workflow (`prisma migrate diff` → hand-written `migration.sql` → `prisma migrate deploy`) this session has used for every prior schema change. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/015-reporting-dashboards/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── reports-api-contract.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # MODIFIED — new ErrorCodeLookup model
|
||||
│ └── migrations/
|
||||
│ └── <timestamp>_add_error_code_lookup/migration.sql # NEW
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── env.ts / reporting.ts (or similar) # MODIFIED — 3 new env-configured values
|
||||
│ └── modules/
|
||||
│ ├── platform/
|
||||
│ │ └── reports/ # REWRITTEN (was a 1-method stub)
|
||||
│ │ ├── controller/
|
||||
│ │ ├── mapper/ # date-range parsing/defaulting, averaging helper
|
||||
│ │ ├── repository/ # the 4 dashboards' Prisma queries
|
||||
│ │ ├── routes/
|
||||
│ │ ├── schema/ # query-param validation
|
||||
│ │ ├── service/
|
||||
│ │ └── index.ts
|
||||
│ └── ai-support/
|
||||
│ └── knowledge/
|
||||
│ ├── repository/ # MODIFIED — errorCodeLookupRepository
|
||||
│ └── service/
|
||||
│ └── error-codes.service.ts # MODIFIED — one new line at the existing
|
||||
│ lookup call site
|
||||
└── tests/
|
||||
├── unit/platform/reports/ # averaging/no-data-null helper, confidence
|
||||
│ bucketing reuse
|
||||
└── integration/platform-reports/ # all four dashboards against real data
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module — `platform/reports` already exists as the
|
||||
correct architectural home and simply needs its real implementation built out, following the
|
||||
same Route → Schema → Controller → Service → Repository → Prisma layering every other module
|
||||
already uses.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Quickstart: Reporting and Analytics Dashboards
|
||||
|
||||
Manual verification steps for each user story, against a running instance backed by real
|
||||
Postgres/Redis, logged in as an ADMIN.
|
||||
|
||||
## Scenario 1 — Management dashboard (User Story 1)
|
||||
|
||||
1. Create several tickets within a known date range: some reaching `AI_RESOLVED`/`RESOLVED` via
|
||||
an AI session, some escalated to a human and resolved via `resolutionsService.record`, some
|
||||
left open.
|
||||
2. Let one ticket's SLA run complete on time and another breach (via the existing breach sweep).
|
||||
3. `GET /admin/reports/management?from=<range start>&to=<range end>`.
|
||||
4. **Expected**: `totalCases`, `aiResolved`, `humanEscalated`, `resolved`, `open` all match what
|
||||
was actually created; `slaCompliance.met`/`.breached` match the two SLA outcomes;
|
||||
`averageResponseSeconds`/`averageResolutionSeconds` are non-null and plausible.
|
||||
5. Request the same endpoint for a date range with no activity at all.
|
||||
6. **Expected**: every count is `0`, every rate/average is `null`, not an error.
|
||||
|
||||
## Scenario 2 — Product dashboard (User Story 2)
|
||||
|
||||
1. Create tickets for two distinct products in the same range, one with a categorized problem.
|
||||
2. Look up a known error code for one product several times, a different code once.
|
||||
3. `GET /admin/reports/product/:externalProductId` for each product.
|
||||
4. **Expected**: each product's `supportVolume`/`problemsByCategory`/`aiResolutionRate` reflect
|
||||
only its own tickets; `topErrors` ranks the more-frequently-looked-up code first.
|
||||
5. Request the endpoint for a nonexistent `externalProductId`.
|
||||
6. **Expected**: `404 NOT_FOUND`, not an empty `200`.
|
||||
|
||||
## Scenario 3 — Support dashboard (User Story 3)
|
||||
|
||||
1. Assign several tickets across two agents (some via the real orchestration flow).
|
||||
2. Let one ticket's SLA run sit within `REPORTING_SLA_RISK_THRESHOLD_MINUTES` of its resolution
|
||||
due date without breaching.
|
||||
3. `GET /admin/reports/support`.
|
||||
4. **Expected**: `workloadByAgent` matches each agent's real current open-assignment count;
|
||||
`slaAtRisk` counts exactly the near-due run, distinct from `slaBreached`.
|
||||
|
||||
## Scenario 4 — AI dashboard (User Story 4)
|
||||
|
||||
1. Run AI sessions to a mix of terminal outcomes (`resolved`, `escalated`), with some tool
|
||||
invocations succeeding and others failing, and diagnoses spanning a range of confidence
|
||||
values.
|
||||
2. `GET /admin/reports/ai`.
|
||||
3. **Expected**: `aiResolutionRate`/`humanHandoffRate` reflect the real outcome mix;
|
||||
`confidenceDistribution` buckets match `decideConfidenceBand`'s own classification of each
|
||||
diagnosis's stored confidence against the system-default thresholds; `toolInvocations`
|
||||
reflects the real success/failure counts.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All four scenarios pass against a real Postgres/Redis, every figure independently verified
|
||||
against hand-computed expected values, and no route is reachable by a non-admin session.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Research: Reporting and Analytics Dashboards
|
||||
|
||||
## 1. Where this lives
|
||||
|
||||
**Decision**: Wire up the existing, pre-scaffolded `src/modules/platform/reports` module (today
|
||||
just `ReportsService.generateSummaryReport()` returning `{}`, confirmed unused anywhere) rather
|
||||
than creating a new module. Its four real methods (`getManagementDashboard`,
|
||||
`getProductDashboard`, `getSupportDashboard`, `getAiDashboard`) replace the one stub method.
|
||||
Routes live at `GET /admin/reports/management`, `GET /admin/reports/product/:externalProductId`,
|
||||
`GET /admin/reports/support`, `GET /admin/reports/ai`, admin-gated the same way every other
|
||||
admin-only endpoint since 010-identity-auth already is (`requireRole('ADMIN')`).
|
||||
|
||||
**Why not the `ANALYTICS` queue** (`src/jobs/analytics`, also pre-scaffolded, also inert): a
|
||||
queued background job fits pre-computing a report nobody is currently waiting on; a dashboard
|
||||
request is someone waiting right now for an answer. Per spec.md's Assumptions, this first cut is
|
||||
synchronous, direct-query aggregation — the queue stub stays exactly as inert as it already was,
|
||||
untouched by this feature.
|
||||
|
||||
## 2. Per-dashboard queries
|
||||
|
||||
All four use Prisma's `groupBy`/`count`/`aggregate`, scoped by `createdAt` (or the
|
||||
milestone-specific timestamp named below) within `[from, to]`, computed directly against the
|
||||
tables that already own each fact — no new table, no denormalized rollup.
|
||||
|
||||
### Management (FR-001)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Total cases | `Ticket.count({ createdAt in range })` |
|
||||
| AI resolved | `Ticket.count({ createdAt in range, status: 'AI_RESOLVED' })` — a ticket that reached `AI_RESOLVED` and stayed there or moved straight to `RESOLVED` without a `Resolution.resolvedBy` other than `'ai'`; see §4 below for the exact "who resolved it" rule shared with the Product dashboard |
|
||||
| Human escalated | `Ticket.count({ createdAt in range, status in [HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED] })` minus AI-only-path tickets — i.e. any ticket that ever reached `HUMAN_ESCALATION`; the state machine research.md already establishes this as one-way (003-ticketing) |
|
||||
| Resolved (either path) | `Ticket.count({ createdAt in range, status in [RESOLVED, CLOSED] })` |
|
||||
| Open | `Ticket.count({ createdAt in range, status not in [RESOLVED, CLOSED] })` |
|
||||
| SLA compliance / breach count | `SLARun.groupBy(['status'], { ticket: { createdAt in range } })`, `status: 'completed'` = met, `'breached'` = breached (mirrors 014's own metric semantics — see 014 research.md §5's "read status before the overwrite" caveat, which applies equally here: a `'breached'`-then-`'completed'` run is still counted breached, by reading the `breachedAt`/`firstResponseBreachedAt` timestamps rather than only the current `status` string) |
|
||||
| Escalation count | `EscalationEvent.count({ createdAt in range })` |
|
||||
| Average response time | `avg(firstAgentMessage.createdAt - ticket.createdAt)` over tickets with at least one `AGENT_MESSAGE` in range — computed in application code over a bounded query result (see §5, no raw SQL) |
|
||||
| Average resolution time | `avg(resolution.createdAt - ticket.createdAt)` over tickets with a `Resolution` row in range |
|
||||
|
||||
### Product (FR-002)
|
||||
|
||||
Same shape as Management, `WHERE Ticket.productId = :productId` (resolved from the given
|
||||
`externalProductId`, 404 if not found — FR-006), plus:
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Problem-type breakdown | `Problem.groupBy(['categoryId'], { productId, createdAt in range })` |
|
||||
| Recurring problems | Same grouped result, sorted descending, top N (config, default 10) |
|
||||
| Top errors | `reuses 014's own instrumentation point conceptually but queries fresh` — no, see §6: there is no persisted "error code lookup" table, only 014's in-memory Prometheus counter, which is NOT queryable historically. Resolved by adding a durable audit read instead: see §6. |
|
||||
|
||||
### Support (FR-003)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| Per-agent workload | `Assignment.groupBy(['agentId'], { isCurrent: true })` — a snapshot of *right now*, not date-ranged (workload is inherently current, not historical — spec.md's own framing: "how much work is currently assigned") |
|
||||
| SLA risk / breached | `SLARun.findMany({ status: 'running', resolutionDueAt: {gte: now} })` filtered in application code by "due within `SLA_RISK_THRESHOLD_MINUTES` of now" for risk, vs. `status: 'breached'` for already-breached |
|
||||
| Escalation count | Same as Management, unfiltered by product |
|
||||
| Response/resolution performance | Same computation as Management's averages |
|
||||
|
||||
### AI (FR-004)
|
||||
|
||||
| Figure | Source |
|
||||
|---|---|
|
||||
| AI resolution rate / human-handoff rate | `AISupportSession.groupBy(['status'], { startedAt in range })` — `resolved` vs. `escalated`/`ended_by_agent` as a share of total terminal sessions |
|
||||
| Failed-troubleshooting-then-escalated rate | Sessions with `status: 'escalated'` that have at least one `AIInteraction`/`AIAction` recording a failed troubleshooting attempt — see 005-ai-support's own runbook-step-outcome classification (`classifyStepOutcome`), reused rather than reinvented |
|
||||
| Knowledge-match rate | `AIKnowledgeReference` presence per session (`recordMany` is only ever called with actual retrieval results — 005-ai-support's own `diagnose.ts`) vs. sessions with zero references recorded |
|
||||
| Confidence distribution | `AIDiagnosis.findMany({ createdAt in range })`, bucketed in application code against `aiConfig.defaultHighConfidence`/`defaultLowConfidence` (see §7 — NOT a per-diagnosis resolved policy) |
|
||||
| Tool success/failure | `AIAction` joined to `AIActionResult`, grouped by `result.status` |
|
||||
|
||||
## 3. "No data" convention (FR-007)
|
||||
|
||||
**Decision**: every rate/average field is `number | null` — `null` means "no qualifying records
|
||||
in range," distinguished in the response shape from a genuine `0` (e.g., a real 0% AI resolution
|
||||
rate because everything escalated is a valid, meaningful `0`; "nobody's data exists yet" is
|
||||
`null`). Application code computes every average by fetching the qualifying count first and
|
||||
returning `null` before ever dividing, never relying on `0/0` producing `NaN` and hoping a caller
|
||||
notices.
|
||||
|
||||
## 4. "Who resolved it" — reused from 014, not reinvented
|
||||
|
||||
014-full-observability's own event subscriber already established the authoritative rule: a
|
||||
ticket's `Resolution.resolvedBy` field (`"ai"` | an `agentId`) is the single source of truth for
|
||||
whether a resolution was AI- or human-driven (014 research.md §5). This feature's Management/
|
||||
Product dashboards reuse the exact same join (`Resolution.findMany` scoped to the range,
|
||||
`resolvedBy === 'ai'` vs. not) rather than re-deriving it from `Ticket.status` transitions a
|
||||
second, potentially-inconsistent way.
|
||||
|
||||
## 5. No raw SQL
|
||||
|
||||
**Decision**: every duration average (response time, resolution time) is computed by fetching
|
||||
the bounded set of qualifying rows (ticket `createdAt` + the milestone timestamp) via Prisma and
|
||||
averaging in application code, not a raw `$queryRaw` computing `AVG(EXTRACT(EPOCH FROM ...))` in
|
||||
SQL. At the data volumes spec.md's Assumptions accept for this first cut (no pre-aggregation,
|
||||
synchronous queries), a bounded per-range fetch is simple, type-safe, and testable without
|
||||
hand-writing SQL — consistent with this codebase's near-total avoidance of `$queryRaw` elsewhere
|
||||
(confirmed by grep: no existing module uses it for reporting-shaped queries).
|
||||
|
||||
## 6. Top errors needs a durable, queryable record — a real gap 014 left open
|
||||
|
||||
014-full-observability's `supporthub_known_error_lookups_total` Prometheus counter is
|
||||
process-lifetime, in-memory, and reset on every restart — useless for "top errors in the last 30
|
||||
days." Since no durable "error code lookup" record exists anywhere in this codebase today (the
|
||||
existing `error-codes.service.ts` just reads `KnownIssue`/`ErrorCode` rows, never records that a
|
||||
lookup happened), this feature adds one small, focused piece of new state: a durable
|
||||
`ErrorCodeLookup` audit row (`errorCodeId`, `productId`, `createdAt`), written by
|
||||
`error-codes.service.ts`'s already-existing `findKnownIssuesByErrorCode` (the same call site
|
||||
014 instrumented for its own live counter — this feature adds one more line there, a durable
|
||||
write alongside the existing live-metric increment, not a replacement for it). This is the one
|
||||
schema change this feature needs; every other dashboard figure is computed from tables that
|
||||
already exist.
|
||||
|
||||
## 7. Confidence distribution uses the system default threshold, not a per-diagnosis policy
|
||||
|
||||
**Decision**: bucket every `AIDiagnosis.confidence` value in range against the env-configured
|
||||
system-wide defaults (`aiConfig.defaultHighConfidence`/`defaultLowConfidence`), the same
|
||||
`decideConfidenceBand` pure function 005-ai-support already exports — reused directly, not
|
||||
reimplemented.
|
||||
|
||||
**Why not resolve each diagnosis's actual applicable per-product/category policy** (what the
|
||||
live reasoning path itself does): `AIDiagnosis.product`/`feature` are the AI's own free-text
|
||||
classification output, not foreign keys to `Product`/`Category` — there is no reliable, existing
|
||||
join from a diagnosis row back to which `ConfidencePolicy` row actually applied to it at the time
|
||||
without speculatively string-matching free text against product names, which this codebase does
|
||||
nowhere else and which research.md declines to invent here. A dashboard-level aggregate
|
||||
distribution using the system-wide default is an honest, documented simplification (spec.md
|
||||
Assumptions) — precise enough to show a meaningful shape without fabricating a false precision
|
||||
the data doesn't actually support.
|
||||
|
||||
## 8. New configuration (Constitution Principle II — nothing hardcoded)
|
||||
|
||||
| Env var | Default | Used by |
|
||||
|---|---|---|
|
||||
| `REPORTING_DEFAULT_WINDOW_DAYS` | `30` | Every dashboard's `from`/`to` default when omitted (FR-005) |
|
||||
| `REPORTING_SLA_RISK_THRESHOLD_MINUTES` | `60` | Support dashboard's "at risk" classification (FR-003) |
|
||||
| `REPORTING_TOP_N_LIMIT` | `10` | Product dashboard's recurring-problems/top-errors ranking length |
|
||||
|
||||
## 9. Test strategy
|
||||
|
||||
Integration tests create real tickets/problems/SLA runs/escalation events/AI sessions/diagnoses/
|
||||
actions/error-code lookups directly against real Postgres (mixing real HTTP-driven setup where a
|
||||
realistic flow matters and direct repository/Prisma writes where only the aggregation math is
|
||||
under test — the same mix 014's own `business-metrics.test.ts` used), then request each
|
||||
dashboard endpoint and assert every figure against hand-computed expected values. Unit tests
|
||||
cover the "no data → null, never NaN" guard and the confidence-bucketing pure-function reuse.
|
||||
@@ -0,0 +1,257 @@
|
||||
# Feature Specification: Reporting and Analytics Dashboards
|
||||
|
||||
**Feature Branch**: `015-reporting-dashboards`
|
||||
|
||||
**Created**: 2026-09-09
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Reporting and analytics dashboards: real, read-only aggregation endpoints backing the four dashboards named in docs/09-testing-observability-cicd.md (Management, Product, Support, AI) — wiring up the pre-scaffolded but never-implemented platform/reports module into actual database-backed aggregation queries, admin-gated, with a date-range filter."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Management sees organization-wide support health (Priority: P1)
|
||||
|
||||
An admin or team lead opens a single view showing how support is doing overall for a chosen
|
||||
period: how many cases came in, how many were resolved (by AI vs. by a human), how many are
|
||||
still open, whether SLA commitments are being met, and how escalation is trending.
|
||||
|
||||
**Why this priority**: This is the one dashboard covering the whole roadmap's own top-level
|
||||
success criteria (`docs/10-implementation-roadmap.md`'s checklist) in one place — the first
|
||||
thing anyone asks about a support operation is "how are we doing," and today there is no way to
|
||||
answer that except querying the database by hand.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating a known set of tickets in various terminal
|
||||
states (AI-resolved, human-resolved, still open) plus a mix of met/breached SLA runs and
|
||||
escalations within a chosen date range, then requesting the Management dashboard for that range
|
||||
and confirming every figure matches what was actually created.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mix of tickets created within a chosen date range — some AI-resolved, some
|
||||
human-resolved, some still open — **When** the Management dashboard is requested for that
|
||||
range, **Then** total cases, AI-resolved count, human-escalated count, resolved count, and
|
||||
open count all match the actual data exactly.
|
||||
2. **Given** SLA runs that completed on time and others that breached within the range,
|
||||
**When** the dashboard is requested, **Then** SLA compliance (a rate) and SLA breach count
|
||||
both reflect the real outcomes.
|
||||
3. **Given** some tickets have a recorded first agent response and a resolution timestamp,
|
||||
**When** the dashboard is requested, **Then** average response time and average resolution
|
||||
time are computed only from tickets that actually reached those milestones within the range
|
||||
(a still-open ticket contributes to "open count" but never a fabricated resolution time).
|
||||
4. **Given** a date range with zero activity, **When** the dashboard is requested, **Then** every
|
||||
count is zero and every average is reported as "no data" rather than a computed zero or a
|
||||
division-by-zero error.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See support broken down by product (Priority: P1)
|
||||
|
||||
An admin viewing support data for a specific product (or comparing products) sees volume,
|
||||
problem-type breakdown, which problems recur most, how well AI is resolving that product's
|
||||
issues versus escalating them, and which error codes come up most often.
|
||||
|
||||
**Why this priority**: SupportHub serves multiple SaaS products (Constitution Principle I); a
|
||||
number that isn't broken out by product hides which integration actually needs attention — this
|
||||
is as fundamental as the Management view, just sliced differently.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating tickets/problems/error-code lookups across
|
||||
two distinct products within a date range, requesting the Product dashboard for each product,
|
||||
and confirming each one's figures include only its own product's data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets exist for two different products in the same date range, **When** the
|
||||
Product dashboard is requested scoped to one product, **Then** support volume and every other
|
||||
figure reflect only that product's tickets, never the other product's.
|
||||
2. **Given** problems in several categories for one product, **When** the dashboard is
|
||||
requested, **Then** the problem-type breakdown and "recurring problems" ranking both reflect
|
||||
the real category distribution, most-frequent first.
|
||||
3. **Given** a mix of AI-resolved and human-escalated tickets for one product, **When** the
|
||||
dashboard is requested, **Then** AI resolution rate and human escalation rate are both
|
||||
computed as a percentage of that product's own total, not the platform-wide total.
|
||||
4. **Given** several known-error-code lookups for one product, some codes looked up more than
|
||||
others, **When** the dashboard is requested, **Then** "top errors" lists those codes ranked by
|
||||
lookup frequency.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Support sees team workload and performance (Priority: P2)
|
||||
|
||||
An admin or team lead sees how much work is currently assigned across agents, which tickets are
|
||||
at SLA risk, how much escalation is happening, and how quickly the team is responding to and
|
||||
resolving tickets.
|
||||
|
||||
**Why this priority**: This view is about ongoing operational load, not historical trend — useful
|
||||
for day-to-day team management, but the organization can already see whether it's healthy
|
||||
overall from User Story 1 without this one; P2 reflects that it adds an operational lens rather
|
||||
than a new class of information.
|
||||
|
||||
**Independent Test**: Can be fully tested by assigning several tickets to known agents (some
|
||||
close to SLA breach, some not), then requesting the Support dashboard and confirming workload
|
||||
per agent and the SLA-risk count both match reality.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** several tickets are currently assigned across two agents, **When** the Support
|
||||
dashboard is requested, **Then** each agent's current open-assignment count matches what was
|
||||
actually assigned to them (not a stale count from a previous, now-unassigned period).
|
||||
2. **Given** a ticket's SLA run is running and past a configurable risk threshold of its
|
||||
resolution due date (but not yet breached), **When** the dashboard is requested, **Then** it
|
||||
is counted as "at risk," distinct from both "on track" and "breached."
|
||||
3. **Given** response and resolution durations for several resolved tickets in the period,
|
||||
**When** the dashboard is requested, **Then** response-performance and resolution-performance
|
||||
figures are computed only from tickets that actually reached those milestones.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - See how well the AI is performing (Priority: P2)
|
||||
|
||||
An admin sees, for a chosen period, how often the AI resolves issues on its own versus escalating
|
||||
them, how often its attempted troubleshooting fails outright, how often it finds relevant
|
||||
knowledge, how confident its diagnoses tend to be, how reliably its tools succeed, and how often
|
||||
it ultimately hands off to a human.
|
||||
|
||||
**Why this priority**: This is the dashboard that validates the AI-first design's core premise
|
||||
(Constitution Principle IV) is actually working in practice — valuable, but a narrower audience
|
||||
than the org-wide and per-product views above, hence P2.
|
||||
|
||||
**Independent Test**: Can be fully tested by running several AI sessions to different terminal
|
||||
outcomes (resolved, escalated, escalated-after-failed-troubleshooting) with a mix of tool
|
||||
successes/failures and confidence levels recorded, then requesting the AI dashboard and
|
||||
confirming every figure matches the real session data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mix of AI sessions ending resolved vs. escalated in the period, **When** the AI
|
||||
dashboard is requested, **Then** AI resolution rate and human-handoff rate both reflect the
|
||||
real outcome mix as percentages of total sessions.
|
||||
2. **Given** some AI tool invocations succeeded and others failed in the period, **When** the
|
||||
dashboard is requested, **Then** tool success/failure figures reflect the real invocation
|
||||
outcomes.
|
||||
3. **Given** diagnoses were recorded with a range of confidence values, **When** the dashboard is
|
||||
requested, **Then** the confidence distribution groups them into the same proceed/ask/escalate
|
||||
bands the AI support module's own confidence-policy service already classifies each diagnosis
|
||||
into (005-ai-support), not a newly-invented scheme.
|
||||
4. **Given** some AI sessions' knowledge-retrieval step found matching entries and others found
|
||||
none, **When** the dashboard is requested, **Then** knowledge-match rate reflects the real
|
||||
match/no-match mix.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when no `from`/`to` date range is given? Defaults to a reasonable trailing window
|
||||
(see Assumptions) rather than scanning the entire history unbounded on every request.
|
||||
- What happens when `from` is after `to`? Rejected as a validation error, not silently swapped or
|
||||
silently returning empty data.
|
||||
- What happens when a requested `productId` (Product dashboard) doesn't exist? Rejected with a
|
||||
clear not-found error, not an empty-but-200 response that looks like "this product has zero
|
||||
activity."
|
||||
- What happens when an average would divide by zero (no tickets reached that milestone in the
|
||||
range)? Reported as an explicit "no data" value, never `NaN`, `null` silently coerced to `0`,
|
||||
or a thrown error.
|
||||
- What happens when a ticket's SLA run was paused for part of the period? SLA-risk/compliance
|
||||
figures use the run's own already-durable due dates (008-sla-escalation's pause/resume
|
||||
already accounts for paused time) rather than this feature re-deriving elapsed time itself.
|
||||
- Who can see these dashboards? Same admin-only gate as every other admin configuration/reporting
|
||||
surface introduced since 010-identity-auth — no new role is introduced.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a Management dashboard summarizing, for a given date range:
|
||||
total cases created, cases resolved by AI, cases escalated to a human, total resolved
|
||||
(either path), total still open, SLA compliance rate, SLA breach count, escalation count,
|
||||
average first-response time, and average resolution time.
|
||||
- **FR-002**: System MUST provide a Product dashboard summarizing, for a given date range and a
|
||||
specific product: support volume, a breakdown by problem category, a ranked list of the most
|
||||
recurring problem categories, AI resolution rate, human escalation rate, and a ranked list of
|
||||
the most frequently looked-up error codes.
|
||||
- **FR-003**: System MUST provide a Support dashboard summarizing, for a given date range:
|
||||
current per-agent open-assignment workload, count of tickets at SLA risk (past a configurable
|
||||
risk threshold of their resolution due date but not yet breached), count of tickets already
|
||||
breached, escalation count, average response performance, and average resolution performance.
|
||||
- **FR-004**: System MUST provide an AI dashboard summarizing, for a given date range: AI
|
||||
resolution rate, rate of sessions that escalated after at least one failed troubleshooting
|
||||
attempt, knowledge-match rate, a distribution of diagnosis confidence across the existing
|
||||
proceed/ask/escalate bands, tool invocation success/failure counts, and human-handoff rate.
|
||||
- **FR-005**: Every dashboard endpoint MUST accept an optional `from`/`to` date range; when
|
||||
omitted, it MUST default to a documented trailing window rather than scanning unbounded
|
||||
history.
|
||||
- **FR-006**: The Product dashboard MUST require a valid `productId` and MUST reject an unknown
|
||||
one with a clear not-found error rather than returning an empty-but-successful response.
|
||||
- **FR-007**: Every rate/average figure MUST be computed only from tickets/sessions/runs that
|
||||
actually reached the relevant milestone within the range; a metric with no qualifying data MUST
|
||||
be reported as an explicit "no data" value, never a computed `0`, `null`, or `NaN`.
|
||||
- **FR-008**: All four dashboard endpoints MUST be admin-gated, consistent with every other
|
||||
admin-only reporting/configuration surface in this codebase.
|
||||
- **FR-009**: This feature MUST NOT alter the meaning or shape of any existing endpoint, event, or
|
||||
table — nearly every figure is derived read-only from data already durably recorded by the
|
||||
modules that own it (003 ticketing, 005 AI support, 007 orchestration, 008 SLA/escalation, 009
|
||||
problem resolution). The one exception is FR-011: a small new durable record needed only
|
||||
because no existing table can answer "which error codes are looked up most" historically.
|
||||
- **FR-011**: System MUST durably record each known-error-code lookup (product, error code,
|
||||
timestamp) at the point it already happens (the existing error-code lookup call site) so the
|
||||
Product dashboard's "top errors" ranking (FR-002) can be computed historically — the
|
||||
equivalent live, in-process counter this project already exposes on `/metrics` (014-full-
|
||||
observability) is process-lifetime and reset on every restart, unusable for a historical
|
||||
dashboard.
|
||||
- **FR-010**: This feature is backend-only; presenting these figures in a UI is a separate,
|
||||
explicitly out-of-scope follow-on (see Assumptions).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Dashboard response**: A read-only, computed JSON summary for one of the four dashboards over
|
||||
a requested date range (and, for the Product dashboard, one product) — never itself persisted;
|
||||
recomputed fresh on every request from existing durable records.
|
||||
- **Date range**: An inclusive `from`/`to` pair (calendar dates or timestamps) scoping every
|
||||
aggregation query; not a stored entity, a request parameter.
|
||||
- **Error code lookup record** (new, FR-011): a durable, append-only audit row — which product,
|
||||
which error code, when — written at the existing lookup call site; exists solely so "top
|
||||
errors" can be computed over a historical range, never read or written anywhere else.
|
||||
- **Confidence band**: The existing proceed/ask/escalate classification 005-ai-support already
|
||||
applies to a diagnosis's confidence score — reused here for the AI dashboard's distribution, not
|
||||
redefined.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: For any chosen date range, every figure on all four dashboards can be independently
|
||||
verified against the underlying ticket/session/SLA-run/escalation-event records and matches
|
||||
exactly — no discrepancy between what a dashboard reports and what actually happened.
|
||||
- **SC-002**: An admin can answer "how is support doing right now" (Management), "how is this
|
||||
specific product doing" (Product), "who's overloaded and what's at risk" (Support), and "is the
|
||||
AI actually helping" (AI) each from a single request, with no manual database query needed.
|
||||
- **SC-003**: A dashboard request for a period with no matching activity returns clean, explicit
|
||||
"no data" results in well under a second — never an error, a stall, or a misleading zero.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Presentation is out of scope for this feature.** The user's own explicit direction was to
|
||||
build the backend aggregation capability first (the established pattern this project has
|
||||
followed for every prior feature that touched both repos — identity/auth, the agent ticket
|
||||
queue, and full observability were each built backend-first). A `supporthub-web` dashboard UI
|
||||
consuming these endpoints is a natural, separate follow-on, not bundled into this spec.
|
||||
- The default trailing window when no date range is given is the last 30 days, matching common
|
||||
reporting-dashboard convention; CONFIGURABLE via the same admin-config env-driven pattern this
|
||||
project already uses for every other business-policy value (Constitution Principle II), not
|
||||
hardcoded as a magic number in application logic.
|
||||
- "SLA risk" needs a threshold (how close to the due date counts as "at risk") that the business
|
||||
has not specified — CONFIGURABLE, not invented as a hardcoded percentage, consistent with
|
||||
`docs/10-implementation-roadmap.md`'s own "never hardcode a placeholder value and ship it as
|
||||
final" instruction.
|
||||
- These endpoints compute their figures synchronously, on request, directly from the existing
|
||||
tables — no new pre-aggregation table, no scheduled batch job, and no use of the pre-scaffolded
|
||||
`ANALYTICS` queue (`src/jobs/analytics`), which remains an inert stub outside this feature's
|
||||
scope. Live query performance at current data volumes is assumed adequate; a future feature can
|
||||
introduce pre-aggregation if and when it's actually needed (load/concurrency testing, a
|
||||
separate not-yet-started Phase 11 sub-area, is where that question would be validated).
|
||||
- "Top errors"/"recurring problems" rankings return a bounded top-N list (CONFIGURABLE limit,
|
||||
defaulting to 10) rather than the full distribution, matching how a dashboard is actually
|
||||
consumed.
|
||||
- Dashboard responses are computed fresh per request (no caching layer) — acceptable given the
|
||||
assumed data volumes and consistent with not prematurely optimizing ahead of the load-testing
|
||||
phase.
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
description: 'Task list for 015-reporting-dashboards'
|
||||
---
|
||||
|
||||
# Tasks: Reporting and Analytics Dashboards
|
||||
|
||||
**Input**: Design documents from `specs/015-reporting-dashboards/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/reports-api-contract.md](./contracts/reports-api-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 Management, US2 = P1 Product,
|
||||
US3 = P2 Support, US4 = P2 AI). All four share the Foundational phase (schema, config, shared
|
||||
helpers, module scaffolding) but are otherwise independent of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [x] T001 Add `REPORTING_DEFAULT_WINDOW_DAYS` (default `30`),
|
||||
`REPORTING_SLA_RISK_THRESHOLD_MINUTES` (default `60`), and `REPORTING_TOP_N_LIMIT`
|
||||
(default `10`) to `src/config/env.ts`, exposed via a new `reportingConfig` in
|
||||
`src/config/reporting.ts` (or added to an existing config file, matching this codebase's
|
||||
own per-feature config-file convention)
|
||||
- [x] T002 Add the `ErrorCodeLookup` model to `prisma/schema.prisma` per data-model.md, generate
|
||||
the migration via `prisma migrate diff --from-url <db-url> --to-schema-datamodel
|
||||
./prisma/schema.prisma --script`, hand-write it into
|
||||
`prisma/migrations/<timestamp>_add_error_code_lookup/migration.sql`, apply via `prisma
|
||||
migrate deploy` against the throwaway test database (depends on T001 only in that both
|
||||
are Foundational — no code dependency)
|
||||
- [x] T003 Add `ai-support/knowledge/repository/error-code-lookup.repository.ts` —
|
||||
`create(errorCodeId, productId)`, exported from the knowledge module's repository index
|
||||
(depends on T002)
|
||||
- [x] T004 [P] Call the new repository's `create(...)` from
|
||||
`ai-support/knowledge/service/error-codes.service.ts`'s existing
|
||||
`findKnownIssuesByErrorCode`, alongside (not replacing) 014's own
|
||||
`knownErrorLookupsCounter.inc(...)` call at that same call site (depends on T003)
|
||||
- [x] T005 [P] Add `platform/reports/mapper/date-range.ts` — parses/validates `from`/`to` query
|
||||
params, defaulting via T001's `reportingConfig.defaultWindowDays`, throwing
|
||||
`ValidationError` when `from > to` (depends on T001)
|
||||
- [x] T006 [P] Add `platform/reports/mapper/rate.ts` — a shared `computeRate(numerator,
|
||||
denominator): number | null` and `computeAverageSeconds(durations: number[]): number |
|
||||
null` pair, both returning `null` (never `NaN`/`0`) when there's no qualifying data
|
||||
(research.md §3) — no dependency, pure functions
|
||||
- [x] T007 Scaffold `platform/reports/schema/` (query-param zod schema using T005's date-range
|
||||
parsing), `platform/reports/controller/reports.controller.ts` (empty methods to be filled
|
||||
in per user story below), `platform/reports/routes/reports.routes.ts` registering all four
|
||||
routes behind `requireRole('ADMIN')`, and update `platform/reports/index.ts` to export the
|
||||
new public surface, replacing `generateSummaryReport`'s stub entirely (depends on T005,
|
||||
T006)
|
||||
|
||||
**Checkpoint**: Config, schema, shared helpers, and module scaffolding in place. Each dashboard
|
||||
can now be built independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 - Management sees organization-wide support health (Priority: P1)
|
||||
|
||||
**Goal**: `GET /admin/reports/management` returns real figures per data-model.md's
|
||||
`ManagementDashboard` shape.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T008 [P] [US1] Unit tests for T006's `computeRate`/`computeAverageSeconds` (empty input ->
|
||||
`null`; a real mix -> the correct value) in
|
||||
`tests/unit/platform/reports/rate-helpers.test.ts`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Add `platform/reports/repository/management.repository.ts` — one method per
|
||||
research.md §2's Management table row (ticket counts by status, SLA-run outcome counts,
|
||||
response/resolution duration row-fetches for T006 to average) (depends on T007)
|
||||
- [x] T010 [US1] Add `ReportsService.getManagementDashboard(range)` composing T009's repository
|
||||
calls into the `ManagementDashboard` shape, reusing the `Resolution.resolvedBy` convention
|
||||
(research.md §4) for the AI-vs-human split (depends on T009)
|
||||
- [x] T011 [US1] Wire `GET /admin/reports/management` to the controller/service (depends on T010)
|
||||
- [x] T012 [US1] Integration test covering Quickstart Scenario 1 (real tickets in various
|
||||
terminal states, a met and a breached SLA run, verified figure-by-figure; a no-activity
|
||||
range returns all-zero counts and all-null rates) in
|
||||
`tests/integration/platform-reports/management-dashboard.test.ts` (depends on T011)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - See support broken down by product (Priority: P1)
|
||||
|
||||
**Goal**: `GET /admin/reports/product/:externalProductId` returns real figures per
|
||||
`ProductDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T013 [P] [US2] Add `platform/reports/repository/product.repository.ts` — ticket/problem
|
||||
queries scoped by `productId`, plus a query against T003's `ErrorCodeLookup` table for
|
||||
the top-N ranking (`reportingConfig.topNLimit`) (depends on T007)
|
||||
- [x] T014 [US2] Add `ReportsService.getProductDashboard(externalProductId, range)`, 404-ing via
|
||||
`NotFoundError` when the product doesn't resolve (FR-006) before running any aggregation
|
||||
query (depends on T013)
|
||||
- [x] T015 [US2] Wire `GET /admin/reports/product/:externalProductId` (depends on T014)
|
||||
- [x] T016 [US2] Integration test covering Quickstart Scenario 2 (two products' data never
|
||||
cross-contaminating each other's figures; an unknown product 404s) in
|
||||
`tests/integration/platform-reports/product-dashboard.test.ts` (depends on T015)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 - Support sees team workload and performance (Priority: P2)
|
||||
|
||||
**Goal**: `GET /admin/reports/support` returns real figures per `SupportDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T017 [P] [US3] Add `platform/reports/repository/support.repository.ts` — current
|
||||
`Assignment` workload-by-agent query, `SLARun` at-risk/breached queries (`resolutionDueAt`
|
||||
within `reportingConfig.slaRiskThresholdMinutes` of now, per research.md §2) (depends on
|
||||
T007)
|
||||
- [x] T018 [US3] Add `ReportsService.getSupportDashboard(range)` (depends on T017)
|
||||
- [x] T019 [US3] Wire `GET /admin/reports/support` (depends on T018)
|
||||
- [x] T020 [US3] Integration test covering Quickstart Scenario 3 (real per-agent assignment
|
||||
counts; a near-due-but-not-breached run counted as at-risk, distinct from breached) in
|
||||
`tests/integration/platform-reports/support-dashboard.test.ts` (depends on T019)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 4 - See how well the AI is performing (Priority: P2)
|
||||
|
||||
**Goal**: `GET /admin/reports/ai` returns real figures per `AiDashboard`.
|
||||
|
||||
**Independent Test**: Quickstart Scenario 4.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [x] T021 [P] [US4] Unit test: the confidence-distribution bucketing reuses
|
||||
`decideConfidenceBand` (005-ai-support) against `aiConfig` defaults, not a reimplemented
|
||||
threshold check, in `tests/unit/platform/reports/confidence-distribution.test.ts`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T022 [US4] Add `platform/reports/repository/ai.repository.ts` — `AISupportSession` outcome
|
||||
counts, `AIDiagnosis` confidence fetch, `AIKnowledgeReference` presence-per-session query,
|
||||
`AIAction`/`AIActionResult` outcome counts (depends on T007)
|
||||
- [x] T023 [US4] Add `ReportsService.getAiDashboard(range)`, bucketing confidence via
|
||||
`decideConfidenceBand` + `aiConfig.defaultHighConfidence`/`defaultLowConfidence`
|
||||
(research.md §7) (depends on T022, T021)
|
||||
- [x] T024 [US4] Wire `GET /admin/reports/ai` (depends on T023)
|
||||
- [x] T025 [US4] Integration test covering Quickstart Scenario 4 (real AI sessions to mixed
|
||||
outcomes, mixed tool results, a spread of diagnosis confidence values) in
|
||||
`tests/integration/platform-reports/ai-dashboard.test.ts` (depends on T024)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes. All four dashboards work independently and
|
||||
together — this feature's full scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T026 [P] Update `specs/015-reporting-dashboards/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T027 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T028 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
(particularly `error-codes.service.ts`'s own existing tests, now touched by T004)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — BLOCKS all four user stories
|
||||
- **User Story 1 (Phase 2)**: Depends on Foundational — independent of US2/US3/US4
|
||||
- **User Story 2 (Phase 3)**: Depends on Foundational — independent of US1/US3/US4
|
||||
- **User Story 3 (Phase 4)**: Depends on Foundational — independent of US1/US2/US4
|
||||
- **User Story 4 (Phase 5)**: Depends on Foundational — independent of US1/US2/US3
|
||||
- **Polish (Phase 6)**: Depends on all four user stories
|
||||
@@ -0,0 +1,80 @@
|
||||
# Specification Quality Checklist: Load and Concurrency Testing
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- This feature was scoped from a targeted codebase audit (not guesswork) confirming which
|
||||
concurrency guarantees already exist untested (ticket optimistic concurrency) versus which
|
||||
have no protection at all today (assignment double-assignment, SLA pause/resume, escalation
|
||||
idempotency) — see spec.md's own Assumptions section.
|
||||
- Per this project's own roadmap convention, exact load-test pass/fail thresholds are left as an
|
||||
explicit `OPEN BUSINESS DECISION` (FR-009) rather than invented — this is intentional, not a
|
||||
gap requiring [NEEDS CLARIFICATION].
|
||||
- All items pass; no revision iterations were needed.
|
||||
|
||||
## Implementation-time findings
|
||||
|
||||
- **All three suspected real races were confirmed real, then fixed.** Before the fix, firing 20
|
||||
genuinely concurrent assignment attempts at the same ticket reliably threw an unhandled
|
||||
Postgres unique-constraint error once the new `assignments_one_current_per_ticket` partial
|
||||
index was in place (proving the race existed even before the retry logic was added) — after
|
||||
the fix (bounded retry with jitter in `AssignmentRepository.createAssignment`), it holds
|
||||
consistently across 10 repeated runs. Escalation idempotency was proven the same way: the
|
||||
database-level unique-violation is visibly caught and absorbed in the logs during the test,
|
||||
confirming the fix actually engages under a genuine race rather than sitting untested.
|
||||
- **The ticket-status optimistic-concurrency mechanism (User Story 4) needed no fix** — proven
|
||||
correct on the first run, exactly as research.md's Assumptions predicted.
|
||||
- **A real, pre-existing test-infrastructure issue was found and resolved along the way**: the
|
||||
throwaway integration-test Postgres database had accumulated a very large number of tickets
|
||||
over this project's long development history, and the ticket-code generator's own
|
||||
documented "rare race between two concurrent creates" (a read-then-increment sequence number
|
||||
scoped by code prefix) became a frequent occurrence at that accumulated volume — manifesting
|
||||
as dozens of unrelated integration-test failures when the full suite ran, unrelated to any
|
||||
change in this feature. Confirmed by direct reproduction (a debug run showing the literal
|
||||
`Unique constraint failed on the fields: (code)` error) and by re-running the exact same
|
||||
suite cleanly (122/124 passing, matching the project's known accepted baseline) after
|
||||
dropping and recreating the throwaway database and replaying its full migration history
|
||||
(`prisma migrate deploy`, 12 migrations including this feature's own). This is a test-
|
||||
infrastructure hygiene finding, not a defect in this feature's own code.
|
||||
- **Two additional integration-test failures seen only in the full-suite run (never in
|
||||
isolation)** were confirmed to be pre-existing cross-file contamination inherent to this
|
||||
suite's shared-database, non-fully-isolated hierarchy/agent scoping (already acknowledged in
|
||||
comments elsewhere in the suite, e.g. sla-escalation-flow.test.ts's own note about a
|
||||
wildcard SLA policy leaking across concurrently-running files) — re-running the two affected
|
||||
files together in isolation passed cleanly (13/13), ruling out this feature's own changes as
|
||||
the cause.
|
||||
- The autocannon-based load-test tooling (User Story 5) surfaced a real, non-obvious cost
|
||||
consideration: ticket creation asynchronously triggers a real, billed Anthropic API call for
|
||||
that ticket's first AI diagnosis turn (005-ai-support) — this applies to both the
|
||||
ticket-creation and AI-support-flow load scripts, not only the latter as initially assumed.
|
||||
All three scripts were run once at a small, explicitly bounded scale (confirmed with the
|
||||
project owner beforehand) rather than an open-ended duration, specifically to keep this real
|
||||
cost small and predictable.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Data Model: Load and Concurrency Testing
|
||||
|
||||
All changes below are additive to existing models — no existing column is removed or
|
||||
retyped, and no existing consumer (012-admin-list-views, 015-reporting-dashboards) needs any
|
||||
change, since none of them write to `Assignment`/`SLARun`/`EscalationEvent` directly (all writes
|
||||
already go through the repositories being changed here).
|
||||
|
||||
## `SLARun` (existing model, one new field)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `version` | `Int @default(0)` | NEW. Optimistic-concurrency counter, identical convention to `Ticket.version` (003-ticketing). Incremented on every successful `updateWithVersion` call. |
|
||||
|
||||
Migration: additive `ALTER TABLE sla_runs ADD COLUMN version INTEGER NOT NULL DEFAULT 0;` —
|
||||
every existing row defaults to `0`, which is exactly the version any in-flight or future
|
||||
`updateWithVersion` call expects for a run nobody has updated since this migration ran.
|
||||
|
||||
## `Assignment` (existing model, no column change — one new index)
|
||||
|
||||
New raw partial unique index (Prisma schema DSL cannot express a partial predicate directly, so
|
||||
this is added via a raw-SQL migration step, same approach already used elsewhere in this
|
||||
project for Postgres-specific constraints):
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX assignments_one_current_per_ticket
|
||||
ON assignments (ticket_id)
|
||||
WHERE is_current = true;
|
||||
```
|
||||
|
||||
Enforces at the database level: a ticket may have at most one `Assignment` row with
|
||||
`isCurrent = true` at any moment, closing the race research.md §1 describes. The existing
|
||||
non-unique `@@index([ticketId, isCurrent])` is unaffected and stays for the repository's own
|
||||
`findCurrent` lookup.
|
||||
|
||||
## `EscalationEvent` (existing model, no column change — one new index)
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX escalation_events_ticket_rule_unique
|
||||
ON escalation_events (ticket_id, rule_id)
|
||||
WHERE rule_id IS NOT NULL;
|
||||
```
|
||||
|
||||
Enforces at the database level: a given rule may fire at most once per ticket over that
|
||||
ticket's lifetime (manual escalations, where `rule_id IS NULL`, are explicitly excluded and
|
||||
remain repeatable). Closes the race research.md §3 describes.
|
||||
|
||||
## Repository contract changes
|
||||
|
||||
### `SlaRunRepository`
|
||||
|
||||
- `update(id, data)` → **replaced** by `updateWithVersion(id, expectedVersion, data): Promise<SLARun | null>`, mirroring `TicketsRepository.updateStatus`'s exact shape: an atomic `updateMany({where:{id, version: expectedVersion}, data:{...data, version:{increment:1}}})`, returning the fresh row on success (count === 1) or `null` on a stale-version mismatch. Every existing call site (`pause`, `resume`, `complete`, `runBreachDetectionSweep`) is updated to pass its own last-read `version` and to retry (re-read + recompute + re-call) up to 3 times on a `null` result before giving up silently (matching the sweep's own existing no-throw, best-effort style — these are internal transitions with no HTTP caller waiting on a 409).
|
||||
|
||||
### `AssignmentRepository`
|
||||
|
||||
- `createAssignment(data)` — same signature and return type; internally catches a Prisma `P2002` on the new `assignments_one_current_per_ticket` index and retries the entire transaction (bounded to 3 attempts) before rethrowing.
|
||||
|
||||
### `EscalationEventRepository`
|
||||
|
||||
- `create(data)` — same signature; internally catches a Prisma `P2002` on the new `escalation_events_ticket_rule_unique` index and returns the pre-existing row for that `(ticketId, ruleId)` pair (a `findFirst({where:{ticketId, ruleId}})` fallback) instead of throwing, so `EscalationService.fire`'s caller sees a normal `EscalationEvent` either way — a duplicate trigger is invisible to the caller, not an error.
|
||||
|
||||
## Test-only entities (not persisted — in-memory test scaffolding)
|
||||
|
||||
- **Load test report** (`tests/load/`): `{ endpoint: string; connections: number; durationSec: number; requestsPerSec: number; latencyP50Ms: number; latencyP90Ms: number; latencyP99Ms: number; non2xxCount: number; rateLimitedCount: number }` — printed to console and written as JSON under `tests/load/reports/<endpoint>-<timestamp>.json` (gitignored) for each run, satisfying FR-008's separation of rate-limited responses from genuine failures.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Implementation Plan: Load and Concurrency Testing
|
||||
|
||||
**Branch**: `016-load-concurrency-testing` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `/specs/016-load-concurrency-testing/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Prove — with real, genuinely-concurrent requests against real Postgres/Redis, never mocked
|
||||
timers — three concurrency guarantees that a prior codebase audit found are NOT currently held
|
||||
(assignment double-assignment, SLA pause/resume/sweep races, escalation duplicate-event risk),
|
||||
fix each real race the tests reveal with a minimal, idiomatic DB-level guard consistent with
|
||||
this codebase's existing patterns, add one new concurrency test proving the existing ticket
|
||||
optimistic-concurrency guarantee holds under genuine concurrency, and add repeatable
|
||||
`autocannon`-based HTTP load-test tooling for the three named critical endpoint groups.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js >=20
|
||||
|
||||
**Primary Dependencies**: Fastify 4.26, Prisma, ioredis/BullMQ, Vitest (existing stack — no new
|
||||
runtime dependency for the concurrency tests); `autocannon` added as a new devDependency for the
|
||||
load-test tooling (pure npm package, no external binary, scriptable in TS, matches this
|
||||
project's existing Node-native toolchain rather than introducing a separate Go binary like k6)
|
||||
|
||||
**Storage**: PostgreSQL via Prisma (existing `Assignment`, `SLARun`, `EscalationEvent` models —
|
||||
one additive schema change per race fix, see data-model.md), Redis (existing, unchanged)
|
||||
|
||||
**Testing**: Vitest, run against the existing throwaway Docker Postgres/Redis
|
||||
(`supporthub-test-pg`/`supporthub-test-redis`) already used by `tests/concurrency/`; load tests
|
||||
run with `autocannon` against a real running instance of the dev server
|
||||
|
||||
**Target Platform**: Linux/Windows server (existing deployment target, unchanged)
|
||||
|
||||
**Project Type**: Backend service (existing modular monolith, unchanged)
|
||||
|
||||
**Performance Goals**: NEEDS CLARIFICATION resolved in research.md — no business-specified
|
||||
throughput/latency targets exist yet; FR-009 requires these be marked `OPEN BUSINESS DECISION`
|
||||
rather than invented, so this feature ships tooling + a baseline report, not a numeric SLA
|
||||
|
||||
**Constraints**: Every fix must be additive/backward-compatible (no breaking change to existing
|
||||
Assignment/SLARun/EscalationEvent consumers — 012-admin-list-views and 015-reporting-dashboards
|
||||
both already query these tables); every concurrency claim must be proven against real
|
||||
Docker-provisioned infrastructure per this project's standing verification discipline, never
|
||||
asserted from code review alone
|
||||
|
||||
**Scale/Scope**: 3 real races to prove-and-fix (assignment, SLA, escalation), 1 race to prove
|
||||
already-safe (ticket status), 3 endpoint groups to load-test (ticket creation, AI support flow,
|
||||
admin reporting) — entirely within `supporthub-api`, no `supporthub-web` changes
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|---|---|---|
|
||||
| I. SaaS Is Sole Identity Authority | N/A — no identity/tenant/product-access logic touched | PASS |
|
||||
| II. Configuration Over Hardcoding | Load-test pass/fail thresholds are NOT hardcoded — explicitly marked `OPEN BUSINESS DECISION` per FR-009, matching roadmap convention | PASS |
|
||||
| III. Layered Architecture / Module Boundaries | All three fixes stay inside their owning module (`orchestration/assignments`, `orchestration/sla`, `orchestration/escalation`) — repository-layer changes only, no new cross-module imports | PASS |
|
||||
| IV. AI Recommends, Policy Decides | N/A — no AI/tool-permission logic touched | PASS |
|
||||
| V. Evidence-Based Verification | This entire feature IS evidence-based verification — every claimed guarantee must be proven by a real concurrency test against real infra before being considered fixed | PASS (this principle is the feature's own thesis) |
|
||||
| VI. Durable Audit & History | No audit-log shape changes; EscalationEvent's idempotency fix preserves the existing audit row for the winning attempt, silently no-ops the loser rather than deleting anything | PASS |
|
||||
| VII. Concurrency-Safe, Durable Job Handling (NON-NEGOTIABLE) | This feature directly implements this principle's own stated requirement ("Assignment and escalation logic MUST be tested under concurrency... job handlers MUST be idempotent") — it is the principle's own overdue test coverage | PASS — this feature exists to close this exact gap |
|
||||
| VIII. Ticket/Problem Separation | N/A — no Ticket/Problem model changes | PASS |
|
||||
|
||||
No violations. No Complexity Tracking entries needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-load-concurrency-testing/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — not yet created)
|
||||
```
|
||||
|
||||
No `contracts/` directory: this feature adds no new HTTP endpoints or request/response
|
||||
contracts — it hardens existing internal behavior and adds test/tooling infrastructure only.
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
prisma/
|
||||
└── schema.prisma # +1 field (SLARun.version), +2 raw partial
|
||||
# unique indexes (migration SQL)
|
||||
|
||||
src/modules/orchestration/assignments/
|
||||
├── repository/assignment.repository.ts # createAssignment: catch+retry on the new
|
||||
# partial-unique-index conflict
|
||||
└── ... # (engine/service unchanged)
|
||||
|
||||
src/modules/orchestration/sla/
|
||||
├── repository/sla-run.repository.ts # update() becomes version-checked; add
|
||||
│ updateWithVersion(id, expectedVersion, data)
|
||||
└── service/sla.service.ts # pause/resume/complete: read-modify-retry
|
||||
loop on version conflict (bounded attempts)
|
||||
|
||||
src/modules/orchestration/escalation/
|
||||
├── repository/escalation-event.repository.ts # create(): catch the new partial-unique
|
||||
│ -index conflict, return existing row
|
||||
└── service/escalation.service.ts # fire(): treat a duplicate-conflict as a
|
||||
no-op, not an error
|
||||
|
||||
tests/concurrency/
|
||||
├── round-robin.test.ts # existing — untouched
|
||||
├── queue.test.ts # existing — untouched
|
||||
├── assignment-race.test.ts # NEW — User Story 1 / FR-001
|
||||
├── sla-race.test.ts # NEW — User Story 2 / FR-002
|
||||
├── escalation-idempotency.test.ts # NEW — User Story 3 / FR-003
|
||||
└── ticket-status-race.test.ts # NEW — User Story 4 / FR-004
|
||||
|
||||
tests/load/
|
||||
├── autocannon.config.ts # NEW — shared runner + report shape
|
||||
├── ticket-creation.load.ts # NEW — User Story 5 / FR-007, FR-008
|
||||
├── ai-support-flow.load.ts # NEW
|
||||
└── admin-reporting.load.ts # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Single backend project (existing `supporthub-api` modular monolith).
|
||||
Fixes live inside their owning module's existing `repository`/`service` files (Principle III);
|
||||
new tests live in the existing `tests/concurrency/` directory (already established by
|
||||
round-robin.test.ts) plus a new `tests/load/` directory for the load-test tooling, mirroring the
|
||||
existing `tests/{unit,integration,e2e,concurrency}` layout with one new sibling rather than
|
||||
overloading `tests/concurrency/` with non-correctness-proving load scripts.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No violations — table omitted.*
|
||||
@@ -0,0 +1,85 @@
|
||||
# Quickstart: Load and Concurrency Testing
|
||||
|
||||
Manual + automated verification steps for each user story, against real Docker-provisioned
|
||||
Postgres/Redis — this project's standing rule that a concurrency claim is never accepted from
|
||||
code review alone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Throwaway test infra up: `supporthub-test-pg` (host port 5433), `supporthub-test-redis` (host
|
||||
port 6380) — the same containers `tests/concurrency/round-robin.test.ts` already uses.
|
||||
- For the load tests (User Story 5) only: a real running instance of the API against the real
|
||||
dev infra (`postgres-development`/`redis-development`), reachable at
|
||||
`http://localhost:4501`, plus an ADMIN session token for the reporting endpoints.
|
||||
|
||||
## Scenario 1 — Assignment double-assignment race (User Story 1)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/assignment-race.test.ts`
|
||||
2. The test creates one ticket, then fires >=20 concurrent `assignmentEngine.assignToSpecificNode`
|
||||
(or the equivalent orchestration entry point) calls at it against real Postgres.
|
||||
3. **Expected**: the test itself queries `assignments` directly afterward and asserts exactly
|
||||
one row has `is_current = true` for that ticket — not just that one HTTP/service call
|
||||
"won." Repeat the run at least 10 times (or use the test's own internal repeat loop) to
|
||||
confirm SC-001's "zero exceptions across 10 repeated runs."
|
||||
4. Before the fix (research.md §1), this test is expected to fail intermittently; after the
|
||||
fix, it must pass every time.
|
||||
|
||||
## Scenario 2 — SLA pause/resume/sweep race (User Story 2)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/sla-race.test.ts`
|
||||
2. The test creates a ticket with an active SLA run, then fires concurrent `pause`/`resume`
|
||||
calls and a `runBreachDetectionSweep()` pass against the same run.
|
||||
3. **Expected**: the run's final DB state (`status`, `pausedAt`, `resumedAt`, `breachedAt`,
|
||||
`firstResponseDueAt`, `resolutionDueAt`) is queried directly and asserted internally
|
||||
consistent — e.g. never `status: 'paused'` with `pausedAt: null`, never a `breached` run
|
||||
silently reverted to `running` by a racing `resume`. Repeat per SC-002.
|
||||
|
||||
## Scenario 3 — Escalation idempotency (User Story 3)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/escalation-idempotency.test.ts`
|
||||
2. The test creates a ticket eligible for a specific escalation rule, then calls
|
||||
`escalationService.handleBreach` (or `fire` via its real trigger path) twice concurrently for
|
||||
the identical trigger.
|
||||
3. **Expected**: exactly one `EscalationEvent` row exists afterward for that `(ticketId,
|
||||
ruleId)` pair, and exactly one `Assignment` row resulted from it (cross-checking Scenario 1's
|
||||
own guarantee). Repeat per SC-003.
|
||||
|
||||
## Scenario 4 — Ticket status optimistic concurrency proof (User Story 4)
|
||||
|
||||
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/ticket-status-race.test.ts`
|
||||
2. The test creates a ticket at a known status/version, then fires >=20 concurrent
|
||||
`ticketsRepository.updateStatus` calls all starting from that same version.
|
||||
3. **Expected**: exactly one call returns the updated ticket; every other call returns `null`
|
||||
(stale-version signal); the ticket's final DB status matches the one call that succeeded.
|
||||
This is expected to pass on the very first run (spec.md Assumptions) — a failure here would
|
||||
mean the existing mechanism has a real gap, not that this quickstart step is wrong.
|
||||
|
||||
## Scenario 5 — Load/throughput baseline (User Story 5)
|
||||
|
||||
1. Ensure the real dev API is running (`npm run dev` against `.env.development`) and reachable.
|
||||
2. `npx tsx tests/load/ticket-creation.load.ts`
|
||||
3. `npx tsx tests/load/ai-support-flow.load.ts`
|
||||
4. `npx tsx tests/load/admin-reporting.load.ts` (needs an ADMIN token — the script signs in
|
||||
itself using the same seeded admin credentials this project's E2E suite already uses)
|
||||
5. **Expected**: each script prints a report (requests/sec, `p50`/`p90`/`p99` latency, non-2xx
|
||||
count, rate-limited count) and writes it to `tests/load/reports/`. There is no pass/fail
|
||||
assertion on the numbers themselves (FR-009, `OPEN BUSINESS DECISION`) — the check here is
|
||||
that the tooling runs cleanly end-to-end and produces a comparable, re-runnable report, not
|
||||
that any specific number is hit.
|
||||
6. Run the same script twice in a row and confirm the two reports are comparable in shape
|
||||
(same fields, plausible numbers) — proving SC-005's "consistent-shape output for comparison
|
||||
across runs."
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
- All four new `tests/concurrency/*.test.ts` files pass consistently (not flakily) against real
|
||||
Postgres/Redis, each proving its own user story's guarantee with a direct database assertion,
|
||||
not just an HTTP response check.
|
||||
- Every race the audit found (assignment, SLA, escalation) is fixed in the actual repository
|
||||
code per data-model.md, not merely detected and left alone.
|
||||
- All three `tests/load/*.load.ts` scripts run cleanly against a real running dev server and
|
||||
produce a report.
|
||||
- Full existing quality gate (typecheck, lint, architecture check, full unit + integration
|
||||
suite) stays green — these fixes touch shared repositories (`Assignment`, `SLARun`,
|
||||
`EscalationEvent`) already exercised by 007-orchestration-assignment's, 008-sla-escalation's,
|
||||
012-admin-list-views's, and 015-reporting-dashboards's own existing tests.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Research: Load and Concurrency Testing
|
||||
|
||||
## 1. Assignment double-assignment race
|
||||
|
||||
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
|
||||
assignments_one_current_per_ticket ON assignments (ticket_id) WHERE is_current = true;` — and
|
||||
change `AssignmentRepository.createAssignment` to catch the resulting unique-violation (Prisma
|
||||
`P2002`) and retry the whole supersede-then-create transaction (bounded to 3 attempts, matching
|
||||
this codebase's existing small-bounded-retry convention), rather than surfacing a raw 500.
|
||||
|
||||
**Rationale**: `createAssignment`'s existing transaction (`updateMany({isCurrent:false}) +
|
||||
create({isCurrent:true})`) is correct in isolation but Postgres's default `READ COMMITTED`
|
||||
isolation lets two concurrent transactions each see "no current row to supersede" and both
|
||||
successfully `create` their own `isCurrent:true` row — there is no read-modify-write cycle a
|
||||
version field could guard here (unlike Ticket/SLARun below), because the operation is a
|
||||
create, not an update, and a create can't be conditioned on "no matching row exists" atomically
|
||||
without a DB-level constraint. A partial unique index is the standard, minimal Postgres pattern
|
||||
for "at most one row matching a predicate" and requires no application-level locking. Retrying
|
||||
on conflict (rather than failing the second caller outright) preserves current behavior for the
|
||||
common, non-racing case and correctly resolves the race by making the loser's request apply
|
||||
*after* the winner's, superseding it — exactly the same "last write wins, but exactly once"
|
||||
semantics `createAssignment`'s own docstring already promises for the non-concurrent case.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *Explicit `SERIALIZABLE` transaction isolation*: would also detect the race (as a
|
||||
serialization failure) but requires the exact same catch-and-retry handling as the unique
|
||||
index approach, adds latency to every assignment (not just racing ones), and does nothing to
|
||||
prevent the row from ever being duplicated if a future code path creates an Assignment outside
|
||||
this transaction — a DB constraint is a stronger, more future-proof guarantee.
|
||||
- *Row-level lock (`SELECT ... FOR UPDATE`) on a per-ticket lock row*: works, but requires
|
||||
inventing a new lock-row concept for a case Postgres's own partial unique index already solves
|
||||
natively.
|
||||
|
||||
## 2. SLA pause/resume/sweep race
|
||||
|
||||
**Decision**: Add `version Int @default(0)` to `SLARun`. Replace `SlaRunRepository.update(id,
|
||||
data)` with `updateWithVersion(id, expectedVersion, data)`, mirroring
|
||||
`TicketsRepository.updateStatus`'s existing atomic `updateMany({where:{id, version:
|
||||
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly. `SlaService.pause`,
|
||||
`resume`, `complete`, and `runBreachDetectionSweep` each move to a small
|
||||
read-compute-write-retry loop (bounded to 3 attempts): re-read the run fresh on a version
|
||||
conflict, recompute the operation's own delta (e.g. resume's `pausedMs` shift) against the fresh
|
||||
state, and retry the versioned write.
|
||||
|
||||
**Rationale**: Every one of `pause`/`resume`/`complete`/the sweep does an unconditional
|
||||
read-then-`update(run.id, {...})` with no guard — two of these racing (e.g. `resume` and the
|
||||
sweep evaluating the same run at once) can silently clobber each other: the sweep's own
|
||||
`update(run.id, {status:'breached', breachedAt: now})` could be overwritten moments later by a
|
||||
`resume` that read the run *before* the sweep's write and still thinks it's `paused`, un-breaching
|
||||
a run that was legitimately breached and permanently losing that breach from SLA-compliance
|
||||
figures — a real, silent correctness bug, not a hypothetical one. `Ticket` already has exactly
|
||||
this problem solved for its own status field with a `version` counter and an atomic
|
||||
conditional-update; reusing that identical mechanism (rather than inventing a new one) keeps the
|
||||
codebase's concurrency idiom singular and matches Principle III's spirit even though it isn't
|
||||
a cross-module boundary concern.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *Wrap each operation in a Postgres advisory lock keyed by run ID*: works but adds a new
|
||||
locking primitive to the codebase for a problem the existing version-counter idiom already
|
||||
solves; rejected for consistency, not because it wouldn't work.
|
||||
- *A single DB transaction spanning the sweep's read+write for all runs at once*: would only
|
||||
protect the sweep against itself, not against `pause`/`resume` racing it from an unrelated
|
||||
request path — doesn't close the actual gap.
|
||||
|
||||
## 3. Escalation idempotency
|
||||
|
||||
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
|
||||
escalation_events_ticket_rule_unique ON escalation_events (ticket_id, rule_id) WHERE rule_id IS
|
||||
NOT NULL;` — and change `EscalationEventRepository.create` (called from
|
||||
`EscalationService.fire`) to catch the resulting `P2002` and return the already-existing event
|
||||
for that `(ticketId, ruleId)` pair instead of creating a duplicate or throwing.
|
||||
|
||||
**Rationale**: `SLARun.ticketId` is `@unique` and "no reopen-cycle support" (existing schema
|
||||
comment) means a given rule can only ever legitimately fire once per ticket's lifetime for a
|
||||
rule-triggered breach (`handleBreach`'s `ruleId` is always a real rule ID scoped to one specific
|
||||
`triggerType`; `resolution_breach` and `first_response_breach` runs are naturally different
|
||||
rules, so this constraint doesn't conflate the two). Manual escalation
|
||||
(`escalateManually`/`fire(ticketId, ruleId: null, ...)`) is deliberately excluded from the
|
||||
constraint (`WHERE rule_id IS NOT NULL`) because an admin legitimately re-escalating the same
|
||||
ticket manually more than once must keep working exactly as it does today. This directly closes
|
||||
the gap the audit found: `runBreachDetectionSweep`'s `findRunningPastResolutionDueAt` can return
|
||||
the same still-`running` row to two overlapping sweep passes (e.g. a slow sweep still finishing
|
||||
when the next scheduled tick fires, or a duplicate BullMQ job delivery calling `handleBreach`
|
||||
directly) before either pass's own `update(run.id, {status:'breached', ...})` commits — without
|
||||
this constraint, both passes independently call `fire` and each successfully creates its own
|
||||
`EscalationEvent` plus its own `assignToSpecificNode`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *A dedicated idempotency-key column populated by the caller (e.g. a sweep-run ID)*: more
|
||||
general, but overkill here — the natural, already-unique business key for a rule-triggered
|
||||
escalation genuinely is `(ticketId, ruleId)` given the "no reopen-cycle" constraint already in
|
||||
place; inventing a separate key would duplicate information the schema already expresses.
|
||||
- *Making the sweep single-flight via a Redis lock around the whole sweep function*: would
|
||||
prevent two sweep passes from overlapping, but does not protect against a duplicate BullMQ job
|
||||
calling `handleBreach` directly for the same trigger outside the sweep's own loop — the DB
|
||||
constraint protects the actual invariant regardless of caller, which is the correct place per
|
||||
Principle VII ("job handlers MUST be idempotent").
|
||||
|
||||
## 4. Ticket optimistic-concurrency proof
|
||||
|
||||
**Decision**: No implementation change. Add `tests/concurrency/ticket-status-race.test.ts`
|
||||
firing a batch of genuinely concurrent `TicketsRepository.updateStatus` calls at the same
|
||||
ticket, all from the same starting version, against the real throwaway Postgres, and asserting
|
||||
exactly one succeeds (returns the updated ticket) while every other call returns `null` (the
|
||||
existing stale-version-mismatch signal) — proving FR-004/SC-004 against the mechanism that
|
||||
already exists (see `tests/concurrency/round-robin.test.ts:12`'s own reference to "003-ticketing's
|
||||
optimistic ticket-status concurrency" as prior art that was never itself concurrency-tested).
|
||||
|
||||
**Rationale**: The existing `updateMany({where:{id, version: expectedVersion}, ...})` is a
|
||||
single atomic SQL statement — Postgres itself guarantees only one concurrent `UPDATE` matching
|
||||
that `WHERE` clause can succeed before the row's `version` changes underneath the others. This
|
||||
is sound by construction; the gap is purely "never proven under real concurrency," which this
|
||||
research assumes will simply confirm the existing guarantee (per spec.md's own Assumptions) —
|
||||
but the test is still written to fail loudly if that assumption turns out to be wrong.
|
||||
|
||||
## 5. Load-test tooling choice
|
||||
|
||||
**Decision**: `autocannon` (npm devDependency), invoked via small TypeScript runner scripts
|
||||
under `tests/load/`, one per named endpoint group (ticket creation, AI support flow, admin
|
||||
reporting), each producing a JSON report (`autocannon`'s own `Result` shape: requests/sec,
|
||||
latency `p50`/`p90`/`p99`, non-2xx count) written to `tests/load/reports/` (gitignored — these
|
||||
are run artifacts, not fixtures) plus a printed console summary.
|
||||
|
||||
**Rationale**: `autocannon` is a pure Node.js package (no separate binary to install, unlike
|
||||
k6), is TypeScript-friendly, and its programmatic API (`autocannon({url, connections, duration,
|
||||
requests: [...]}, callback)`) fits scripting multi-step flows (e.g. sign-in once, then hammer an
|
||||
authenticated endpoint) far more naturally than k6's separate-runtime JS dialect — keeping this
|
||||
feature's new tooling inside the same Node/TS toolchain as the rest of the project (Technical
|
||||
Context), consistent with this project's existing minimal-new-tooling bias.
|
||||
|
||||
**Alternatives considered**:
|
||||
- *k6*: the industry-standard load-testing tool with richer scripting and threshold
|
||||
assertions, but ships as a separate Go binary requiring its own install/Docker image outside
|
||||
npm — heavier footprint for a project whose stack is otherwise 100% npm-managed.
|
||||
Reconsider if this project later needs distributed/cloud load generation, which `autocannon`
|
||||
does not support and k6 does.
|
||||
- *artillery*: also npm-native and closer to k6 in scripting richness, but pulls in a much
|
||||
larger dependency tree for YAML-driven scenario files this feature doesn't need — `autocannon`
|
||||
is a lighter fit for three hand-written TS scripts.
|
||||
|
||||
## 6. Load-test pass/fail thresholds
|
||||
|
||||
**Decision**: Per FR-009, no numeric throughput/latency/error-rate threshold is hardcoded as
|
||||
pass/fail. Each load-test report prints its own measured numbers and the tooling exits `0`
|
||||
regardless of the numbers observed (this is a measurement tool, not a gate) — a comment in each
|
||||
script marks the threshold question as `OPEN BUSINESS DECISION` and links back to spec.md
|
||||
Assumptions, so a future feature can wire an explicit pass/fail gate into CI once the business
|
||||
sets a real target.
|
||||
|
||||
**Rationale**: Inventing an arbitrary "must handle 500 req/s at p99 < 200ms" number would
|
||||
violate the roadmap's own explicit rule ("Never hardcode a placeholder value for any of the
|
||||
[open business decisions] and ship it as if it were final") — throughput/latency targets are
|
||||
exactly this kind of business-owned number, not an engineering default.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Feature Specification: Load and Concurrency Testing
|
||||
|
||||
**Feature Branch**: `016-load-concurrency-testing`
|
||||
|
||||
**Created**: 2026-09-09
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Load and concurrency testing (Phase 11): exercise the concurrency-safety guarantees docs/09-testing-observability-cicd.md's own testing strategy already calls for — assignment race conditions, SLA pause/resume durability, escalation idempotency, ticket optimistic concurrency — under genuinely concurrent requests against real infrastructure, fixing any real race a test reveals; and add real HTTP load/throughput testing against the API's own critical endpoints."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - A ticket is never assigned to two agents at once under concurrent escalation (Priority: P1)
|
||||
|
||||
An operator needs confidence that when a ticket is escalated to a human (or reassigned) from
|
||||
more than one trigger at nearly the same moment — for example, a manual reassignment landing at
|
||||
the same instant as an automatic escalation-rule firing — the ticket ends up with exactly one
|
||||
current assignment, never two agents both believing they own the same case.
|
||||
|
||||
**Why this priority**: A double-assignment is a customer- and agent-facing correctness failure
|
||||
(two agents work the same ticket, or the SLA/workload dashboards silently double-count it) and
|
||||
undermines every dashboard and workload figure already shipped in this system. This is the most
|
||||
severe class of bug this feature can find.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing many genuinely concurrent assignment
|
||||
requests at the same ticket against a real running instance of the API and a real Postgres
|
||||
database, then confirming exactly one `Assignment` row is marked current for that ticket
|
||||
afterward — no reliance on timing assumptions or sequential calls.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket eligible for assignment, **When** many concurrent assignment attempts are
|
||||
made against it at once, **Then** exactly one assignment ends up marked as the ticket's
|
||||
current assignment, and the database itself (not just the last response received) confirms
|
||||
this.
|
||||
2. **Given** the race in Scenario 1 is exercised repeatedly, **When** the test is run multiple
|
||||
times, **Then** the result is consistent every time — the protection does not depend on
|
||||
lucky timing.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An SLA clock is never corrupted by overlapping pause/resume activity (Priority: P1)
|
||||
|
||||
An operator needs confidence that when a ticket's SLA clock is paused and resumed by more than
|
||||
one concurrent trigger — for example, a customer-reply webhook resuming the clock at the same
|
||||
moment the scheduled breach-detection sweep is evaluating that same ticket — the SLA run ends up
|
||||
in one coherent, correct state, never a state where the clock is simultaneously "paused" and
|
||||
"counting toward breach," and never a state that silently drops a pause/resume event.
|
||||
|
||||
**Why this priority**: SLA correctness is a contractual promise to customers and already backs
|
||||
the Management and Support dashboards shipped in 015-reporting-dashboards; a corrupted SLA clock
|
||||
produces wrong compliance figures and wrong breach alerts without any visible error.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing concurrent pause and resume operations at
|
||||
the same SLA run against a real running instance of the API and a real Postgres database, then
|
||||
confirming the run's final stored state (paused/active, due-at timestamps) is internally
|
||||
consistent and matches one coherent ordering of the operations — not a mix of both.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active SLA run, **When** a pause and a resume are triggered concurrently,
|
||||
**Then** the run's final state is exactly one of "paused" or "active" — never a state with
|
||||
contradictory fields (e.g., marked paused with no pause timestamp recorded, or marked active
|
||||
with a stale due-at that never accounted for the pause).
|
||||
2. **Given** the breach-detection sweep is evaluating a run at the same moment a resume is
|
||||
requested for it, **When** both complete, **Then** the run is not double-processed (no
|
||||
duplicate breach event, no lost resume).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An escalation rule firing twice never creates two escalation events (Priority: P1)
|
||||
|
||||
An operator needs confidence that if the same escalation trigger is delivered more than once —
|
||||
for example, a retried background job or a re-processed event — the ticket is escalated exactly
|
||||
once, not reassigned and re-notified redundantly.
|
||||
|
||||
**Why this priority**: Duplicate escalations would double-notify agents, double-count in the
|
||||
Support and Management dashboards, and could re-trigger reassignment away from an agent who has
|
||||
already started work — a direct regression of work already done in this session.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing the same escalation trigger concurrently
|
||||
more than once for the same ticket against a real running instance of the API and a real
|
||||
Postgres database, then confirming only one `EscalationEvent` row exists for that trigger
|
||||
afterward.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket eligible for escalation, **When** the same escalation trigger is delivered
|
||||
twice at nearly the same moment, **Then** exactly one escalation event is recorded for it.
|
||||
2. **Given** Scenario 1's duplicate delivery, **When** the escalation event is created,
|
||||
**Then** the ticket is reassigned exactly once, not twice.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - A ticket's status can never be corrupted by two simultaneous updates (Priority: P2)
|
||||
|
||||
An operator needs confidence that the ticket status-transition safeguard already built for this
|
||||
system actually holds under real concurrent load, not just in isolated sequential tests — this
|
||||
is existing protection, but has never been proven under genuine concurrency.
|
||||
|
||||
**Why this priority**: Lower priority than User Stories 1-3 because a real defensive mechanism
|
||||
already exists here (see Assumptions); this story exists to convert an untested assumption into
|
||||
a proven guarantee, and is valuable but lower-risk than the three unguarded races above.
|
||||
|
||||
**Independent Test**: Can be fully tested by firing multiple concurrent status-update attempts
|
||||
at the same ticket, each based on the same starting version, against a real running API and
|
||||
database, then confirming exactly one update succeeds and every other attempt receives a clear
|
||||
conflict response rather than silently corrupting or skipping the ticket's state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ticket at a known status and version, **When** multiple concurrent status-update
|
||||
requests are made from that same version, **Then** exactly one succeeds and the rest are
|
||||
rejected with a conflict response, and the ticket's final status matches the one update that
|
||||
succeeded.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - The API's critical endpoints hold up under realistic concurrent traffic (Priority: P2)
|
||||
|
||||
An operator needs a documented, repeatable measurement of how the system's most important
|
||||
endpoints — new support requests coming in, the AI support flow, and the admin reporting
|
||||
dashboards — behave under sustained concurrent load, so that a future capacity or performance
|
||||
regression can be caught by comparing against this baseline rather than guessed at.
|
||||
|
||||
**Why this priority**: This is about establishing a measurable baseline and repeatable tooling
|
||||
rather than proving or fixing a specific correctness bug (unlike User Stories 1-4), so it is
|
||||
valuable but not blocking for the correctness guarantees above.
|
||||
|
||||
**Independent Test**: Can be fully tested by running a load-test tool against a real running
|
||||
instance of the API for each of the three named endpoint groups and producing a report of
|
||||
throughput, latency percentiles, and error rate, independent of whether any other user story in
|
||||
this feature has been completed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is running against real infrastructure, **When** a defined concurrent load
|
||||
is sent to the ticket-creation endpoint for a sustained period, **Then** a report is produced
|
||||
showing throughput, latency percentiles, and error rate for that run.
|
||||
2. **Given** the same setup, **When** the same load profile is sent to the AI support flow and
|
||||
to the admin reporting endpoints, **Then** an equivalent report is produced for each,
|
||||
allowing the three to be compared against each other and against future runs.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a concurrent assignment race includes a ticket that is simultaneously being
|
||||
closed or reopened? The assignment/reassignment safeguard must not be bypassable by a
|
||||
status change racing the same window.
|
||||
- What happens when a pause and a breach both become due at the exact same instant? The final
|
||||
state must reflect one coherent, auditable outcome, not an unresolvable both-happened state.
|
||||
- What happens when the load test itself pushes an endpoint into its own rate limiter (e.g. the
|
||||
013-auth-hardening login rate limit, or the SaaS integration per-minute rate limits)? The
|
||||
report must distinguish "rejected by design (rate limit)" from "failed under load" rather than
|
||||
counting both as the same kind of failure.
|
||||
- What happens when two of these races are exercised back-to-back against the same throwaway
|
||||
database without cleanup? Each test must use its own uniquely-identified fixtures so repeated
|
||||
runs (and CI re-runs) don't produce false positives or false negatives from leftover state.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST guarantee that a ticket never has more than one assignment marked
|
||||
as current, even when multiple assignment operations are attempted concurrently against it.
|
||||
- **FR-002**: The system MUST guarantee that an SLA run's paused/active state and its associated
|
||||
timestamps remain internally consistent when pause, resume, and the breach-detection sweep are
|
||||
triggered concurrently against the same run.
|
||||
- **FR-003**: The system MUST guarantee that the same escalation trigger delivered more than
|
||||
once for the same ticket produces exactly one escalation event and exactly one resulting
|
||||
reassignment.
|
||||
- **FR-004**: The system MUST reject a ticket status update whose expected starting version no
|
||||
longer matches the ticket's actual current version, even when the conflicting updates are
|
||||
concurrent, and MUST leave the ticket in the state produced by whichever single update
|
||||
actually succeeded.
|
||||
- **FR-005**: The system's automated test suite MUST include a dedicated concurrency test for
|
||||
each of FR-001 through FR-004, each exercising genuinely concurrent requests against real,
|
||||
live infrastructure (not mocked timers or sequential calls standing in for concurrency).
|
||||
- **FR-006**: Where a concurrency test written for this feature reveals that a guarantee in
|
||||
FR-001, FR-002, or FR-003 does not currently hold, the underlying race MUST be fixed as part
|
||||
of this feature, not merely documented.
|
||||
- **FR-007**: The system MUST provide repeatable load-test tooling covering, at minimum: new
|
||||
support request submission, the AI support flow, and the admin reporting dashboard endpoints.
|
||||
- **FR-008**: Each load test run MUST produce a report including throughput, latency
|
||||
percentiles, and error rate, with rate-limited responses reported separately from failures.
|
||||
- **FR-009**: Pass/fail thresholds for the load tests (target throughput, acceptable latency,
|
||||
acceptable error rate) MUST be explicitly marked as `OPEN BUSINESS DECISION` wherever the
|
||||
business has not already specified a number, per this project's own roadmap convention —
|
||||
never hardcoded as if final.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Assignment race scenario**: A reusable test setup representing "many concurrent attempts to
|
||||
assign or reassign the same ticket," used to exercise FR-001.
|
||||
- **SLA race scenario**: A reusable test setup representing "concurrent pause, resume, and sweep
|
||||
activity against the same SLA run," used to exercise FR-002.
|
||||
- **Escalation race scenario**: A reusable test setup representing "the same escalation trigger
|
||||
delivered more than once for the same ticket," used to exercise FR-003.
|
||||
- **Load test report**: The recorded output of a load-test run against one endpoint group —
|
||||
throughput, latency percentiles, error rate, and rate-limited-response count — kept so a
|
||||
future run can be compared against it.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A test run that fires at least 20 genuinely concurrent assignment attempts at the
|
||||
same ticket always results in exactly one current assignment, with zero exceptions across at
|
||||
least 10 repeated runs.
|
||||
- **SC-002**: A test run that fires concurrent pause/resume/sweep activity against the same SLA
|
||||
run always leaves that run in one internally-consistent, auditable state, with zero
|
||||
contradictory-state outcomes across at least 10 repeated runs.
|
||||
- **SC-003**: A test run that delivers the same escalation trigger twice for the same ticket
|
||||
always results in exactly one escalation event and exactly one reassignment, with zero
|
||||
duplicate outcomes across at least 10 repeated runs.
|
||||
- **SC-004**: A test run that fires at least 20 genuinely concurrent status-update attempts from
|
||||
the same starting version against the same ticket always results in exactly one success and
|
||||
the ticket left in that one succeeding state.
|
||||
- **SC-005**: A load-test report exists for each of the three named endpoint groups (ticket
|
||||
creation, AI support flow, admin reporting), each independently re-runnable on demand and
|
||||
producing consistent-shape output for comparison across runs.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Ticket status optimistic concurrency (User Story 4) already has a real defensive mechanism in
|
||||
the codebase (a version-checked atomic update) — this feature's job for that story is to prove
|
||||
it under genuine concurrency with a new test, not to build new protection, unless that test
|
||||
surprises this assumption and reveals a real gap.
|
||||
- Assignment double-assignment, SLA pause/resume races, and escalation duplicate-event risk (User
|
||||
Stories 1-3) are NOT currently guarded against — this feature's job for those stories is both
|
||||
to prove the gap with a real concurrency test and to implement the fix, per FR-006.
|
||||
- "Genuinely concurrent" means real parallel requests issued against a real running instance of
|
||||
the API backed by real Postgres/Redis (this project's standing verification discipline
|
||||
throughout every prior feature), not fake-timer or mocked-clock simulations.
|
||||
- Load testing (User Story 5) targets the existing dev/throwaway infrastructure already used for
|
||||
this project's own manual verification, not a separate staging or production environment —
|
||||
provisioning a dedicated load-test environment is out of scope.
|
||||
- Specific throughput/latency/error-rate thresholds for "pass" are an `OPEN BUSINESS DECISION`
|
||||
per FR-009; this feature delivers the tooling and a baseline report, not a final SLA number.
|
||||
- Round-robin assignment-selection counter safety is already covered by an existing genuine
|
||||
concurrency test and is explicitly out of scope for this feature.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
description: "Task list for 016-load-concurrency-testing"
|
||||
---
|
||||
|
||||
# Tasks: Load and Concurrency Testing
|
||||
|
||||
**Input**: Design documents from `specs/016-load-concurrency-testing/`
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = assignment race, US2 = SLA race, US3 =
|
||||
escalation idempotency, US4 = ticket-status race proof, US5 = load-test tooling). US1-US4 share
|
||||
one Foundational phase (the schema migration all four rely on); US5 has no schema dependency and
|
||||
can proceed independently of it.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 [P] Add `autocannon` as a devDependency (`package.json`) and add
|
||||
`tests/load/reports/` to `.gitignore` (run artifacts, not fixtures)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites for US1-US4)
|
||||
|
||||
**Purpose**: The one shared schema migration US1, US2, and US3's fixes each depend on. US4 (no
|
||||
schema change, see research.md §4) and US5 (no schema dependency) do not need this phase and can
|
||||
proceed in parallel with it.
|
||||
|
||||
- [x] T002 In `prisma/schema.prisma`, add `version Int @default(0)` to `SLARun`; generate one
|
||||
migration (`npx prisma migrate dev --name concurrency_guards`) that also includes, as raw
|
||||
SQL, `CREATE UNIQUE INDEX assignments_one_current_per_ticket ON assignments (ticket_id)
|
||||
WHERE is_current = true;` and `CREATE UNIQUE INDEX escalation_events_ticket_rule_unique ON
|
||||
escalation_events (ticket_id, rule_id) WHERE rule_id IS NOT NULL;` (data-model.md); apply
|
||||
to the throwaway test Postgres (`supporthub-test-pg`, port 5433) and the real dev Postgres
|
||||
(`postgres-development`, port 5434, via `prisma migrate diff` + direct `psql` per this
|
||||
project's own established non-destructive dev-sync approach); regenerate the Prisma client
|
||||
|
||||
**Checkpoint**: Schema ready — US1, US2, US3 implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Assignment never double-assigned under concurrency (Priority: P1)
|
||||
|
||||
**Goal**: Two concurrent assignment attempts on the same ticket always leave exactly one current
|
||||
assignment.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/assignment-race.test.ts` alone against the
|
||||
throwaway Postgres — it creates its own ticket and needs nothing from US2-US5.
|
||||
|
||||
- [x] T003 [US1] Write `tests/concurrency/assignment-race.test.ts`: create one ticket, fire
|
||||
>=20 genuinely concurrent assignment attempts at it (via the real assignment
|
||||
engine/service entry point, not the repository directly), then query `assignments`
|
||||
directly and assert exactly one row has `is_current = true` for that ticket (depends on
|
||||
T002)
|
||||
- [x] T004 [US1] Fix `AssignmentRepository.createAssignment` in
|
||||
`src/modules/orchestration/assignments/repository/assignment.repository.ts` to catch the
|
||||
`assignments_one_current_per_ticket` unique-violation (Prisma `P2002`) and retry the whole
|
||||
supersede-then-create transaction, bounded to 3 attempts, per research.md §1 (depends on
|
||||
T002)
|
||||
- [x] T005 [US1] Re-run `assignment-race.test.ts` at least 10 times in a row (or extend the test
|
||||
with its own internal repeat loop) confirming zero failures — SC-001 (depends on T003, T004)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 1 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - SLA clock never corrupted by overlapping pause/resume/sweep (Priority: P1)
|
||||
|
||||
**Goal**: Concurrent pause/resume/breach-sweep activity against the same SLA run always leaves
|
||||
it in one internally-consistent state.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/sla-race.test.ts` alone against the throwaway
|
||||
Postgres — it creates its own ticket + SLA run and needs nothing from US1/US3/US4/US5.
|
||||
|
||||
- [x] T006 [US2] Replace `SlaRunRepository.update` with `updateWithVersion(id, expectedVersion,
|
||||
data)` in `src/modules/orchestration/sla/repository/sla-run.repository.ts`, mirroring
|
||||
`TicketsRepository.updateStatus`'s atomic `updateMany({where:{id, version:
|
||||
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly (depends on T002)
|
||||
- [x] T007 [US2] Update `pause`, `resume`, `complete`, and `runBreachDetectionSweep` in
|
||||
`src/modules/orchestration/sla/service/sla.service.ts` to call `updateWithVersion` with
|
||||
each run's last-read version, and to re-read + recompute + retry (bounded to 3 attempts)
|
||||
on a version-conflict `null` result, per research.md §2 (depends on T006)
|
||||
- [x] T008 [US2] Write `tests/concurrency/sla-race.test.ts`: create a ticket with an active SLA
|
||||
run, fire concurrent `pause`/`resume` calls and a `runBreachDetectionSweep()` pass against
|
||||
it, then query the run directly and assert its final state is internally consistent (never
|
||||
`paused` with `pausedAt: null`, never a legitimately `breached` run silently reverted to
|
||||
`running`) (depends on T007)
|
||||
- [x] T009 [US2] Re-run `sla-race.test.ts` at least 10 times confirming zero
|
||||
contradictory-state outcomes — SC-002 (depends on T008)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 2 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - An escalation trigger fired twice never duplicates (Priority: P1)
|
||||
|
||||
**Goal**: The same escalation trigger delivered twice for the same ticket always results in
|
||||
exactly one escalation event and one reassignment.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/escalation-idempotency.test.ts` alone against the
|
||||
throwaway Postgres — it creates its own ticket + escalation rule and needs nothing from
|
||||
US1/US2/US4/US5 (though it exercises the same `Assignment` table US1 protects, as a
|
||||
cross-check).
|
||||
|
||||
- [x] T010 [US3] Fix `EscalationEventRepository.create` in
|
||||
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` to catch
|
||||
the `escalation_events_ticket_rule_unique` unique-violation (Prisma `P2002`) and return
|
||||
the pre-existing row for that `(ticketId, ruleId)` pair via a `findFirst` fallback instead
|
||||
of throwing, per research.md §3 (depends on T002)
|
||||
- [x] T011 [US3] Confirm `EscalationService.fire` in
|
||||
`src/modules/orchestration/escalation/service/escalation.service.ts` behaves correctly
|
||||
when `create` returns a pre-existing event (it must not also re-run
|
||||
`assignToSpecificNode` for a duplicate trigger) — adjust `fire` if needed so a
|
||||
duplicate-conflict short-circuits before reassignment (depends on T010)
|
||||
- [x] T012 [US3] Write `tests/concurrency/escalation-idempotency.test.ts`: create a ticket
|
||||
eligible for a specific escalation rule, call the real trigger path (e.g.
|
||||
`escalationService.handleBreach`) twice concurrently for the identical trigger, then query
|
||||
`escalation_events` and `assignments` directly and assert exactly one of each resulted
|
||||
(depends on T011)
|
||||
- [x] T013 [US3] Re-run `escalation-idempotency.test.ts` at least 10 times confirming zero
|
||||
duplicate outcomes — SC-003 (depends on T012)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 3 passes against real infrastructure, consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Ticket status optimistic concurrency, proven (Priority: P2)
|
||||
|
||||
**Goal**: Prove the existing version-checked ticket-status update holds under genuine
|
||||
concurrency.
|
||||
|
||||
**Independent Test**: Run `tests/concurrency/ticket-status-race.test.ts` alone against the
|
||||
throwaway Postgres — no dependency on T002 or any other user story (research.md §4: no
|
||||
implementation change expected).
|
||||
|
||||
- [x] T014 [US4] Write `tests/concurrency/ticket-status-race.test.ts`: create a ticket at a
|
||||
known status/version, fire >=20 genuinely concurrent `ticketsRepository.updateStatus`
|
||||
calls all starting from that same version, and assert exactly one returns the updated
|
||||
ticket while every other call returns `null` — SC-004
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 4 passes, confirming the existing mechanism (no fix
|
||||
expected; a failure here would mean research.md's assumption was wrong and needs revisiting).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 - Repeatable load/throughput baseline (Priority: P2)
|
||||
|
||||
**Goal**: Repeatable `autocannon`-based load-test tooling and a baseline report for the three
|
||||
named critical endpoint groups.
|
||||
|
||||
**Independent Test**: Run each `tests/load/*.load.ts` script alone against a real running dev
|
||||
server — no dependency on T002 or any other user story.
|
||||
|
||||
- [x] T015 [P] [US5] Create `tests/load/autocannon.config.ts`: a shared runner helper wrapping
|
||||
`autocannon`'s programmatic API, producing the report shape from data-model.md
|
||||
(`requestsPerSec`, `latencyP50Ms`/`P90Ms`/`P99Ms`, `non2xxCount`, `rateLimitedCount`),
|
||||
printing a console summary and writing JSON to `tests/load/reports/` (depends on T001)
|
||||
- [x] T016 [P] [US5] Create `tests/load/ticket-creation.load.ts` using the T015 helper against
|
||||
`POST /v1/support/requests` (depends on T015)
|
||||
- [x] T017 [P] [US5] Create `tests/load/ai-support-flow.load.ts` using the T015 helper against
|
||||
the AI support flow's own endpoints (depends on T015)
|
||||
- [x] T018 [P] [US5] Create `tests/load/admin-reporting.load.ts` using the T015 helper, signing
|
||||
in as the seeded admin first, against the 015-reporting-dashboards endpoints (depends on
|
||||
T015)
|
||||
- [x] T019 [US5] Run all three scripts against a real running dev server, confirm each produces
|
||||
a report, and run each twice to confirm consistent-shape output for comparison — SC-005
|
||||
(depends on T016, T017, T018)
|
||||
|
||||
**Checkpoint**: Quickstart Scenario 5 passes; a baseline report exists for each endpoint group.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T020 Update `specs/016-load-concurrency-testing/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T021 `npx tsc --noEmit` / `npm run lint` / `npx tsx scripts/check-architecture.ts` clean
|
||||
- [x] T022 Full existing unit + integration + concurrency suite re-run (throwaway DB), confirming
|
||||
no regression in 007-orchestration-assignment's, 008-sla-escalation's,
|
||||
012-admin-list-views's, and 015-reporting-dashboards's own existing coverage of
|
||||
`Assignment`/`SLARun`/`EscalationEvent`
|
||||
- [x] T023 Mark all of this file's checkboxes complete once verified
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **Foundational (Phase 2)**: No dependencies — BLOCKS User Stories 1, 2, 3 only
|
||||
- **User Story 4**: No dependency on Phase 2 or any other story — can start immediately
|
||||
- **User Story 5**: No dependency on Phase 2 or any other story — can start immediately (only
|
||||
needs Phase 1's `autocannon` devDependency)
|
||||
- **User Stories 1, 2, 3**: Each depends only on Phase 2 — independent of each other and of
|
||||
User Stories 4/5
|
||||
- **Polish (Phase 8)**: Depends on all five user stories
|
||||
|
||||
## Parallel Example: Foundational-independent stories
|
||||
|
||||
```text
|
||||
# Once Phase 1 completes, these can start immediately in parallel, without waiting on Phase 2:
|
||||
Task: "Write tests/concurrency/ticket-status-race.test.ts" (US4, T014)
|
||||
Task: "Create tests/load/autocannon.config.ts" (US5, T015)
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested order
|
||||
|
||||
1. Phase 1 (Setup) and Phase 2 (Foundational) — Phase 2 unblocks the three highest-severity
|
||||
real-bug fixes (US1, US2, US3)
|
||||
2. User Stories 1, 2, 3 (all P1) — each is a real, currently-unguarded race; fix and prove each
|
||||
in turn, or in parallel across files since they touch different modules
|
||||
3. User Story 4 (P2) — quick to add, proves existing protection, can be done any time after
|
||||
Phase 1
|
||||
4. User Story 5 (P2) — independent tooling work, can be done any time after Phase 1, in parallel
|
||||
with 1-4
|
||||
5. Phase 8 (Polish) once all five stories are verified
|
||||
@@ -24,6 +24,7 @@ import { solutionsRoutes } from '@/modules/problem-management/solutions';
|
||||
import { verificationRoutes } from '@/modules/problem-management/verification';
|
||||
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
|
||||
import { authRoutes } from '@/modules/identity/auth';
|
||||
import { reportsRoutes } from '@/modules/platform/reports';
|
||||
|
||||
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||
await app.register(healthRoutes);
|
||||
@@ -49,5 +50,6 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
|
||||
await app.register(solutionsRoutes);
|
||||
await app.register(verificationRoutes);
|
||||
await app.register(resolutionsRoutes);
|
||||
await app.register(reportsRoutes);
|
||||
// Further domain module routes will be registered here as feature modules are wired up
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ const envSchema = z.object({
|
||||
// configured" — tracing still runs, just exports to the console instead (never a startup
|
||||
// requirement) — see specs/014-full-observability/research.md "Distributed tracing".
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||||
|
||||
// Reporting and Analytics Dashboards (015) — CONFIGURABLE per docs/10-implementation-
|
||||
// roadmap.md's own "never hardcode a placeholder value and ship it as final" instruction —
|
||||
// see specs/015-reporting-dashboards/research.md §8.
|
||||
REPORTING_DEFAULT_WINDOW_DAYS: z.coerce.number().default(30),
|
||||
REPORTING_SLA_RISK_THRESHOLD_MINUTES: z.coerce.number().default(60),
|
||||
REPORTING_TOP_N_LIMIT: z.coerce.number().default(10),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './ai';
|
||||
export * from './orchestration';
|
||||
export * from './problem-resolution';
|
||||
export * from './auth';
|
||||
export * from './reporting';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const reportingConfig = {
|
||||
defaultWindowDays: env.REPORTING_DEFAULT_WINDOW_DAYS,
|
||||
slaRiskThresholdMinutes: env.REPORTING_SLA_RISK_THRESHOLD_MINUTES,
|
||||
topNLimit: env.REPORTING_TOP_N_LIMIT,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { ErrorCodeLookup } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* 015-reporting-dashboards research.md §6: a durable, append-only audit row — no update/delete
|
||||
* path, every lookup is its own row, duplicates over time are the point (frequency is what
|
||||
* "top errors" measures).
|
||||
*/
|
||||
export class ErrorCodeLookupRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async create(errorCodeId: string, productId: string): Promise<ErrorCodeLookup> {
|
||||
return this.prisma.errorCodeLookup.create({ data: { errorCodeId, productId } });
|
||||
}
|
||||
|
||||
async countByCodeForProduct(
|
||||
productId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<Array<{ errorCodeId: string; count: number }>> {
|
||||
const grouped = await this.prisma.errorCodeLookup.groupBy({
|
||||
by: ['errorCodeId'],
|
||||
where: { productId, createdAt: { gte: from, lte: to } },
|
||||
_count: { errorCodeId: true },
|
||||
orderBy: { _count: { errorCodeId: 'desc' } },
|
||||
});
|
||||
return grouped.map((g) => ({ errorCodeId: g.errorCodeId, count: g._count.errorCodeId }));
|
||||
}
|
||||
}
|
||||
|
||||
export const errorCodeLookupRepository = new ErrorCodeLookupRepository();
|
||||
@@ -13,6 +13,10 @@ export class ErrorCodesRepository {
|
||||
where: { productId_code: { productId, code } },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ErrorCode | null> {
|
||||
return this.prisma.errorCode.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
export const errorCodesRepository = new ErrorCodesRepository();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './knowledge.repository';
|
||||
export * from './error-codes.repository';
|
||||
export * from './error-code-lookup.repository';
|
||||
export * from './known-issues.repository';
|
||||
export * from './runbooks.repository';
|
||||
|
||||
@@ -4,6 +4,8 @@ import { knownErrorLookupsCounter } from '@/infrastructure/observability';
|
||||
import {
|
||||
errorCodesRepository,
|
||||
ErrorCodesRepository,
|
||||
errorCodeLookupRepository,
|
||||
ErrorCodeLookupRepository,
|
||||
knownIssuesRepository,
|
||||
KnownIssuesRepository,
|
||||
CreateKnownIssueData,
|
||||
@@ -13,6 +15,7 @@ export class ErrorCodesService {
|
||||
constructor(
|
||||
private readonly errorCodesRepo: ErrorCodesRepository = errorCodesRepository,
|
||||
private readonly knownIssuesRepo: KnownIssuesRepository = knownIssuesRepository,
|
||||
private readonly lookupsRepo: ErrorCodeLookupRepository = errorCodeLookupRepository,
|
||||
) {}
|
||||
|
||||
async createErrorCode(productId: string, code: string, description: string): Promise<ErrorCode> {
|
||||
@@ -28,12 +31,36 @@ export class ErrorCodesService {
|
||||
const errorCode = await this.errorCodesRepo.findByCode(productId, code);
|
||||
if (!errorCode) throw new NotFoundError('Error code not found.');
|
||||
|
||||
// 014-full-observability data-model.md #9: "most common errors" — a raw counter, ranked by
|
||||
// an external monitoring stack (FR-009), counted only once the code is confirmed real.
|
||||
// 014-full-observability data-model.md #9: "most common errors" — a raw, process-lifetime
|
||||
// counter for a live monitoring stack (FR-009 there), counted only once the code is
|
||||
// confirmed real.
|
||||
knownErrorLookupsCounter.inc({ code });
|
||||
// 015-reporting-dashboards research.md §6: the durable counterpart — the live counter above
|
||||
// resets on every restart, so a historical "top errors" report needs its own audit row.
|
||||
await this.lookupsRepo.create(errorCode.id, productId);
|
||||
|
||||
return this.knownIssuesRepo.findByErrorCodeId(errorCode.id);
|
||||
}
|
||||
|
||||
/** 015-reporting-dashboards: the Product dashboard's "top errors" ranking — encapsulated here
|
||||
* (not exposed as raw repository access) since resolving a lookup count back to its error
|
||||
* code's own `code` string is this module's own concern, not the reports module's. */
|
||||
async getTopErrorCodesForProduct(
|
||||
productId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
limit: number,
|
||||
): Promise<Array<{ code: string; count: number }>> {
|
||||
const ranked = await this.lookupsRepo.countByCodeForProduct(productId, from, to);
|
||||
const top = ranked.slice(0, limit);
|
||||
const rows = await Promise.all(
|
||||
top.map(async (row) => {
|
||||
const errorCode = await this.errorCodesRepo.findById(row.errorCodeId);
|
||||
return { code: errorCode?.code ?? row.errorCodeId, count: row.count };
|
||||
}),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
export const errorCodesService = new ErrorCodesService();
|
||||
|
||||
@@ -3,11 +3,20 @@ export { SessionsService, sessionsService } from './service';
|
||||
export type { SessionTurnResult } from './service';
|
||||
export { ConfidencePolicyService, confidencePolicyService } from './service';
|
||||
export type { ResolvedConfidencePolicy } from './service';
|
||||
// 015-reporting-dashboards research.md §7: reused for the AI dashboard's confidence
|
||||
// distribution, not reimplemented.
|
||||
export { decideConfidenceBand } from './service';
|
||||
export type { ConfidenceBand } from './service';
|
||||
export {
|
||||
sessionRepository,
|
||||
SessionRepository,
|
||||
diagnosisRepository,
|
||||
DiagnosisRepository,
|
||||
// 015-reporting-dashboards: test setup needs to record a session's knowledge references
|
||||
// directly, the same "extend an existing module's public surface for a later feature"
|
||||
// precedent as 004's productsRepository/009's problemsRepository.
|
||||
knowledgeReferenceRepository,
|
||||
KnowledgeReferenceRepository,
|
||||
} from './repository';
|
||||
export { ACTIVE_SESSION_STATUSES, TERMINAL_SESSION_STATUSES } from './mapper';
|
||||
export type { SessionStatus } from './mapper';
|
||||
|
||||
@@ -8,6 +8,24 @@ export interface CreateAssignmentData {
|
||||
reason?: string | undefined;
|
||||
}
|
||||
|
||||
// Bounded, but generous: under N genuinely concurrent attempts on the same ticket, a given
|
||||
// attempt can collide with a different still-in-flight one on each of several retries before
|
||||
// the field of contenders drains — 3 was observed to be too few under a 20-way race in this
|
||||
// project's own concurrency test (tests/concurrency/assignment-race.test.ts).
|
||||
const MAX_CREATE_ASSIGNMENT_ATTEMPTS = 20;
|
||||
|
||||
function isCurrentAssignmentConflict(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002' &&
|
||||
(error.meta?.target as string[] | undefined)?.includes('ticketId') === true
|
||||
);
|
||||
}
|
||||
|
||||
function jitterDelayMs(): number {
|
||||
return Math.floor(Math.random() * 15);
|
||||
}
|
||||
|
||||
export class AssignmentRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
@@ -16,23 +34,42 @@ export class AssignmentRepository {
|
||||
* supersedes any existing current row for this ticket (isCurrent: false, unassignedAt: now())
|
||||
* and inserts the new current row — the same "never overwrite, always a new row" guarantee
|
||||
* 004's KnowledgeEntry versioning already established for a different entity.
|
||||
*
|
||||
* 016-load-concurrency-testing research.md §1: at Postgres's default READ COMMITTED
|
||||
* isolation, two concurrent calls can each see "nothing current to supersede" and both
|
||||
* attempt to `create` their own `isCurrent: true` row. The `assignments_one_current_per_ticket`
|
||||
* partial unique index (migration 20260909120000) makes the second one fail fast with `P2002`
|
||||
* instead of silently succeeding — caught here and retried (bounded) so the loser's own
|
||||
* request still applies, correctly superseding the winner's row on the next attempt, rather
|
||||
* than surfacing a raw conflict to a caller that did nothing wrong.
|
||||
*/
|
||||
async createAssignment(data: CreateAssignmentData): Promise<Assignment> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.assignment.updateMany({
|
||||
where: { ticketId: data.ticketId, isCurrent: true },
|
||||
data: { isCurrent: false, unassignedAt: new Date() },
|
||||
});
|
||||
return tx.assignment.create({
|
||||
data: {
|
||||
ticketId: data.ticketId,
|
||||
agentId: data.agentId,
|
||||
strategy: data.strategy,
|
||||
reason: data.reason,
|
||||
isCurrent: true,
|
||||
} as Prisma.AssignmentUncheckedCreateInput,
|
||||
});
|
||||
});
|
||||
for (let attempt = 1; attempt <= MAX_CREATE_ASSIGNMENT_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
await tx.assignment.updateMany({
|
||||
where: { ticketId: data.ticketId, isCurrent: true },
|
||||
data: { isCurrent: false, unassignedAt: new Date() },
|
||||
});
|
||||
return tx.assignment.create({
|
||||
data: {
|
||||
ticketId: data.ticketId,
|
||||
agentId: data.agentId,
|
||||
strategy: data.strategy,
|
||||
reason: data.reason,
|
||||
isCurrent: true,
|
||||
} as Prisma.AssignmentUncheckedCreateInput,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentAssignmentConflict(error) || attempt === MAX_CREATE_ASSIGNMENT_ATTEMPTS) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, jitterDelayMs()));
|
||||
}
|
||||
}
|
||||
/* istanbul ignore next -- unreachable: the loop above always returns or throws */
|
||||
throw new Error('createAssignment: exhausted retry attempts unexpectedly.');
|
||||
}
|
||||
|
||||
async findCurrent(ticketId: string): Promise<Assignment | null> {
|
||||
|
||||
@@ -10,13 +10,54 @@ export interface CreateEscalationEventData {
|
||||
triggeredBy: string;
|
||||
}
|
||||
|
||||
export interface CreateEscalationEventResult {
|
||||
event: EscalationEvent;
|
||||
/** False when `create` returned a pre-existing event instead of inserting a new one — the
|
||||
* caller (EscalationService.fire) uses this to skip re-running side effects (reassignment,
|
||||
* the audit event-bus publish) for a duplicate trigger. */
|
||||
wasNewlyCreated: boolean;
|
||||
}
|
||||
|
||||
function isDuplicateRuleEscalationConflict(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002' &&
|
||||
(error.meta?.target as string[] | undefined)?.includes('ruleId') === true
|
||||
);
|
||||
}
|
||||
|
||||
export class EscalationEventRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async create(data: CreateEscalationEventData): Promise<EscalationEvent> {
|
||||
return this.prisma.escalationEvent.create({
|
||||
data: data as Prisma.EscalationEventUncheckedCreateInput,
|
||||
});
|
||||
/**
|
||||
* 016-load-concurrency-testing research.md §3: a rule-triggered escalation
|
||||
* (`data.ruleId` set) can only ever legitimately fire once per ticket over that ticket's
|
||||
* lifetime (SLARun.ticketId is @unique — no reopen-cycle support). The
|
||||
* `escalation_events_ticket_rule_unique` partial unique index (migration 20260909120000)
|
||||
* enforces this at the database level; a duplicate trigger (e.g. two overlapping breach-sweep
|
||||
* passes, or a re-delivered job) fails with `P2002` here, and this method returns the
|
||||
* pre-existing event instead of throwing — the duplicate is silently absorbed, never
|
||||
* surfaced as an error to a caller that did nothing wrong. Manual escalations (`ruleId: null`)
|
||||
* are unaffected and always insert a new row.
|
||||
*/
|
||||
async create(data: CreateEscalationEventData): Promise<CreateEscalationEventResult> {
|
||||
try {
|
||||
const event = await this.prisma.escalationEvent.create({
|
||||
data: data as Prisma.EscalationEventUncheckedCreateInput,
|
||||
});
|
||||
return { event, wasNewlyCreated: true };
|
||||
} catch (error) {
|
||||
if (!isDuplicateRuleEscalationConflict(error)) throw error;
|
||||
|
||||
const existing = await this.prisma.escalationEvent.findFirst({
|
||||
where: {
|
||||
ticketId: data.ticketId,
|
||||
...(data.ruleId !== undefined ? { ruleId: data.ruleId } : {}),
|
||||
},
|
||||
});
|
||||
if (!existing) throw error; // conflict raced with a delete — surface the original error.
|
||||
return { event: existing, wasNewlyCreated: false };
|
||||
}
|
||||
}
|
||||
|
||||
async findAllForTicket(ticketId: string): Promise<EscalationEvent[]> {
|
||||
|
||||
@@ -76,7 +76,7 @@ export class EscalationService {
|
||||
actor: string,
|
||||
reason: string,
|
||||
): Promise<EscalationEvent> {
|
||||
const event = await this.events.create({
|
||||
const { event, wasNewlyCreated } = await this.events.create({
|
||||
ticketId,
|
||||
ruleId,
|
||||
// No existing model persists "which hierarchy node is this ticket currently in" — Assignment
|
||||
@@ -88,6 +88,12 @@ export class EscalationService {
|
||||
triggeredBy: actor,
|
||||
});
|
||||
|
||||
// 016-load-concurrency-testing research.md §3/FR-003: a duplicate rule-triggered trigger
|
||||
// (the repository already detected and absorbed it) must not also reassign or re-publish —
|
||||
// both already happened for the winning attempt; doing them again would be the exact
|
||||
// duplicate-side-effect bug this feature exists to close.
|
||||
if (!wasNewlyCreated) return event;
|
||||
|
||||
await this.assignments.assignToSpecificNode(ticketId, targetNodeId, actor, reason);
|
||||
|
||||
// research.md "SLA_BREACHED/ESCALATION_TRIGGERED are also published, for audit, not for
|
||||
|
||||
@@ -21,8 +21,26 @@ export class SlaRunRepository {
|
||||
return this.prisma.sLARun.findUnique({ where: { ticketId } });
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.SLARunUpdateInput): Promise<SLARun> {
|
||||
return this.prisma.sLARun.update({ where: { id }, data });
|
||||
/**
|
||||
* 016-load-concurrency-testing research.md §2: optimistic concurrency, identical shape to
|
||||
* TicketsRepository.updateStatus (003-ticketing) — an atomic single-statement `updateMany`
|
||||
* conditioned on the row's `version` still matching `expectedVersion`. Replaces the old plain
|
||||
* `update(id, data)`, which let two racing callers (e.g. `resume` and the breach sweep
|
||||
* evaluating the same run at once) silently clobber each other's writes. Returns `null` on a
|
||||
* stale-version mismatch — the caller re-reads and retries, exactly like `TicketsService`
|
||||
* already does for ticket-status conflicts.
|
||||
*/
|
||||
async updateWithVersion(
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
data: Prisma.SLARunUpdateInput,
|
||||
): Promise<SLARun | null> {
|
||||
const result = await this.prisma.sLARun.updateMany({
|
||||
where: { id, version: expectedVersion },
|
||||
data: { ...data, version: { increment: 1 } } as Prisma.SLARunUncheckedUpdateManyInput,
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
return this.prisma.sLARun.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** research.md "Breach detection — one repeatable BullMQ job": every running run whose
|
||||
|
||||
@@ -98,13 +98,28 @@ export class SlaService {
|
||||
});
|
||||
}
|
||||
|
||||
// 016-load-concurrency-testing research.md §2: pause/resume/complete each read-then-write a
|
||||
// run with no guard, so two of them racing (or one racing the sweep below) could silently
|
||||
// clobber each other — e.g. a resume reading a run just before the sweep marks it breached,
|
||||
// then overwriting that breach back to 'running' moments later. Bounded to a handful of
|
||||
// attempts, re-reading fresh state each time (mirroring TicketsService's own version-conflict
|
||||
// handling), so a losing attempt still applies correctly against the winner's result instead
|
||||
// of being silently dropped or corrupting state.
|
||||
private static readonly MAX_UPDATE_ATTEMPTS = 5;
|
||||
|
||||
/** FR-007: pausing on WAITING_FOR_CUSTOMER records pausedAt and flips status — no-ops if
|
||||
* there's no run or it isn't currently running. */
|
||||
async pause(ticketId: string): Promise<void> {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status !== 'running') return;
|
||||
for (let attempt = 1; attempt <= SlaService.MAX_UPDATE_ATTEMPTS; attempt++) {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status !== 'running') return;
|
||||
|
||||
await this.runs.update(run.id, { status: 'paused', pausedAt: new Date() });
|
||||
const updated = await this.runs.updateWithVersion(run.id, run.version, {
|
||||
status: 'paused',
|
||||
pausedAt: new Date(),
|
||||
});
|
||||
if (updated) return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,38 +128,49 @@ export class SlaService {
|
||||
* durability mechanism (no separate remaining-minutes bookkeeping, no in-memory state).
|
||||
*/
|
||||
async resume(ticketId: string): Promise<void> {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status !== 'paused' || !run.pausedAt) return;
|
||||
for (let attempt = 1; attempt <= SlaService.MAX_UPDATE_ATTEMPTS; attempt++) {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status !== 'paused' || !run.pausedAt) return;
|
||||
|
||||
const pausedMs = Date.now() - run.pausedAt.getTime();
|
||||
await this.runs.update(run.id, {
|
||||
status: 'running',
|
||||
pausedAt: null,
|
||||
resumedAt: new Date(),
|
||||
firstResponseDueAt: run.firstResponseDueAt
|
||||
? new Date(run.firstResponseDueAt.getTime() + pausedMs)
|
||||
: null,
|
||||
resolutionDueAt: run.resolutionDueAt
|
||||
? new Date(run.resolutionDueAt.getTime() + pausedMs)
|
||||
: null,
|
||||
});
|
||||
const pausedMs = Date.now() - run.pausedAt.getTime();
|
||||
const updated = await this.runs.updateWithVersion(run.id, run.version, {
|
||||
status: 'running',
|
||||
pausedAt: null,
|
||||
resumedAt: new Date(),
|
||||
firstResponseDueAt: run.firstResponseDueAt
|
||||
? new Date(run.firstResponseDueAt.getTime() + pausedMs)
|
||||
: null,
|
||||
resolutionDueAt: run.resolutionDueAt
|
||||
? new Date(run.resolutionDueAt.getTime() + pausedMs)
|
||||
: null,
|
||||
});
|
||||
if (updated) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** FR-010: a run that resolves before its due date is marked completed and is never later
|
||||
* flagged breached (the breach sweep only ever looks at status: 'running' runs). */
|
||||
async complete(ticketId: string): Promise<void> {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status === 'completed') return;
|
||||
for (let attempt = 1; attempt <= SlaService.MAX_UPDATE_ATTEMPTS; attempt++) {
|
||||
const run = await this.runs.findByTicketId(ticketId);
|
||||
if (!run || run.status === 'completed') return;
|
||||
|
||||
// 014-full-observability data-model.md #6: read BEFORE the update below — a run already
|
||||
// 'breached' by the time it resolves was already counted breached by the sweep and must
|
||||
// never also be counted 'met' here, even though this update still (pre-existing behavior,
|
||||
// unrelated to this feature — see research.md §5) overwrites its status to 'completed'.
|
||||
if (run.status !== 'breached') {
|
||||
slaRunOutcomesCounter.inc({ outcome: 'met' });
|
||||
const updated = await this.runs.updateWithVersion(run.id, run.version, {
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
});
|
||||
if (updated) {
|
||||
// 014-full-observability data-model.md #6: gated on this same successful transition's
|
||||
// pre-update status (not re-read afterward) — a run already 'breached' by the time it
|
||||
// resolves was already counted breached by the sweep and must never also be counted
|
||||
// 'met' here, even though this update still (pre-existing behavior, unrelated to this
|
||||
// feature — see research.md §5) overwrites its status to 'completed'.
|
||||
if (run.status !== 'breached') {
|
||||
slaRunOutcomesCounter.inc({ outcome: 'met' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.runs.update(run.id, { status: 'completed', completedAt: new Date() });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,13 +179,23 @@ export class SlaService {
|
||||
* worker process needed to invoke it, tests call this directly). Marks resolution breaches
|
||||
* (status -> breached) and first-response breaches (firstResponseBreachedAt, status
|
||||
* unchanged), then fires escalation for each newly-detected breach.
|
||||
*
|
||||
* 016-load-concurrency-testing research.md §2: each run's update is now version-guarded. A
|
||||
* lost race here (a concurrent pause/resume/complete changed the run first) means this run's
|
||||
* status is no longer what the sweep's own query assumed — skipped for this pass rather than
|
||||
* retried, since the next scheduled sweep re-evaluates every run fresh against real
|
||||
* conditions anyway; this only ever defers, never drops, a genuine breach.
|
||||
*/
|
||||
async runBreachDetectionSweep(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const resolutionBreaches = await this.runs.findRunningPastResolutionDueAt(now);
|
||||
for (const run of resolutionBreaches) {
|
||||
await this.runs.update(run.id, { status: 'breached', breachedAt: now });
|
||||
const updated = await this.runs.updateWithVersion(run.id, run.version, {
|
||||
status: 'breached',
|
||||
breachedAt: now,
|
||||
});
|
||||
if (!updated) continue;
|
||||
slaRunOutcomesCounter.inc({ outcome: 'breached' });
|
||||
await this.escalation.handleBreach(run.ticketId, 'resolution_breach');
|
||||
}
|
||||
@@ -170,7 +206,10 @@ export class SlaService {
|
||||
const hasAgentResponse = messages.some((m) => m.type === 'AGENT_MESSAGE');
|
||||
if (hasAgentResponse) continue;
|
||||
|
||||
await this.runs.update(run.id, { firstResponseBreachedAt: now });
|
||||
const updated = await this.runs.updateWithVersion(run.id, run.version, {
|
||||
firstResponseBreachedAt: now,
|
||||
});
|
||||
if (!updated) continue;
|
||||
await this.escalation.handleBreach(run.ticketId, 'first_response_breach');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reports.controller';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { reportsService, ReportsService } from '../service';
|
||||
import { dateRangeQuerySchema } from '../schema';
|
||||
import { resolveDateRange } from '../mapper';
|
||||
|
||||
export class ReportsController {
|
||||
constructor(private readonly service: ReportsService = reportsService) {}
|
||||
|
||||
async getManagementDashboard(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = dateRangeQuerySchema.parse(request.query);
|
||||
const range = resolveDateRange(query);
|
||||
const dashboard = await this.service.getManagementDashboard(range);
|
||||
return reply.status(200).send({ success: true, data: dashboard, meta: null });
|
||||
}
|
||||
|
||||
async getProductDashboard(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { externalProductId } = request.params as { externalProductId: string };
|
||||
const query = dateRangeQuerySchema.parse(request.query);
|
||||
const range = resolveDateRange(query);
|
||||
const dashboard = await this.service.getProductDashboard(externalProductId, range);
|
||||
return reply.status(200).send({ success: true, data: dashboard, meta: null });
|
||||
}
|
||||
|
||||
async getSupportDashboard(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = dateRangeQuerySchema.parse(request.query);
|
||||
const range = resolveDateRange(query);
|
||||
const dashboard = await this.service.getSupportDashboard(range);
|
||||
return reply.status(200).send({ success: true, data: dashboard, meta: null });
|
||||
}
|
||||
|
||||
async getAiDashboard(request: FastifyRequest, reply: FastifyReply) {
|
||||
const query = dateRangeQuerySchema.parse(request.query);
|
||||
const range = resolveDateRange(query);
|
||||
const dashboard = await this.service.getAiDashboard(range);
|
||||
return reply.status(200).send({ success: true, data: dashboard, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const reportsController = new ReportsController();
|
||||
@@ -1,11 +1,8 @@
|
||||
export const REPORTS_CONSTANTS = {
|
||||
MODULE_NAME: 'PLATFORM_REPORTS',
|
||||
} as const;
|
||||
|
||||
export class ReportsService {
|
||||
async generateSummaryReport(): Promise<Record<string, unknown>> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const reportsService = new ReportsService();
|
||||
export { reportsRoutes } from './routes';
|
||||
export { ReportsService, reportsService } from './service';
|
||||
export type {
|
||||
ManagementDashboard,
|
||||
ProductDashboard,
|
||||
SupportDashboard,
|
||||
AiDashboard,
|
||||
} from './service';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ValidationError } from '@/common/errors';
|
||||
import { reportingConfig } from '@/config';
|
||||
|
||||
export interface DateRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 015-reporting-dashboards data-model.md "Query Parameters": both ends optional — `to` defaults
|
||||
* to now, `from` defaults to `to - reportingConfig.defaultWindowDays`. `from > to` is a
|
||||
* ValidationError (spec.md Edge Cases), never silently swapped or silently returning empty data.
|
||||
*/
|
||||
export function resolveDateRange(query: {
|
||||
from?: string | undefined;
|
||||
to?: string | undefined;
|
||||
}): DateRange {
|
||||
const to = query.to ? new Date(query.to) : new Date();
|
||||
if (Number.isNaN(to.getTime())) {
|
||||
throw new ValidationError('"to" is not a valid date.');
|
||||
}
|
||||
|
||||
const from = query.from
|
||||
? new Date(query.from)
|
||||
: new Date(to.getTime() - reportingConfig.defaultWindowDays * 24 * 60 * 60 * 1000);
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
throw new ValidationError('"from" is not a valid date.');
|
||||
}
|
||||
|
||||
if (from > to) {
|
||||
throw new ValidationError('"from" must not be after "to".');
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function serializeDateRange(range: DateRange): { from: string; to: string } {
|
||||
return { from: range.from.toISOString(), to: range.to.toISOString() };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
interface TicketWithFirstAgentMessage {
|
||||
createdAt: Date;
|
||||
messages: Array<{ createdAt: Date }>;
|
||||
}
|
||||
|
||||
/** Shared by ManagementRepository and SupportRepository — both need "ticket createdAt -> its
|
||||
* first AGENT_MESSAGE createdAt" in milliseconds, for tickets that actually have one. */
|
||||
export function extractFirstResponseDurationsMs(tickets: TicketWithFirstAgentMessage[]): number[] {
|
||||
return tickets.flatMap((t) => {
|
||||
const firstAgentMessage = t.messages[0];
|
||||
if (!firstAgentMessage) return [];
|
||||
return [firstAgentMessage.createdAt.getTime() - t.createdAt.getTime()];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './date-range';
|
||||
export * from './rate';
|
||||
export * from './durations';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 015-reporting-dashboards research.md §3: every rate/average is `number | null` — `null` means
|
||||
* "no qualifying data in range," distinguished from a genuine `0` (e.g. a real 0% AI resolution
|
||||
* rate is meaningful; "nobody's data exists yet" is not the same thing). Never computed as
|
||||
* `numerator / 0`, which would silently produce `NaN`.
|
||||
*/
|
||||
export function computeRate(numerator: number, denominator: number): number | null {
|
||||
if (denominator === 0) return null;
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
export function computeAverageSeconds(durationsMs: number[]): number | null {
|
||||
if (durationsMs.length === 0) return null;
|
||||
const totalMs = durationsMs.reduce((sum, ms) => sum + ms, 0);
|
||||
return totalMs / durationsMs.length / 1000;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { DateRange } from '../mapper';
|
||||
|
||||
export class AiRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async sessionOutcomeCounts(
|
||||
range: DateRange,
|
||||
): Promise<{ resolved: number; escalated: number; total: number }> {
|
||||
const [resolved, escalated, total] = await Promise.all([
|
||||
this.prisma.aISupportSession.count({
|
||||
where: { startedAt: { gte: range.from, lte: range.to }, status: 'resolved' },
|
||||
}),
|
||||
this.prisma.aISupportSession.count({
|
||||
where: {
|
||||
startedAt: { gte: range.from, lte: range.to },
|
||||
status: { in: ['escalated', 'ended_by_agent'] },
|
||||
},
|
||||
}),
|
||||
this.prisma.aISupportSession.count({
|
||||
where: { startedAt: { gte: range.from, lte: range.to } },
|
||||
}),
|
||||
]);
|
||||
return { resolved, escalated, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Failed troubleshooting then escalated" (spec.md User Story 4) has no single stored flag —
|
||||
* classifyStepOutcome's own verdicts aren't persisted as a durable per-step record. Documented
|
||||
* proxy: an escalated session that made at least one tool call (toolCallCount > 0) attempted
|
||||
* troubleshooting before giving up, vs. one that escalated immediately with zero attempts.
|
||||
*/
|
||||
async escalatedSessionsWithToolAttempts(range: DateRange): Promise<number> {
|
||||
return this.prisma.aISupportSession.count({
|
||||
where: {
|
||||
startedAt: { gte: range.from, lte: range.to },
|
||||
status: { in: ['escalated', 'ended_by_agent'] },
|
||||
toolCallCount: { gt: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sessionsWithKnowledgeMatch(range: DateRange): Promise<number> {
|
||||
const sessions = await this.prisma.aISupportSession.findMany({
|
||||
where: { startedAt: { gte: range.from, lte: range.to } },
|
||||
select: { knowledgeRefs: { select: { id: true }, take: 1 } },
|
||||
});
|
||||
return sessions.filter((s) => s.knowledgeRefs.length > 0).length;
|
||||
}
|
||||
|
||||
async diagnosisConfidences(range: DateRange): Promise<number[]> {
|
||||
const diagnoses = await this.prisma.aIDiagnosis.findMany({
|
||||
where: { createdAt: { gte: range.from, lte: range.to } },
|
||||
select: { confidence: true },
|
||||
});
|
||||
return diagnoses.map((d) => d.confidence);
|
||||
}
|
||||
|
||||
async toolInvocationOutcomeCounts(
|
||||
range: DateRange,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
const [success, failed] = await Promise.all([
|
||||
this.prisma.aIActionResult.count({
|
||||
where: { status: 'success', createdAt: { gte: range.from, lte: range.to } },
|
||||
}),
|
||||
this.prisma.aIActionResult.count({
|
||||
where: { status: 'failed', createdAt: { gte: range.from, lte: range.to } },
|
||||
}),
|
||||
]);
|
||||
return { success, failed };
|
||||
}
|
||||
}
|
||||
|
||||
export const aiRepository = new AiRepository();
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './management.repository';
|
||||
export * from './product.repository';
|
||||
export * from './support.repository';
|
||||
export * from './ai.repository';
|
||||
export * from './shared.repository';
|
||||
@@ -0,0 +1,79 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { DateRange } from '../mapper';
|
||||
|
||||
export class ManagementRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async totalCases(range: DateRange): Promise<number> {
|
||||
return this.prisma.ticket.count({
|
||||
where: { createdAt: { gte: range.from, lte: range.to } },
|
||||
});
|
||||
}
|
||||
|
||||
async countByStatus(range: DateRange, statuses: string[]): Promise<number> {
|
||||
return this.prisma.ticket.count({
|
||||
where: { createdAt: { gte: range.from, lte: range.to }, status: { in: statuses } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ever escalated to a human. NOT a current-status check: 003-ticketing's own state machine
|
||||
* lets both the AI path (AI_RESOLVED) and the human path (HUMAN_ESCALATION) converge on the
|
||||
* same shared terminal statuses (RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED are
|
||||
* all reachable from AI_RESOLVED directly, per ticket-state-machine.ts's own transition
|
||||
* table) — a status-list check over those shared statuses would count every AI-resolved
|
||||
* ticket as "human escalated" too (caught via manual verification against real seeded data,
|
||||
* not by any test fixture, since every existing test's fixtures happened to keep the two
|
||||
* paths' terminal statuses apart).
|
||||
*
|
||||
* The unambiguous, direct signal instead: 007-orchestration-assignment's own
|
||||
* `orchestrationService.handleHumanEscalation` is the *only* code path that ever creates an
|
||||
* `Assignment` row (research.md's own module map) — a ticket has one if and only if it was
|
||||
* actually escalated to a human at some point, regardless of its current status.
|
||||
*/
|
||||
async countEverEscalatedToHuman(range: DateRange): Promise<number> {
|
||||
return this.prisma.ticket.count({
|
||||
where: {
|
||||
createdAt: { gte: range.from, lte: range.to },
|
||||
assignments: { some: {} },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** research.md §4: `Resolution.resolvedBy` is the single source of truth for AI vs. human. */
|
||||
async countResolutionsBy(range: DateRange, resolvedByAi: boolean): Promise<number> {
|
||||
return this.prisma.resolution.count({
|
||||
where: {
|
||||
resolvedAt: { gte: range.from, lte: range.to },
|
||||
resolvedBy: resolvedByAi ? 'ai' : { not: 'ai' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async slaOutcomeCounts(range: DateRange): Promise<{ met: number; breached: number }> {
|
||||
const [met, breached] = await Promise.all([
|
||||
this.prisma.sLARun.count({
|
||||
where: {
|
||||
ticket: { createdAt: { gte: range.from, lte: range.to } },
|
||||
status: 'completed',
|
||||
breachedAt: null,
|
||||
},
|
||||
}),
|
||||
this.prisma.sLARun.count({
|
||||
where: {
|
||||
ticket: { createdAt: { gte: range.from, lte: range.to } },
|
||||
breachedAt: { not: null },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { met, breached };
|
||||
}
|
||||
|
||||
async escalationCount(range: DateRange): Promise<number> {
|
||||
return this.prisma.escalationEvent.count({
|
||||
where: { createdAt: { gte: range.from, lte: range.to } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const managementRepository = new ManagementRepository();
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { DateRange } from '../mapper';
|
||||
|
||||
// Named ProductReportRepository (not ProductRepository) to avoid colliding with
|
||||
// catalog/products' own ProductsRepository, which this module reuses (via its public index) for
|
||||
// resolving externalProductId -> Product rather than duplicating that lookup here.
|
||||
export class ProductReportRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async supportVolume(productId: string, range: DateRange): Promise<number> {
|
||||
return this.prisma.ticket.count({
|
||||
where: { productId, createdAt: { gte: range.from, lte: range.to } },
|
||||
});
|
||||
}
|
||||
|
||||
async problemsByCategory(
|
||||
productId: string,
|
||||
range: DateRange,
|
||||
): Promise<Array<{ categoryId: string | null; count: number }>> {
|
||||
const grouped = await this.prisma.problem.groupBy({
|
||||
by: ['categoryId'],
|
||||
where: { productId, createdAt: { gte: range.from, lte: range.to } },
|
||||
_count: { categoryId: true },
|
||||
orderBy: { _count: { categoryId: 'desc' } },
|
||||
});
|
||||
return grouped.map((g) => ({ categoryId: g.categoryId, count: g._count.categoryId }));
|
||||
}
|
||||
|
||||
async countResolutionsBy(
|
||||
productId: string,
|
||||
range: DateRange,
|
||||
resolvedByAi: boolean,
|
||||
): Promise<number> {
|
||||
return this.prisma.resolution.count({
|
||||
where: {
|
||||
resolvedAt: { gte: range.from, lte: range.to },
|
||||
resolvedBy: resolvedByAi ? 'ai' : { not: 'ai' },
|
||||
ticket: { productId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Same fixed "has at least one Assignment row" signal as
|
||||
* ManagementRepository.countEverEscalatedToHuman (see its own comment for why a current-status
|
||||
* check is wrong), scoped to one product. */
|
||||
async countEverEscalatedToHuman(productId: string, range: DateRange): Promise<number> {
|
||||
return this.prisma.ticket.count({
|
||||
where: {
|
||||
productId,
|
||||
createdAt: { gte: range.from, lte: range.to },
|
||||
assignments: { some: {} },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const productReportRepository = new ProductReportRepository();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { DateRange, extractFirstResponseDurationsMs } from '../mapper';
|
||||
|
||||
/** Response/resolution duration queries the Management and Support dashboards both need
|
||||
* identically — composed by each, not duplicated. */
|
||||
export class SharedReportRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async firstResponseDurationsMs(range: DateRange): Promise<number[]> {
|
||||
const tickets = await this.prisma.ticket.findMany({
|
||||
where: { createdAt: { gte: range.from, lte: range.to } },
|
||||
select: {
|
||||
createdAt: true,
|
||||
messages: {
|
||||
where: { type: 'AGENT_MESSAGE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return extractFirstResponseDurationsMs(tickets);
|
||||
}
|
||||
|
||||
async resolutionDurationsMs(range: DateRange): Promise<number[]> {
|
||||
const resolutions = await this.prisma.resolution.findMany({
|
||||
where: { resolvedAt: { gte: range.from, lte: range.to } },
|
||||
select: { resolvedAt: true, ticket: { select: { createdAt: true } } },
|
||||
});
|
||||
return resolutions.map((r) => r.resolvedAt.getTime() - r.ticket.createdAt.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
export const sharedReportRepository = new SharedReportRepository();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { DateRange } from '../mapper';
|
||||
|
||||
export class SupportRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
/** research.md §2: current, point-in-time — not range-scoped. "How much work is assigned
|
||||
* right now," not a historical count. */
|
||||
async workloadByAgent(): Promise<Array<{ agentId: string; openAssignments: number }>> {
|
||||
const grouped = await this.prisma.assignment.groupBy({
|
||||
by: ['agentId'],
|
||||
where: { isCurrent: true },
|
||||
_count: { agentId: true },
|
||||
});
|
||||
return grouped.map((g) => ({ agentId: g.agentId, openAssignments: g._count.agentId }));
|
||||
}
|
||||
|
||||
async slaAtRisk(thresholdMinutes: number): Promise<number> {
|
||||
const now = new Date();
|
||||
const riskCutoff = new Date(now.getTime() + thresholdMinutes * 60 * 1000);
|
||||
return this.prisma.sLARun.count({
|
||||
where: {
|
||||
status: 'running',
|
||||
resolutionDueAt: { gte: now, lte: riskCutoff },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async slaBreached(): Promise<number> {
|
||||
return this.prisma.sLARun.count({ where: { status: 'breached' } });
|
||||
}
|
||||
|
||||
async escalationCount(range: DateRange): Promise<number> {
|
||||
return this.prisma.escalationEvent.count({
|
||||
where: { createdAt: { gte: range.from, lte: range.to } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const supportRepository = new SupportRepository();
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reports.routes';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { reportsController } from '../controller';
|
||||
|
||||
/** contracts/reports-api-contract.md: every dashboard is admin-only, the same gate every other
|
||||
* admin-only surface uses since 010-identity-auth. */
|
||||
export async function reportsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get(
|
||||
'/admin/reports/management',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => reportsController.getManagementDashboard(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/reports/product/:externalProductId',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => reportsController.getProductDashboard(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/reports/support',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => reportsController.getSupportDashboard(req, reply),
|
||||
);
|
||||
fastify.get(
|
||||
'/admin/reports/ai',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => reportsController.getAiDashboard(req, reply),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reports.schema';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dateRangeQuerySchema = z
|
||||
.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DateRangeQuery = z.infer<typeof dateRangeQuerySchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reports.service';
|
||||
@@ -0,0 +1,219 @@
|
||||
import { NotFoundError } from '@/common/errors';
|
||||
import { reportingConfig, aiConfig } from '@/config';
|
||||
import { productsRepository, ProductsRepository } from '@/modules/catalog/products';
|
||||
import { decideConfidenceBand } from '@/modules/ai-support/sessions';
|
||||
import { errorCodesService, ErrorCodesService } from '@/modules/ai-support/knowledge';
|
||||
import {
|
||||
managementRepository,
|
||||
ManagementRepository,
|
||||
productReportRepository,
|
||||
ProductReportRepository,
|
||||
supportRepository,
|
||||
SupportRepository,
|
||||
aiRepository,
|
||||
AiRepository,
|
||||
sharedReportRepository,
|
||||
SharedReportRepository,
|
||||
} from '../repository';
|
||||
import { DateRange, serializeDateRange, computeRate, computeAverageSeconds } from '../mapper';
|
||||
|
||||
export interface ManagementDashboard {
|
||||
range: { from: string; to: string };
|
||||
totalCases: number;
|
||||
aiResolved: number;
|
||||
humanEscalated: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
slaCompliance: { met: number; breached: number; rate: number | null };
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface ProductDashboard {
|
||||
productId: string;
|
||||
range: { from: string; to: string };
|
||||
supportVolume: number;
|
||||
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
|
||||
recurringProblems: Array<{ categoryId: string | null; count: number }>;
|
||||
aiResolutionRate: number | null;
|
||||
humanEscalationRate: number | null;
|
||||
topErrors: Array<{ code: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface SupportDashboard {
|
||||
generatedAt: string;
|
||||
range: { from: string; to: string };
|
||||
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
|
||||
slaAtRisk: number;
|
||||
slaBreached: number;
|
||||
escalationCount: number;
|
||||
averageResponseSeconds: number | null;
|
||||
averageResolutionSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface AiDashboard {
|
||||
range: { from: string; to: string };
|
||||
totalSessions: number;
|
||||
aiResolutionRate: number | null;
|
||||
humanHandoffRate: number | null;
|
||||
failedTroubleshootingEscalationRate: number | null;
|
||||
knowledgeMatchRate: number | null;
|
||||
confidenceDistribution: { proceed: number; ask: number; escalate: number };
|
||||
toolInvocations: { success: number; failed: number };
|
||||
}
|
||||
|
||||
const RESOLVED_STATUSES = ['RESOLVED', 'CLOSED'];
|
||||
|
||||
export class ReportsService {
|
||||
constructor(
|
||||
private readonly management: ManagementRepository = managementRepository,
|
||||
private readonly productReports: ProductReportRepository = productReportRepository,
|
||||
private readonly support: SupportRepository = supportRepository,
|
||||
private readonly ai: AiRepository = aiRepository,
|
||||
private readonly products: ProductsRepository = productsRepository,
|
||||
private readonly errorCodes: ErrorCodesService = errorCodesService,
|
||||
private readonly shared: SharedReportRepository = sharedReportRepository,
|
||||
) {}
|
||||
|
||||
async getManagementDashboard(range: DateRange): Promise<ManagementDashboard> {
|
||||
const [
|
||||
totalCases,
|
||||
aiResolved,
|
||||
humanEscalated,
|
||||
resolved,
|
||||
slaOutcomes,
|
||||
escalationCount,
|
||||
responseDurations,
|
||||
resolutionDurations,
|
||||
] = await Promise.all([
|
||||
this.management.totalCases(range),
|
||||
this.management.countResolutionsBy(range, true),
|
||||
this.management.countEverEscalatedToHuman(range),
|
||||
this.management.countByStatus(range, RESOLVED_STATUSES),
|
||||
this.management.slaOutcomeCounts(range),
|
||||
this.management.escalationCount(range),
|
||||
this.shared.firstResponseDurationsMs(range),
|
||||
this.shared.resolutionDurationsMs(range),
|
||||
]);
|
||||
|
||||
return {
|
||||
range: serializeDateRange(range),
|
||||
totalCases,
|
||||
aiResolved,
|
||||
humanEscalated,
|
||||
resolved,
|
||||
open: totalCases - resolved,
|
||||
slaCompliance: {
|
||||
met: slaOutcomes.met,
|
||||
breached: slaOutcomes.breached,
|
||||
rate: computeRate(slaOutcomes.met, slaOutcomes.met + slaOutcomes.breached),
|
||||
},
|
||||
escalationCount,
|
||||
averageResponseSeconds: computeAverageSeconds(responseDurations),
|
||||
averageResolutionSeconds: computeAverageSeconds(resolutionDurations),
|
||||
};
|
||||
}
|
||||
|
||||
async getProductDashboard(
|
||||
externalProductId: string,
|
||||
range: DateRange,
|
||||
): Promise<ProductDashboard> {
|
||||
const product = await this.products.findByExternalProductId(externalProductId);
|
||||
if (!product) throw new NotFoundError('Product not found.');
|
||||
|
||||
const [supportVolume, problemsByCategory, aiResolvedCount, humanEscalatedCount, topErrors] =
|
||||
await Promise.all([
|
||||
this.productReports.supportVolume(product.id, range),
|
||||
this.productReports.problemsByCategory(product.id, range),
|
||||
this.productReports.countResolutionsBy(product.id, range, true),
|
||||
this.productReports.countEverEscalatedToHuman(product.id, range),
|
||||
this.errorCodes.getTopErrorCodesForProduct(
|
||||
product.id,
|
||||
range.from,
|
||||
range.to,
|
||||
reportingConfig.topNLimit,
|
||||
),
|
||||
]);
|
||||
|
||||
const recurringProblems = [...problemsByCategory]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, reportingConfig.topNLimit);
|
||||
|
||||
return {
|
||||
productId: externalProductId,
|
||||
range: serializeDateRange(range),
|
||||
supportVolume,
|
||||
problemsByCategory,
|
||||
recurringProblems,
|
||||
aiResolutionRate: computeRate(aiResolvedCount, supportVolume),
|
||||
humanEscalationRate: computeRate(humanEscalatedCount, supportVolume),
|
||||
topErrors,
|
||||
};
|
||||
}
|
||||
|
||||
async getSupportDashboard(range: DateRange): Promise<SupportDashboard> {
|
||||
const [
|
||||
workloadByAgent,
|
||||
slaAtRisk,
|
||||
slaBreached,
|
||||
escalationCount,
|
||||
responseDurations,
|
||||
resolutionDurations,
|
||||
] = await Promise.all([
|
||||
this.support.workloadByAgent(),
|
||||
this.support.slaAtRisk(reportingConfig.slaRiskThresholdMinutes),
|
||||
this.support.slaBreached(),
|
||||
this.support.escalationCount(range),
|
||||
this.shared.firstResponseDurationsMs(range),
|
||||
this.shared.resolutionDurationsMs(range),
|
||||
]);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
range: serializeDateRange(range),
|
||||
workloadByAgent,
|
||||
slaAtRisk,
|
||||
slaBreached,
|
||||
escalationCount,
|
||||
averageResponseSeconds: computeAverageSeconds(responseDurations),
|
||||
averageResolutionSeconds: computeAverageSeconds(resolutionDurations),
|
||||
};
|
||||
}
|
||||
|
||||
async getAiDashboard(range: DateRange): Promise<AiDashboard> {
|
||||
const [outcomeCounts, escalatedWithAttempts, knowledgeMatches, confidences, toolCounts] =
|
||||
await Promise.all([
|
||||
this.ai.sessionOutcomeCounts(range),
|
||||
this.ai.escalatedSessionsWithToolAttempts(range),
|
||||
this.ai.sessionsWithKnowledgeMatch(range),
|
||||
this.ai.diagnosisConfidences(range),
|
||||
this.ai.toolInvocationOutcomeCounts(range),
|
||||
]);
|
||||
|
||||
const confidenceDistribution = { proceed: 0, ask: 0, escalate: 0 };
|
||||
for (const confidence of confidences) {
|
||||
const band = decideConfidenceBand(confidence, {
|
||||
highThreshold: aiConfig.defaultHighConfidence,
|
||||
lowThreshold: aiConfig.defaultLowConfidence,
|
||||
});
|
||||
confidenceDistribution[band] += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
range: serializeDateRange(range),
|
||||
totalSessions: outcomeCounts.total,
|
||||
aiResolutionRate: computeRate(outcomeCounts.resolved, outcomeCounts.total),
|
||||
humanHandoffRate: computeRate(outcomeCounts.escalated, outcomeCounts.total),
|
||||
failedTroubleshootingEscalationRate: computeRate(
|
||||
escalatedWithAttempts,
|
||||
outcomeCounts.escalated,
|
||||
),
|
||||
knowledgeMatchRate: computeRate(knowledgeMatches, outcomeCounts.total),
|
||||
confidenceDistribution,
|
||||
toolInvocations: toolCounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const reportsService = new ReportsService();
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { assignmentEngine } from '@/modules/orchestration/assignments';
|
||||
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 1 / FR-001 / SC-001: two or more concurrent
|
||||
* assignment attempts on the same ticket must never leave more than one Assignment row marked
|
||||
* `isCurrent`. Before research.md §1's fix (a Postgres partial unique index on
|
||||
* `assignments(ticketId) WHERE isCurrent = true`, plus a bounded retry in
|
||||
* AssignmentRepository.createAssignment), Postgres's default READ COMMITTED isolation let two
|
||||
* concurrent `assignmentEngine.assignToSpecificNode` calls each see "nothing current to
|
||||
* supersede" and both successfully create their own `isCurrent: true` row.
|
||||
*/
|
||||
describe('Assignment double-assignment race (User Story 1)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_ASSIGN_RACE_PROD_${Date.now()}`;
|
||||
const skillTag = `assign_race_skill_${Date.now()}`;
|
||||
let productId: string;
|
||||
let teamId: string;
|
||||
const agentIds: string[] = [];
|
||||
let nodeId: string;
|
||||
let secret: string;
|
||||
const createdTicketIds: string[] = [];
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Assignment race test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
const ticketId = created.json().data.ticketId as string;
|
||||
createdTicketIds.push(ticketId);
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Assignment Race Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `Assign Race Team ${Date.now()}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
|
||||
// Several eligible agents so a race has real agents to (incorrectly) double-assign across,
|
||||
// not just one candidate every attempt would trivially agree on.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const agent = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `Assign Race Agent ${i}` },
|
||||
});
|
||||
const agentId = agent.json().data.id;
|
||||
agentIds.push(agentId);
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentId}/skills/${skillTag}`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { level: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
const node = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
headers: authHeader(authToken),
|
||||
payload: {
|
||||
name: 'Assign Race Node',
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
nodeId = node.json().data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const ticketFilter = { ticketId: { in: createdTicketIds } };
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.assignment.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.hierarchyNode.deleteMany({ where: { id: nodeId } });
|
||||
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: agentIds } } });
|
||||
await prismaClient.agent.deleteMany({ where: { teamId } });
|
||||
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('leaves exactly one current assignment after 20 genuinely concurrent assignment attempts', async () => {
|
||||
const ticketId = await createTicket();
|
||||
|
||||
const attempts = 20;
|
||||
await Promise.all(
|
||||
Array.from({ length: attempts }, () =>
|
||||
assignmentEngine.assignToSpecificNode(ticketId, nodeId, 'system', 'concurrency test'),
|
||||
),
|
||||
);
|
||||
|
||||
const currentAssignments = await prismaClient.assignment.findMany({
|
||||
where: { ticketId, isCurrent: true },
|
||||
});
|
||||
expect(currentAssignments.length).toBe(1);
|
||||
|
||||
// Every attempt was still recorded (superseded or current) — the race must not have
|
||||
// silently dropped attempts, only converged them onto a single current row.
|
||||
const allAssignments = await prismaClient.assignment.findMany({ where: { ticketId } });
|
||||
expect(allAssignments.length).toBe(attempts);
|
||||
});
|
||||
|
||||
it(
|
||||
'holds consistently across 10 repeated runs (SC-001: zero exceptions)',
|
||||
async () => {
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const ticketId = await createTicket();
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
assignmentEngine.assignToSpecificNode(ticketId, nodeId, 'system', `run ${run}`),
|
||||
),
|
||||
);
|
||||
|
||||
const currentAssignments = await prismaClient.assignment.findMany({
|
||||
where: { ticketId, isCurrent: true },
|
||||
});
|
||||
expect(currentAssignments.length).toBe(1);
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { escalationService } from '@/modules/orchestration/escalation';
|
||||
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 3 / FR-003 / SC-003: the same escalation
|
||||
* trigger delivered more than once for the same ticket (e.g. two overlapping breach-sweep
|
||||
* passes, or a re-delivered job) must result in exactly one EscalationEvent and exactly one
|
||||
* resulting reassignment — never two. Before research.md §3's fix (the
|
||||
* `escalation_events_ticket_rule_unique` partial unique index plus
|
||||
* EscalationEventRepository.create's catch-and-absorb), `EscalationService.fire` unconditionally
|
||||
* created a new event and reassigned on every call, with no dedup mechanism at all.
|
||||
*/
|
||||
describe('Escalation idempotency (User Story 3)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_ESCALATION_IDEMPOTENCY_PROD_${Date.now()}`;
|
||||
const skillTag = `escalation_idempotency_skill_${Date.now()}`;
|
||||
let productId: string;
|
||||
let teamId: string;
|
||||
let agentId: string;
|
||||
let nodeId: string;
|
||||
let policyId: string;
|
||||
let escalationPolicyId: string;
|
||||
let secret: string;
|
||||
const createdTicketIds: string[] = [];
|
||||
|
||||
async function createTicketAndAssign(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Escalation idempotency test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
const ticketId = created.json().data.ticketId as string;
|
||||
createdTicketIds.push(ticketId);
|
||||
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
|
||||
// Force the run overdue — the concrete condition handleBreach fires for in production, via
|
||||
// the breach sweep.
|
||||
await prismaClient.sLARun.update({
|
||||
where: { ticketId },
|
||||
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Escalation Idempotency Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `Escalation Idempotency Team ${Date.now()}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
|
||||
const agent = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'Escalation Idempotency Agent' },
|
||||
});
|
||||
agentId = agent.json().data.id;
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentId}/skills/${skillTag}`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { level: 3 },
|
||||
});
|
||||
|
||||
const node = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
headers: authHeader(authToken),
|
||||
payload: {
|
||||
name: 'Escalation Idempotency Node',
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
nodeId = node.json().data.id;
|
||||
|
||||
const policy = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sla-policies',
|
||||
headers: authHeader(authToken),
|
||||
payload: {
|
||||
name: 'Escalation Idempotency SLA Policy',
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 240,
|
||||
},
|
||||
});
|
||||
policyId = policy.json().data.id;
|
||||
|
||||
const escPolicy = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/escalation-policies',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'Escalation Idempotency Policy', productId },
|
||||
});
|
||||
escalationPolicyId = escPolicy.json().data.id;
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/escalation-policies/${escalationPolicyId}/rules`,
|
||||
headers: authHeader(authToken),
|
||||
payload: {
|
||||
triggerType: 'resolution_breach',
|
||||
condition: {},
|
||||
targetNodeId: nodeId,
|
||||
notify: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const ticketFilter = { ticketId: { in: createdTicketIds } };
|
||||
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: nodeId } });
|
||||
await prismaClient.escalationPolicy.deleteMany({ where: { id: escalationPolicyId } });
|
||||
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.sLAPolicy.deleteMany({ where: { id: policyId } });
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.assignment.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.hierarchyNode.deleteMany({ where: { id: nodeId } });
|
||||
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
|
||||
await prismaClient.agent.deleteMany({ where: { teamId } });
|
||||
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('results in exactly one escalation event and one assignment when the same trigger fires twice concurrently', async () => {
|
||||
const ticketId = await createTicketAndAssign();
|
||||
|
||||
await Promise.all([
|
||||
escalationService.handleBreach(ticketId, 'resolution_breach'),
|
||||
escalationService.handleBreach(ticketId, 'resolution_breach'),
|
||||
]);
|
||||
|
||||
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
|
||||
expect(events.length).toBe(1);
|
||||
|
||||
const currentAssignments = await prismaClient.assignment.findMany({
|
||||
where: { ticketId, isCurrent: true },
|
||||
});
|
||||
expect(currentAssignments.length).toBe(1);
|
||||
});
|
||||
|
||||
it(
|
||||
'holds consistently across 10 repeated runs (SC-003: zero duplicate outcomes)',
|
||||
async () => {
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const ticketId = await createTicketAndAssign();
|
||||
|
||||
await Promise.all([
|
||||
escalationService.handleBreach(ticketId, 'resolution_breach'),
|
||||
escalationService.handleBreach(ticketId, 'resolution_breach'),
|
||||
escalationService.handleBreach(ticketId, 'resolution_breach'),
|
||||
]);
|
||||
|
||||
const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } });
|
||||
expect(events.length).toBe(1);
|
||||
|
||||
const currentAssignments = await prismaClient.assignment.findMany({
|
||||
where: { ticketId, isCurrent: true },
|
||||
});
|
||||
expect(currentAssignments.length).toBe(1);
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { slaService } from '@/modules/orchestration/sla';
|
||||
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 2 / FR-002 / SC-002: concurrent pause, resume,
|
||||
* and breach-sweep activity against the same SLA run must always leave it in one
|
||||
* internally-consistent, auditable state — never a state with contradictory fields (paused with
|
||||
* no pause timestamp, or a legitimately breached run silently reverted to running by a racing
|
||||
* resume). Before research.md §2's fix (SLARun.version + SlaRunRepository.updateWithVersion),
|
||||
* SlaService.pause/resume/complete/runBreachDetectionSweep each did a plain read-then-write with
|
||||
* no guard, so two racing calls could clobber each other's writes.
|
||||
*/
|
||||
describe('SLA pause/resume/sweep race (User Story 2)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_SLA_RACE_PROD_${Date.now()}`;
|
||||
const skillTag = `sla_race_skill_${Date.now()}`;
|
||||
let productId: string;
|
||||
let teamId: string;
|
||||
let agentId: string;
|
||||
let nodeId: string;
|
||||
let policyId: string;
|
||||
let secret: string;
|
||||
const createdTicketIds: string[] = [];
|
||||
|
||||
async function createTicketAndAssign(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `SLA race test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
const ticketId = created.json().data.ticketId as string;
|
||||
createdTicketIds.push(ticketId);
|
||||
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'SLA Race Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `SLA Race Team ${Date.now()}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
|
||||
const agent = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'SLA Race Agent' },
|
||||
});
|
||||
agentId = agent.json().data.id;
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentId}/skills/${skillTag}`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { level: 3 },
|
||||
});
|
||||
|
||||
const node = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
headers: authHeader(authToken),
|
||||
payload: {
|
||||
name: 'SLA Race Node',
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
nodeId = node.json().data.id;
|
||||
|
||||
const policy = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sla-policies',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'SLA Race Policy', productId, firstResponseMinutes: 30, resolutionMinutes: 240 },
|
||||
});
|
||||
policyId = policy.json().data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const ticketFilter = { ticketId: { in: createdTicketIds } };
|
||||
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.sLAPolicy.deleteMany({ where: { id: policyId } });
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.assignment.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.hierarchyNode.deleteMany({ where: { id: nodeId } });
|
||||
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
|
||||
await prismaClient.agent.deleteMany({ where: { teamId } });
|
||||
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function assertInternallyConsistent(run: {
|
||||
status: string;
|
||||
pausedAt: Date | null;
|
||||
resumedAt: Date | null;
|
||||
breachedAt: Date | null;
|
||||
}) {
|
||||
if (run.status === 'paused') {
|
||||
expect(run.pausedAt).not.toBeNull();
|
||||
} else {
|
||||
expect(run.pausedAt).toBeNull();
|
||||
}
|
||||
// A run the sweep has genuinely marked breached must never be silently reverted to running
|
||||
// by a racing resume — status and breachedAt must agree with each other.
|
||||
if (run.status === 'breached') {
|
||||
expect(run.breachedAt).not.toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
it(
|
||||
'leaves an internally-consistent final state under concurrent pause/resume/sweep',
|
||||
async () => {
|
||||
const ticketId = await createTicketAndAssign();
|
||||
|
||||
// Force the run's resolution due date into the past so the breach sweep genuinely has
|
||||
// something real to detect concurrently with pause/resume, not a no-op query.
|
||||
await prismaClient.sLARun.update({
|
||||
where: { ticketId },
|
||||
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
slaService.pause(ticketId),
|
||||
slaService.resume(ticketId),
|
||||
slaService.runBreachDetectionSweep(),
|
||||
slaService.pause(ticketId),
|
||||
slaService.resume(ticketId),
|
||||
]);
|
||||
|
||||
const run = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
|
||||
assertInternallyConsistent(run);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
it(
|
||||
'holds consistently across 10 repeated runs (SC-002: zero contradictory-state outcomes)',
|
||||
async () => {
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const ticketId = await createTicketAndAssign();
|
||||
await prismaClient.sLARun.update({
|
||||
where: { ticketId },
|
||||
data: { resolutionDueAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
slaService.pause(ticketId),
|
||||
slaService.resume(ticketId),
|
||||
slaService.runBreachDetectionSweep(),
|
||||
]);
|
||||
|
||||
const finalRun = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
|
||||
assertInternallyConsistent(finalRun);
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { ticketsRepository } from '@/modules/ticketing/tickets';
|
||||
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 4 / FR-004 / SC-004: proves — rather than
|
||||
* assumes — that 003-ticketing's own optimistic-concurrency guarantee
|
||||
* (TicketsRepository.updateStatus's atomic `updateMany({where:{id, version: expectedVersion}})`)
|
||||
* actually holds under genuinely concurrent requests, not just the sequential checks that
|
||||
* existed before this feature. research.md §4: no implementation change is expected here — this
|
||||
* is a real Postgres, real concurrency proof of an already-sound mechanism.
|
||||
*/
|
||||
describe('Ticket status optimistic concurrency (User Story 4)', () => {
|
||||
let app: FastifyInstance;
|
||||
const externalProductId = `TEST_TICKET_STATUS_RACE_PROD_${Date.now()}`;
|
||||
let productId: string;
|
||||
let secret: string;
|
||||
const createdTicketIds: string[] = [];
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Ticket status race test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
const ticketId = created.json().data.ticketId as string;
|
||||
createdTicketIds.push(ticketId);
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Ticket Status Race Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticketId: { in: createdTicketIds } } });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('exactly one of 20 concurrent status updates from the same version succeeds', async () => {
|
||||
const ticketId = await createTicket();
|
||||
const original = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
expect(original.status).toBe('NEW');
|
||||
|
||||
const attempts = 20;
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: attempts }, () =>
|
||||
ticketsRepository.updateStatus(ticketId, 'AI_ANALYZING', original.version),
|
||||
),
|
||||
);
|
||||
|
||||
const successes = results.filter((r) => r !== null);
|
||||
const failures = results.filter((r) => r === null);
|
||||
expect(successes.length).toBe(1);
|
||||
expect(failures.length).toBe(attempts - 1);
|
||||
|
||||
const finalTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
expect(finalTicket.status).toBe('AI_ANALYZING');
|
||||
expect(finalTicket.version).toBe(original.version + 1);
|
||||
});
|
||||
|
||||
it(
|
||||
'holds consistently across 10 repeated runs (SC-004)',
|
||||
async () => {
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const ticketId = await createTicket();
|
||||
const original = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
ticketsRepository.updateStatus(ticketId, 'AI_ANALYZING', original.version),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r !== null).length).toBe(1);
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -21,6 +21,9 @@ describe('Error codes and known issues', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.knownIssue.deleteMany({ where: { product: { externalProductId } } });
|
||||
// 015-reporting-dashboards: findKnownIssuesByErrorCode now also writes a durable
|
||||
// ErrorCodeLookup row (RESTRICT FK to ErrorCode) — must be deleted before ErrorCode itself.
|
||||
await prismaClient.errorCodeLookup.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.errorCode.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.product.deleteMany({ where: { externalProductId } });
|
||||
await app.close();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import {
|
||||
sessionRepository,
|
||||
diagnosisRepository,
|
||||
knowledgeReferenceRepository,
|
||||
} from '@/modules/ai-support/sessions';
|
||||
import { actionRepository } from '@/modules/ai-support/tools';
|
||||
import { aiConfig } from '@/config';
|
||||
|
||||
/** Covers specs/015-reporting-dashboards/quickstart.md Scenario 4 against a real Postgres/Redis. */
|
||||
describe('AI dashboard (User Story 4)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_AI_REPORT_PROD_${Date.now()}`;
|
||||
let secret: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'AI Report Test Product', status: 'active' },
|
||||
});
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `AI report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
it('reflects real session outcomes, tool results, and confidence bands', async () => {
|
||||
// A resolved session, with a knowledge match and a successful tool call.
|
||||
const resolvedTicketId = await createTicket();
|
||||
const resolvedSession = await sessionRepository.create(resolvedTicketId);
|
||||
await knowledgeReferenceRepository.recordMany(resolvedSession.id, ['fake-knowledge-id']);
|
||||
await diagnosisRepository.create({
|
||||
sessionId: resolvedSession.id,
|
||||
product: 'test-product',
|
||||
problemType: 'test-problem',
|
||||
severity: 'medium',
|
||||
confidence: aiConfig.defaultHighConfidence,
|
||||
possibleCauses: ['test cause'],
|
||||
});
|
||||
const successAction = await actionRepository.create({
|
||||
sessionId: resolvedSession.id,
|
||||
toolName: 'getTicketSnapshot',
|
||||
input: {},
|
||||
riskLevel: 'low',
|
||||
evaluationOutcome: 'approved',
|
||||
});
|
||||
await actionRepository.createResult(successAction.id, { ok: true }, 'success');
|
||||
await sessionRepository.updateStatus(resolvedSession.id, 'resolved');
|
||||
|
||||
// An escalated session, with a failed tool call and a low-confidence diagnosis.
|
||||
const escalatedTicketId = await createTicket();
|
||||
const escalatedSession = await sessionRepository.create(escalatedTicketId);
|
||||
await diagnosisRepository.create({
|
||||
sessionId: escalatedSession.id,
|
||||
product: 'test-product',
|
||||
problemType: 'test-problem',
|
||||
severity: 'high',
|
||||
confidence: aiConfig.defaultLowConfidence - 0.05,
|
||||
possibleCauses: ['test cause'],
|
||||
});
|
||||
const failedAction = await actionRepository.create({
|
||||
sessionId: escalatedSession.id,
|
||||
toolName: 'getTicketSnapshot',
|
||||
input: {},
|
||||
riskLevel: 'low',
|
||||
evaluationOutcome: 'approved',
|
||||
});
|
||||
await actionRepository.createResult(failedAction.id, { error: 'boom' }, 'failed');
|
||||
await sessionRepository.updateStatus(escalatedSession.id, 'escalated');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/ai?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalSessions).toBeGreaterThanOrEqual(2);
|
||||
expect(data.aiResolutionRate).not.toBeNull();
|
||||
expect(data.humanHandoffRate).not.toBeNull();
|
||||
expect(data.knowledgeMatchRate).not.toBeNull();
|
||||
expect(data.confidenceDistribution.proceed).toBeGreaterThanOrEqual(1);
|
||||
expect(data.confidenceDistribution.escalate).toBeGreaterThanOrEqual(1);
|
||||
expect(data.toolInvocations.success).toBeGreaterThanOrEqual(1);
|
||||
expect(data.toolInvocations.failed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('returns null rates and zero counts for a range with no AI activity', async () => {
|
||||
const farPastFrom = new Date('2000-01-01').toISOString();
|
||||
const farPastTo = new Date('2000-01-02').toISOString();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/ai?from=${farPastFrom}&to=${farPastTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalSessions).toBe(0);
|
||||
expect(data.aiResolutionRate).toBeNull();
|
||||
expect(data.humanHandoffRate).toBeNull();
|
||||
expect(data.knowledgeMatchRate).toBeNull();
|
||||
expect(data.confidenceDistribution).toEqual({ proceed: 0, ask: 0, escalate: 0 });
|
||||
expect(data.toolInvocations).toEqual({ success: 0, failed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { ticketsRepository } from '@/modules/ticketing/tickets';
|
||||
import { messagesService } from '@/modules/ticketing/messages';
|
||||
import { resolutionRepository } from '@/modules/problem-management/resolutions';
|
||||
|
||||
/**
|
||||
* Covers specs/015-reporting-dashboards/quickstart.md Scenario 1 against a real Postgres/Redis.
|
||||
* Drives ticket-status transitions directly through ticketsRepository (not ticketsService) to
|
||||
* avoid publishing TICKET_UPDATED — this test only needs the raw persisted state its own
|
||||
* aggregation queries read, and publishing real domain events here risks the same kind of
|
||||
* cross-file contamination 014-full-observability's own business-metrics.test.ts found and fixed
|
||||
* (an unscoped HUMAN_ESCALATION triggering real auto-assignment against the shared agent pool).
|
||||
*/
|
||||
describe('Management dashboard (User Story 1)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_MGMT_REPORT_PROD_${Date.now()}`;
|
||||
let productId: string;
|
||||
let secret: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Management Report Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Management report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
async function driveDirectly(ticketId: string, statuses: string[]): Promise<void> {
|
||||
let ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
for (const status of statuses) {
|
||||
const updated = await ticketsRepository.updateStatus(ticketId, status, ticket.version);
|
||||
if (!updated) throw new Error(`Failed to transition ticket to ${status} in test setup.`);
|
||||
ticket = updated;
|
||||
}
|
||||
}
|
||||
|
||||
it('reports real figures matching the actual created data', async () => {
|
||||
// AI-resolved ticket.
|
||||
const aiTicketId = await createTicket();
|
||||
await driveDirectly(aiTicketId, [
|
||||
'AI_ANALYZING',
|
||||
'AI_TROUBLESHOOTING',
|
||||
'AI_VERIFYING',
|
||||
'AI_RESOLVED',
|
||||
]);
|
||||
await resolutionRepository.create({ ticketId: aiTicketId, outcome: 'fixed', resolvedBy: 'ai' });
|
||||
await driveDirectly(aiTicketId, ['RESOLVED']);
|
||||
|
||||
// Human-resolved ticket, with a first agent response recorded and a real Assignment row —
|
||||
// "ever escalated to a human" is keyed off Assignment existence (see
|
||||
// ManagementRepository.countEverEscalatedToHuman's own comment on why a ticket's current
|
||||
// status can't distinguish the AI path from the human path once both converge on the same
|
||||
// shared terminal statuses).
|
||||
const team = await prismaClient.team.create({ data: { name: `Mgmt Report Team ${Date.now()}` } });
|
||||
const agent = await prismaClient.agent.create({ data: { teamId: team.id, name: 'Mgmt Report Agent' } });
|
||||
const humanTicketId = await createTicket();
|
||||
await prismaClient.assignment.create({
|
||||
data: { ticketId: humanTicketId, agentId: agent.id, strategy: 'MANUAL', isCurrent: true },
|
||||
});
|
||||
await messagesService.post(humanTicketId, 'agent-1', 'AGENT_MESSAGE', 'Looking into this.');
|
||||
await driveDirectly(humanTicketId, [
|
||||
'HUMAN_ESCALATION',
|
||||
'IN_PROGRESS',
|
||||
'RESOLUTION_PENDING_CUSTOMER',
|
||||
]);
|
||||
await resolutionRepository.create({
|
||||
ticketId: humanTicketId,
|
||||
outcome: 'fixed',
|
||||
resolvedBy: 'agent-1',
|
||||
});
|
||||
await driveDirectly(humanTicketId, ['RESOLVED']);
|
||||
|
||||
// Still-open ticket.
|
||||
await createTicket();
|
||||
|
||||
// SLA policy + one met, one breached run.
|
||||
const policy = await prismaClient.sLAPolicy.create({
|
||||
data: {
|
||||
name: `Mgmt Report Policy ${Date.now()}`,
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 240,
|
||||
},
|
||||
});
|
||||
const metTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: metTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() + 240 * 60_000),
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const breachedTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: breachedTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() - 60_000),
|
||||
status: 'breached',
|
||||
breachedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// One escalation event.
|
||||
const escalatedTicketId = await createTicket();
|
||||
await prismaClient.escalationEvent.create({
|
||||
data: {
|
||||
ticketId: escalatedTicketId,
|
||||
ruleId: null,
|
||||
fromNodeId: null,
|
||||
toNodeId: null,
|
||||
reason: 'management dashboard test',
|
||||
triggeredBy: 'system',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalCases).toBeGreaterThanOrEqual(6);
|
||||
expect(data.aiResolved).toBeGreaterThanOrEqual(1);
|
||||
expect(data.humanEscalated).toBeGreaterThanOrEqual(1);
|
||||
expect(data.resolved).toBeGreaterThanOrEqual(2);
|
||||
expect(data.open).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.met).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.breached).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.rate).not.toBeNull();
|
||||
expect(data.escalationCount).toBeGreaterThanOrEqual(1);
|
||||
expect(data.averageResponseSeconds).not.toBeNull();
|
||||
expect(data.averageResolutionSeconds).not.toBeNull();
|
||||
expect(data.range.from).toBeTruthy();
|
||||
expect(data.range.to).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns all-zero counts and all-null rates for a range with no activity', async () => {
|
||||
const farPastFrom = new Date('2000-01-01').toISOString();
|
||||
const farPastTo = new Date('2000-01-02').toISOString();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${farPastFrom}&to=${farPastTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalCases).toBe(0);
|
||||
expect(data.aiResolved).toBe(0);
|
||||
expect(data.humanEscalated).toBe(0);
|
||||
expect(data.resolved).toBe(0);
|
||||
expect(data.open).toBe(0);
|
||||
expect(data.slaCompliance.rate).toBeNull();
|
||||
expect(data.averageResponseSeconds).toBeNull();
|
||||
expect(data.averageResolutionSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a range where from is after to', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${rangeTo}&to=${rangeFrom}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { errorCodesService } from '@/modules/ai-support/knowledge';
|
||||
|
||||
/** Covers specs/015-reporting-dashboards/quickstart.md Scenario 2 against a real Postgres/Redis. */
|
||||
describe('Product dashboard (User Story 2)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
async function setUpProduct(nameSuffix: string) {
|
||||
const externalProductId = `TEST_PRODUCT_REPORT_${nameSuffix}_${Date.now()}`;
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: `Product Report ${nameSuffix}`, status: 'active' },
|
||||
});
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
return { externalProductId, productId: product.id, secret };
|
||||
}
|
||||
|
||||
async function createTicket(externalProductId: string, secret: string): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Product report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("scopes every figure to the requested product, never another product's data", async () => {
|
||||
const productA = await setUpProduct('A');
|
||||
const productB = await setUpProduct('B');
|
||||
|
||||
await createTicket(productA.externalProductId, productA.secret);
|
||||
await createTicket(productA.externalProductId, productA.secret);
|
||||
await createTicket(productB.externalProductId, productB.secret);
|
||||
|
||||
const resA = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${productA.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(resA.statusCode).toBe(200);
|
||||
expect(resA.json().data.supportVolume).toBe(2);
|
||||
|
||||
const resB = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${productB.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(resB.statusCode).toBe(200);
|
||||
expect(resB.json().data.supportVolume).toBe(1);
|
||||
});
|
||||
|
||||
it('ranks the most-frequently-looked-up error code first', async () => {
|
||||
const product = await setUpProduct('ERR');
|
||||
const popularCode = `POPULAR-${Date.now()}`;
|
||||
const rareCode = `RARE-${Date.now()}`;
|
||||
await errorCodesService.createErrorCode(product.productId, popularCode, 'Popular error');
|
||||
await errorCodesService.createErrorCode(product.productId, rareCode, 'Rare error');
|
||||
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, rareCode);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${product.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const topErrors = res.json().data.topErrors as Array<{ code: string; count: number }>;
|
||||
expect(topErrors[0]).toMatchObject({ code: popularCode, count: 3 });
|
||||
expect(topErrors.find((e) => e.code === rareCode)).toMatchObject({ count: 1 });
|
||||
});
|
||||
|
||||
it('404s for an unknown product', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/NONEXISTENT_PRODUCT_${Date.now()}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { reportingConfig } from '@/config';
|
||||
|
||||
/**
|
||||
* Covers specs/015-reporting-dashboards/quickstart.md Scenario 3 against a real Postgres/Redis.
|
||||
* Assignment rows are created directly via Prisma (not through a real HUMAN_ESCALATION +
|
||||
* default-strategy auto-assignment) — the same contamination avoidance
|
||||
* management-dashboard.test.ts already documents: this test only needs the persisted
|
||||
* Assignment/SLARun state its own aggregation queries read, not a live orchestration run.
|
||||
*/
|
||||
describe('Support dashboard (User Story 3)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_SUPPORT_REPORT_PROD_${Date.now()}`;
|
||||
let productId: string;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Support Report Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Support report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
it("reflects each agent's real current assignment workload", async () => {
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `Support Report Team ${Date.now()}` },
|
||||
});
|
||||
const teamId = team.json().data.id as string;
|
||||
const agent = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'Support Report Agent' },
|
||||
});
|
||||
const agentId = agent.json().data.id as string;
|
||||
|
||||
const ticket1 = await createTicket();
|
||||
const ticket2 = await createTicket();
|
||||
await prismaClient.assignment.createMany({
|
||||
data: [
|
||||
{ ticketId: ticket1, agentId, strategy: 'MANUAL', isCurrent: true },
|
||||
{ ticketId: ticket2, agentId, strategy: 'MANUAL', isCurrent: true },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/reports/support',
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const workload = res.json().data.workloadByAgent as Array<{
|
||||
agentId: string;
|
||||
openAssignments: number;
|
||||
}>;
|
||||
expect(workload.find((w) => w.agentId === agentId)).toMatchObject({ openAssignments: 2 });
|
||||
});
|
||||
|
||||
it('counts a near-due SLA run as at-risk, distinct from breached', async () => {
|
||||
const policy = await prismaClient.sLAPolicy.create({
|
||||
data: {
|
||||
name: `Support Risk Policy ${Date.now()}`,
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 240,
|
||||
},
|
||||
});
|
||||
|
||||
const riskTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: riskTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(
|
||||
Date.now() + (reportingConfig.slaRiskThresholdMinutes - 1) * 60_000,
|
||||
),
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
const safeTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: safeTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() + 999 * 60_000),
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/reports/support',
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.slaAtRisk).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 5 / FR-007: load/throughput baseline for the
|
||||
* 015-reporting-dashboards admin endpoints — pure database aggregation reads, no third-party
|
||||
* cost (unlike ai-support-flow.load.ts). Run against a real running dev server:
|
||||
* `npx tsx tests/load/admin-reporting.load.ts`.
|
||||
*
|
||||
* Signs in as the project's own seeded admin account once (a session JWT is reusable across
|
||||
* requests, unlike 002-saas-integration's single-use integration tokens), then cycles across
|
||||
* all four dashboards so the report reflects a realistic mix of the endpoint group, not just one
|
||||
* route.
|
||||
*/
|
||||
import { runLoadTest } from './autocannon.config';
|
||||
|
||||
const API_URL = process.env.LOAD_TEST_API_URL ?? 'http://localhost:4501';
|
||||
const CONNECTIONS = Number(process.env.LOAD_TEST_CONNECTIONS ?? 10);
|
||||
const DURATION_SEC = Number(process.env.LOAD_TEST_DURATION_SEC ?? 10);
|
||||
const ADMIN_EMAIL = process.env.LOAD_TEST_ADMIN_EMAIL ?? 'admin@supporthub.internal';
|
||||
const ADMIN_PASSWORD = process.env.LOAD_TEST_ADMIN_PASSWORD ?? 'ChangeMe123!';
|
||||
|
||||
const DASHBOARD_PATHS = [
|
||||
'/admin/reports/management',
|
||||
'/admin/reports/support',
|
||||
'/admin/reports/ai',
|
||||
];
|
||||
|
||||
async function signIn(): Promise<string> {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Admin sign-in failed (${response.status}) — set LOAD_TEST_ADMIN_EMAIL/PASSWORD if the seeded admin credentials differ.`,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as { data: { token: string } };
|
||||
return body.data.token;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const token = await signIn();
|
||||
|
||||
let requestIndex = 0;
|
||||
await runLoadTest('admin-reporting', {
|
||||
url: API_URL,
|
||||
connections: CONNECTIONS,
|
||||
duration: DURATION_SEC,
|
||||
requests: [
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
setupRequest: (request) => {
|
||||
request.path = DASHBOARD_PATHS[requestIndex % DASHBOARD_PATHS.length];
|
||||
requestIndex += 1;
|
||||
return request;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 5 / FR-007: load/throughput baseline for the AI
|
||||
* support flow's customer-reply turn (`POST /tickets/:ticketId/ai-session/messages`).
|
||||
*
|
||||
* IMPORTANT — real cost: 005-ai-support calls the real Anthropic API for every diagnosis turn
|
||||
* (via @anthropic-ai/sdk), both when a ticket's first AI turn runs (asynchronously, right after
|
||||
* ticket creation) AND for every customer-reply turn this script sends. Running this script
|
||||
* fires genuine, billed Claude API calls — it is NOT a free, purely-internal load test like
|
||||
* ticket-creation.load.ts's own database-only path. Confirm the intended request volume
|
||||
* (LOAD_TEST_REQUESTS below) with whoever owns the Anthropic billing before running this against
|
||||
* anything but a small smoke-sized amount.
|
||||
*
|
||||
* Observed in practice: a synthetic problem string with no matching entry in this throwaway
|
||||
* product's (empty) knowledge base often escalates on the very first AI turn — there is nothing
|
||||
* for the model to diagnose confidently. That is still a real, honestly-measured code path (a
|
||||
* fast 404 from `handleCustomerReply`'s "no active session" guard), not a script bug — this
|
||||
* script measures whatever the endpoint actually does, rather than forcing every session to
|
||||
* stay open by only ever picking already-active ones.
|
||||
*
|
||||
* Run against a real running dev server: `npx tsx tests/load/ai-support-flow.load.ts`.
|
||||
* `LOAD_TEST_REQUESTS` (default 5) hard-caps the total number of real API-consuming requests —
|
||||
* this script uses autocannon's `amount` option, never an open-ended `duration`, specifically to
|
||||
* keep the real-money cost bounded and predictable.
|
||||
*/
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { runLoadTest } from './autocannon.config';
|
||||
|
||||
const API_URL = process.env.LOAD_TEST_API_URL ?? 'http://localhost:4501';
|
||||
const CONNECTIONS = Number(process.env.LOAD_TEST_CONNECTIONS ?? 1);
|
||||
const REQUESTS = Number(process.env.LOAD_TEST_REQUESTS ?? 5);
|
||||
const SESSION_POLL_TIMEOUT_MS = 30_000;
|
||||
const SESSION_POLL_INTERVAL_MS = 500;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** The AI_SESSION worker creates the session asynchronously after ticket creation (FR-001,
|
||||
* session.service.ts's own runFirstTurn docstring) — poll until SOME session row exists (any
|
||||
* status) rather than assuming it's ready the instant the create-ticket request returns.
|
||||
* Deliberately not restricted to the "active" statuses: this throwaway product's problems have
|
||||
* no matching knowledge, so the very first turn commonly escalates immediately, which is a
|
||||
* legitimate terminal outcome to measure, not a wait condition. */
|
||||
async function waitForAnySession(ticketId: string): Promise<void> {
|
||||
const deadline = Date.now() + SESSION_POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const session = await prismaClient.aISupportSession.findFirst({ where: { ticketId } });
|
||||
if (session) return;
|
||||
await sleep(SESSION_POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(`Timed out waiting for an AI session to be created on ticket ${ticketId}.`);
|
||||
}
|
||||
|
||||
async function cleanup(productId: string): Promise<void> {
|
||||
// AI-support rows form a deeper chain than a plain ticket (session -> diagnosis/interaction/
|
||||
// action/knowledge-reference), all RESTRICT-constrained back to the ticket — every level must
|
||||
// be cleared before the ticket itself can be deleted.
|
||||
const sessions = await prismaClient.aISupportSession.findMany({
|
||||
where: { ticket: { productId } },
|
||||
select: { id: true },
|
||||
});
|
||||
const sessionIds = sessions.map((s) => s.id);
|
||||
await prismaClient.aIActionResult.deleteMany({
|
||||
where: { action: { sessionId: { in: sessionIds } } },
|
||||
});
|
||||
await prismaClient.aIAction.deleteMany({ where: { sessionId: { in: sessionIds } } });
|
||||
await prismaClient.aIDiagnosis.deleteMany({ where: { sessionId: { in: sessionIds } } });
|
||||
await prismaClient.aIInteraction.deleteMany({ where: { sessionId: { in: sessionIds } } });
|
||||
await prismaClient.aIKnowledgeReference.deleteMany({ where: { sessionId: { in: sessionIds } } });
|
||||
await prismaClient.aISupportSession.deleteMany({ where: { id: { in: sessionIds } } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticket: { productId } } });
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: { ticket: { productId } } });
|
||||
await prismaClient.assignment.deleteMany({ where: { ticket: { productId } } });
|
||||
await prismaClient.sLARun.deleteMany({ where: { ticket: { productId } } });
|
||||
await prismaClient.escalationEvent.deleteMany({ where: { ticket: { productId } } });
|
||||
await prismaClient.ticket.deleteMany({ where: { productId } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const externalProductId = `LOAD_TEST_AI_FLOW_${Date.now()}`;
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Load Test — AI Support Flow', status: 'active' },
|
||||
});
|
||||
|
||||
try {
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['load-test-tenant'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 100000,
|
||||
rateLimitPerUserPerMinute: 100000,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Pre-creating ${REQUESTS} tickets and waiting for each one's AI session...`);
|
||||
const ticketIds: string[] = [];
|
||||
for (let i = 0; i < REQUESTS; i++) {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'load-test-tenant',
|
||||
userId: `load-test-user-${i}`,
|
||||
});
|
||||
const response = await fetch(`${API_URL}/v1/support/requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
productId: externalProductId,
|
||||
tenantId: 'load-test-tenant',
|
||||
userId: `load-test-user-${i}`,
|
||||
source: 'load-test',
|
||||
problem: `AI flow load test problem ${i} ${Date.now()}`,
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as { data: { ticketId: string } };
|
||||
ticketIds.push(body.data.ticketId);
|
||||
await waitForAnySession(body.data.ticketId);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Every ticket has an AI session (active or already resolved/escalated) — starting the load test.');
|
||||
|
||||
let requestIndex = 0;
|
||||
await runLoadTest('ai-support-flow', {
|
||||
url: `${API_URL}/tickets/placeholder/ai-session/messages`,
|
||||
connections: CONNECTIONS,
|
||||
amount: REQUESTS,
|
||||
requests: [
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
setupRequest: (request) => {
|
||||
const ticketId = ticketIds[requestIndex % ticketIds.length];
|
||||
requestIndex += 1;
|
||||
request.path = `/tickets/${ticketId}/ai-session/messages`;
|
||||
request.body = JSON.stringify({
|
||||
message: 'I already tried restarting, still broken.',
|
||||
});
|
||||
return request;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await cleanup(product.id);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import autocannon from 'autocannon';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* specs/016-load-concurrency-testing data-model.md "Load test report". Printed to the console
|
||||
* and written as JSON under tests/load/reports/ (gitignored — a run artifact, not a fixture)
|
||||
* for every run, so two runs of the same script can be compared.
|
||||
*/
|
||||
export interface LoadTestReport {
|
||||
endpoint: string;
|
||||
connections: number;
|
||||
durationSec: number;
|
||||
requestsPerSec: number;
|
||||
latencyP50Ms: number;
|
||||
latencyP90Ms: number;
|
||||
latencyP99Ms: number;
|
||||
non2xxCount: number;
|
||||
rateLimitedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007/FR-008: a thin wrapper over autocannon's programmatic API producing this feature's own
|
||||
* report shape. FR-009: deliberately has NO pass/fail assertion on the numbers — throughput and
|
||||
* latency targets are an `OPEN BUSINESS DECISION` (spec.md Assumptions) this project's roadmap
|
||||
* says must never be invented and shipped as if final. This tool measures and records; wiring an
|
||||
* explicit CI gate is future work once the business sets a real target.
|
||||
*/
|
||||
export async function runLoadTest(
|
||||
endpoint: string,
|
||||
options: autocannon.Options,
|
||||
): Promise<LoadTestReport> {
|
||||
const result = await autocannon(options);
|
||||
|
||||
const report: LoadTestReport = {
|
||||
endpoint,
|
||||
connections: result.connections,
|
||||
durationSec: result.duration,
|
||||
requestsPerSec: Number(result.requests.average.toFixed(2)),
|
||||
latencyP50Ms: result.latency.p50,
|
||||
latencyP90Ms: result.latency.p90,
|
||||
latencyP99Ms: result.latency.p99,
|
||||
non2xxCount: result.non2xx,
|
||||
rateLimitedCount: result.statusCodeStats?.['429']?.count ?? 0,
|
||||
};
|
||||
|
||||
printReport(report);
|
||||
writeReport(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
function printReport(report: LoadTestReport): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n=== Load test report: ${report.endpoint} ===`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`connections: ${report.connections} duration: ${report.durationSec}s`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`requests/sec (avg): ${report.requestsPerSec}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`latency p50/p90/p99 (ms): ${report.latencyP50Ms}/${report.latencyP90Ms}/${report.latencyP99Ms}`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`non-2xx: ${report.non2xxCount} rate-limited (429): ${report.rateLimitedCount}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'No pass/fail threshold applied — throughput/latency targets are an OPEN BUSINESS DECISION.',
|
||||
);
|
||||
}
|
||||
|
||||
function writeReport(report: LoadTestReport): void {
|
||||
const dir = path.join(__dirname, 'reports');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const safeName = report.endpoint.replace(/[^a-z0-9-]/gi, '_');
|
||||
const filePath = path.join(dir, `${safeName}-${Date.now()}.json`);
|
||||
writeFileSync(filePath, JSON.stringify(report, null, 2));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Report written to ${filePath}`);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* specs/016-load-concurrency-testing User Story 5 / FR-007: load/throughput baseline for new
|
||||
* support-request submission — the entry point of the entire support flow. Run against a real
|
||||
* running dev server: `npx tsx tests/load/ticket-creation.load.ts`.
|
||||
*
|
||||
* IMPORTANT — real cost: a successful ticket creation asynchronously triggers 005-ai-support's
|
||||
* AI_SESSION worker, which makes a real, billed Anthropic API call for that ticket's first
|
||||
* diagnosis turn. This endpoint's own HTTP response is fast and free, but the request still has
|
||||
* a real downstream cost — set `LOAD_TEST_REQUESTS` to bound the total ticket count explicitly
|
||||
* rather than relying on an open-ended `LOAD_TEST_DURATION_SEC` run whose total is
|
||||
* latency-dependent and less predictable.
|
||||
*
|
||||
* Creates its own throwaway product + integration credential, generates a fresh single-use
|
||||
* integration token per request (002-saas-integration's own jti replay protection means a
|
||||
* single static token can't be reused across requests), and cleans up everything it created
|
||||
* once the run finishes.
|
||||
*/
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { runLoadTest } from './autocannon.config';
|
||||
|
||||
const API_URL = process.env.LOAD_TEST_API_URL ?? 'http://localhost:4501';
|
||||
const CONNECTIONS = Number(process.env.LOAD_TEST_CONNECTIONS ?? 10);
|
||||
const DURATION_SEC = Number(process.env.LOAD_TEST_DURATION_SEC ?? 10);
|
||||
// When set, caps the total number of tickets created (and thus the total downstream AI cost)
|
||||
// instead of running for an open-ended duration.
|
||||
const REQUESTS = process.env.LOAD_TEST_REQUESTS ? Number(process.env.LOAD_TEST_REQUESTS) : undefined;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const externalProductId = `LOAD_TEST_TICKET_CREATION_${Date.now()}`;
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Load Test — Ticket Creation', status: 'active' },
|
||||
});
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['load-test-tenant'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 100000,
|
||||
rateLimitPerUserPerMinute: 100000,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runLoadTest('ticket-creation', {
|
||||
url: `${API_URL}/v1/support/requests`,
|
||||
connections: CONNECTIONS,
|
||||
...(REQUESTS !== undefined ? { amount: REQUESTS } : { duration: DURATION_SEC }),
|
||||
requests: [
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
setupRequest: (request) => {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'load-test-tenant',
|
||||
userId: 'load-test-user',
|
||||
});
|
||||
request.headers = { ...request.headers, authorization: `Bearer ${token}` };
|
||||
request.body = JSON.stringify({
|
||||
productId: externalProductId,
|
||||
tenantId: 'load-test-tenant',
|
||||
userId: 'load-test-user',
|
||||
source: 'load-test',
|
||||
problem: `Load test problem ${Date.now()}-${Math.random()}`,
|
||||
});
|
||||
return request;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticket: { productId: product.id } } });
|
||||
await prismaClient.ticket.deleteMany({ where: { productId: product.id } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId: product.id } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId: product.id } });
|
||||
await prismaClient.product.deleteMany({ where: { id: product.id } });
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ function fakeRun(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
id: 'run-1',
|
||||
ticketId: 'ticket-1',
|
||||
status: 'running',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -20,7 +21,7 @@ describe('SLA compliance metric (014-full-observability data-model.md #6)', () =
|
||||
const incSpy = vi.spyOn(observability.slaRunOutcomesCounter, 'inc');
|
||||
const runs = {
|
||||
findByTicketId: vi.fn().mockResolvedValue(fakeRun({ status: 'running' })),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateWithVersion: vi.fn().mockResolvedValue(fakeRun({ status: 'completed' })),
|
||||
} as never;
|
||||
const service = new SlaService(undefined, runs);
|
||||
|
||||
@@ -33,7 +34,7 @@ describe('SLA compliance metric (014-full-observability data-model.md #6)', () =
|
||||
const incSpy = vi.spyOn(observability.slaRunOutcomesCounter, 'inc');
|
||||
const runs = {
|
||||
findByTicketId: vi.fn().mockResolvedValue(fakeRun({ status: 'breached' })),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateWithVersion: vi.fn().mockResolvedValue(fakeRun({ status: 'completed' })),
|
||||
} as never;
|
||||
const service = new SlaService(undefined, runs);
|
||||
|
||||
@@ -46,13 +47,13 @@ describe('SLA compliance metric (014-full-observability data-model.md #6)', () =
|
||||
const incSpy = vi.spyOn(observability.slaRunOutcomesCounter, 'inc');
|
||||
const runs = {
|
||||
findByTicketId: vi.fn().mockResolvedValue(fakeRun({ status: 'completed' })),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateWithVersion: vi.fn().mockResolvedValue(undefined),
|
||||
} as never;
|
||||
const service = new SlaService(undefined, runs);
|
||||
|
||||
await service.complete('ticket-1');
|
||||
|
||||
expect(incSpy).not.toHaveBeenCalled();
|
||||
expect((runs as unknown as { update: ReturnType<typeof vi.fn> }).update).not.toHaveBeenCalled();
|
||||
expect((runs as unknown as { updateWithVersion: ReturnType<typeof vi.fn> }).updateWithVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@ describe('EscalationService.handleBreach', () => {
|
||||
findApplicable: vi.fn().mockResolvedValue({ id: 'policy-1', productId: 'prod-1' }),
|
||||
} as never;
|
||||
const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never;
|
||||
const events = { create: vi.fn().mockResolvedValue({ id: 'event-1' }) } as never;
|
||||
const events = {
|
||||
create: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, wasNewlyCreated: true }),
|
||||
} as never;
|
||||
const assignmentEngine = {
|
||||
assignToSpecificNode: vi.fn().mockResolvedValue(undefined),
|
||||
} as never;
|
||||
|
||||
@@ -10,6 +10,7 @@ function run(overrides: Partial<SLARun>): SLARun {
|
||||
firstResponseDueAt: null,
|
||||
resolutionDueAt: null,
|
||||
status: 'running',
|
||||
version: 1,
|
||||
pausedAt: null,
|
||||
resumedAt: null,
|
||||
breachedAt: null,
|
||||
@@ -22,11 +23,11 @@ function run(overrides: Partial<SLARun>): SLARun {
|
||||
describe('SlaService.runBreachDetectionSweep', () => {
|
||||
it('marks every running run past its resolution due date as breached and fires escalation', async () => {
|
||||
const overdue = run({ id: 'r1', ticketId: 't1' });
|
||||
const update = vi.fn().mockResolvedValue(overdue);
|
||||
const updateWithVersion = vi.fn().mockResolvedValue(overdue);
|
||||
const runsRepo = {
|
||||
findRunningPastResolutionDueAt: vi.fn().mockResolvedValue([overdue]),
|
||||
findRunningPastFirstResponseDueAt: vi.fn().mockResolvedValue([]),
|
||||
update,
|
||||
updateWithVersion,
|
||||
} as never;
|
||||
const handleBreach = vi.fn().mockResolvedValue(undefined);
|
||||
const escalation = { handleBreach } as never;
|
||||
@@ -34,7 +35,7 @@ describe('SlaService.runBreachDetectionSweep', () => {
|
||||
const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation);
|
||||
await service.runBreachDetectionSweep();
|
||||
|
||||
expect(update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'breached' }));
|
||||
expect(updateWithVersion).toHaveBeenCalledWith('r1', 1, expect.objectContaining({ status: 'breached' }));
|
||||
expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach');
|
||||
});
|
||||
|
||||
@@ -42,7 +43,7 @@ describe('SlaService.runBreachDetectionSweep', () => {
|
||||
const runsRepo = {
|
||||
findRunningPastResolutionDueAt: vi.fn().mockResolvedValue([]),
|
||||
findRunningPastFirstResponseDueAt: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
updateWithVersion: vi.fn(),
|
||||
} as never;
|
||||
const handleBreach = vi.fn();
|
||||
const service = new SlaService(undefined, runsRepo, undefined, undefined, {
|
||||
|
||||
@@ -10,6 +10,7 @@ function run(overrides: Partial<SLARun>): SLARun {
|
||||
firstResponseDueAt: new Date('2026-01-05T12:00:00.000Z'),
|
||||
resolutionDueAt: new Date('2026-01-05T17:00:00.000Z'),
|
||||
status: 'running',
|
||||
version: 1,
|
||||
pausedAt: null,
|
||||
resumedAt: null,
|
||||
breachedAt: null,
|
||||
@@ -22,13 +23,13 @@ function run(overrides: Partial<SLARun>): SLARun {
|
||||
describe('SlaService pause/resume', () => {
|
||||
it('pause records pausedAt and flips status to paused', async () => {
|
||||
const found = run({});
|
||||
const update = vi.fn().mockResolvedValue(found);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(found), update } as never;
|
||||
const updateWithVersion = vi.fn().mockResolvedValue(found);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(found), updateWithVersion } as never;
|
||||
const service = new SlaService(undefined, runsRepo);
|
||||
|
||||
await service.pause('ticket-1');
|
||||
|
||||
expect(update).toHaveBeenCalledWith('run-1', expect.objectContaining({ status: 'paused' }));
|
||||
expect(updateWithVersion).toHaveBeenCalledWith('run-1', 1, expect.objectContaining({ status: 'paused' }));
|
||||
});
|
||||
|
||||
it('resume shifts both due dates forward by exactly the paused wall-clock duration', async () => {
|
||||
@@ -39,16 +40,16 @@ describe('SlaService pause/resume', () => {
|
||||
firstResponseDueAt: new Date('2026-01-05T12:00:00.000Z'),
|
||||
resolutionDueAt: new Date('2026-01-05T17:00:00.000Z'),
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue(paused);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(paused), update } as never;
|
||||
const updateWithVersion = vi.fn().mockResolvedValue(paused);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(paused), updateWithVersion } as never;
|
||||
const service = new SlaService(undefined, runsRepo);
|
||||
|
||||
const before = Date.now();
|
||||
await service.resume('ticket-1');
|
||||
const after = Date.now();
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const [, patch] = update.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(updateWithVersion).toHaveBeenCalledTimes(1);
|
||||
const [, , patch] = updateWithVersion.mock.calls[0] as [string, number, Record<string, unknown>];
|
||||
expect(patch.status).toBe('running');
|
||||
expect(patch.pausedAt).toBeNull();
|
||||
|
||||
@@ -74,13 +75,13 @@ describe('SlaService pause/resume', () => {
|
||||
status: 'paused',
|
||||
pausedAt: secondPausedAt,
|
||||
} as SLARun;
|
||||
const update = vi.fn().mockResolvedValue(pausedAgain);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never;
|
||||
const updateWithVersion = vi.fn().mockResolvedValue(pausedAgain);
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), updateWithVersion } as never;
|
||||
const service = new SlaService(undefined, runsRepo);
|
||||
|
||||
await service.resume('ticket-1');
|
||||
|
||||
const [, patch] = update.mock.calls[0] as [string, Record<string, unknown>];
|
||||
const [, , patch] = updateWithVersion.mock.calls[0] as [string, number, Record<string, unknown>];
|
||||
const shifted = (patch.resolutionDueAt as Date).getTime();
|
||||
// Must be shifted from the ALREADY-shifted 18:00 baseline, not the original 17:00 baseline.
|
||||
expect(shifted).toBeGreaterThan(new Date('2026-01-05T18:00:00.000Z').getTime());
|
||||
@@ -88,11 +89,11 @@ describe('SlaService pause/resume', () => {
|
||||
|
||||
it('never resumes a run that is not currently paused', async () => {
|
||||
const runningRun = run({ status: 'running', pausedAt: null });
|
||||
const update = vi.fn();
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(runningRun), update } as never;
|
||||
const updateWithVersion = vi.fn();
|
||||
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(runningRun), updateWithVersion } as never;
|
||||
const service = new SlaService(undefined, runsRepo);
|
||||
|
||||
await service.resume('ticket-1');
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(updateWithVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideConfidenceBand } from '@/modules/ai-support/sessions';
|
||||
import { aiConfig } from '@/config';
|
||||
|
||||
/**
|
||||
* 015-reporting-dashboards research.md §7: the AI dashboard's confidence distribution reuses
|
||||
* 005-ai-support's own decideConfidenceBand against the system-default thresholds, rather than
|
||||
* reimplementing a threshold check — this test proves the reused function classifies values
|
||||
* the way the dashboard's own bucketing loop (reports.service.ts) depends on.
|
||||
*/
|
||||
describe('AI dashboard confidence distribution reuses decideConfidenceBand', () => {
|
||||
const policy = {
|
||||
highThreshold: aiConfig.defaultHighConfidence,
|
||||
lowThreshold: aiConfig.defaultLowConfidence,
|
||||
};
|
||||
|
||||
it('classifies a high-confidence value as proceed', () => {
|
||||
expect(decideConfidenceBand(policy.highThreshold, policy)).toBe('proceed');
|
||||
});
|
||||
|
||||
it('classifies a low-confidence value as escalate', () => {
|
||||
expect(decideConfidenceBand(policy.lowThreshold - 0.01, policy)).toBe('escalate');
|
||||
});
|
||||
|
||||
it('classifies a mid-range value as ask', () => {
|
||||
const midpoint = (policy.highThreshold + policy.lowThreshold) / 2;
|
||||
expect(decideConfidenceBand(midpoint, policy)).toBe('ask');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeRate, computeAverageSeconds } from '@/modules/platform/reports/mapper';
|
||||
|
||||
describe('computeRate (015-reporting-dashboards research.md §3)', () => {
|
||||
it('returns null when the denominator is zero — never NaN, never a computed 0', () => {
|
||||
expect(computeRate(0, 0)).toBeNull();
|
||||
expect(computeRate(5, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('computes a real rate when there is qualifying data', () => {
|
||||
expect(computeRate(3, 12)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('returns a real 0 when the numerator is legitimately zero but the denominator is not', () => {
|
||||
expect(computeRate(0, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAverageSeconds', () => {
|
||||
it('returns null for an empty list — no fabricated average', () => {
|
||||
expect(computeAverageSeconds([])).toBeNull();
|
||||
});
|
||||
|
||||
it('averages a list of millisecond durations into seconds', () => {
|
||||
expect(computeAverageSeconds([1000, 2000, 3000])).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user