Author SHA1 Message Date
saqib mirandClaude Sonnet 5 700daf4104 feat(012-admin-list-views): GET /admin/escalation-policies now includes each policy's rules
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own escalation admin screen (001-agent-admin-ui User
Story 5): the list endpoint returned bare policies with no way to read
back which rules (trigger type, target node) already existed under
each one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:42:25 +05:30
saqib mirandClaude Sonnet 5 249e7cd0ce feat(012-admin-list-views): GET /admin/business-calendars/:id now includes holidays
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own SLA/calendar admin screen (001-agent-admin-ui User
Story 4): holidays could only be added or removed, never read back -
GET /admin/business-calendars/:id returned the bare calendar with no
way to display what holidays were already on file. The repository
already had findByIdWithHolidays; it just wasn't wired to this route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:40:00 +05:30
saqib mirandClaude Sonnet 5 2034966d6d docs(012-admin-list-views): note the knowledge-governance follow-up
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:35:47 +05:30
saqib mirandClaude Sonnet 5 7948182988 feat(012-admin-list-views): add GET /admin/products/:id/knowledge for governance
Follow-up to 012-admin-list-views, discovered while building supporthub-
web's own knowledge-governance screen (001-agent-admin-ui User Story 7):
GET /knowledge/retrieve only ever returns published entries (its own
AI-consumption purpose), so a governance screen that needs to see and
publish a draft entry had no endpoint to list it. Adds a small
admin-list-views-style read query scoped to the knowledge module itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:35:25 +05:30
saqib mirandClaude Sonnet 5 3bd068b031 feat(012-admin-list-views): SLA-run, escalation-event, and product-catalog list endpoints
Adds GET /admin/sla-runs (filterable by status), GET /admin/escalation-
events (capped, most-recent-first), and GET /admin/products (with
integration status joined in, never the full ProductIntegration row).
None of these existed as a single query before - only per-ticket or
per-integration-id lookups did.

Discovered while planning supporthub-web's 001-agent-admin-ui User
Stories 6-7 (SLA/escalation monitoring, product catalog), the same way
011-agent-ticket-queue was discovered for User Story 1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:31:20 +05:30
saqib mirandClaude Sonnet 5 43158ff0c4 docs(012-admin-list-views): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:14:52 +05:30
saqib mirandClaude Sonnet 5 2b00b6d6a1 docs(012-admin-list-views): plan, research, data model, contract, quickstart
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:14:25 +05:30
saqib mirandClaude Sonnet 5 49db40d7c1 docs(012-admin-list-views): spec for SLA-run, escalation-event, and product-catalog list endpoints
Discovered while planning supporthub-web's 001-agent-admin-ui User
Stories 6-7: no endpoint lists SLA runs or escalation events across
multiple tickets (only per-ticket), and no endpoint returns the product
catalog with integration status joined in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:12:21 +05:30
saqib mirandClaude Sonnet 5 fb9606b6aa feat(011-agent-ticket-queue): link agents to accounts, list assigned tickets
Extends PATCH /admin/agents/:agentId with an optional userId to finally
wire Agent.userId (added in 010-identity-auth as schema-only, never
consumed by any workflow), with proactive role/duplicate-link checks
mirroring UsersService.create's own pre-check style.

Adds GET /agents/me/tickets and GET /admin/agents/:agentId/tickets,
sharing one TicketsService.listAssignedTo method, returning a dashboard-
ready summary (product, customer, priority, severity, status, SLA state)
of every ticket currently assigned to an agent — no such query existed
anywhere in the ticketing or orchestration modules before this. Backed by
a new Assignment @@index([agentId, isCurrent]).

Discovered while starting supporthub-web's 001-agent-admin-ui: its agent-
dashboard user story had no backend data source without this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:19:13 +05:30
saqib mirandClaude Sonnet 5 d574af087a docs(011-agent-ticket-queue): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:00:35 +05:30
saqib mirandClaude Sonnet 5 23fadebb5c docs(011-agent-ticket-queue): plan, research, data model, contract, quickstart
Extends the existing PATCH /admin/agents/:agentId with an optional userId
to finish wiring 010's Agent.userId link, and adds GET /agents/me/tickets
+ GET /admin/agents/:agentId/tickets sharing one ticketing/tickets service
method, backed by a new Assignment @@index([agentId, isCurrent]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:58:25 +05:30
saqib mirandClaude Sonnet 5 d58bc98c7f docs(011-agent-ticket-queue): spec for agent-user linking and assigned-ticket listing
Discovered while starting supporthub-web's 001-agent-admin-ui planning:
its agent-dashboard user story needs to list tickets currently assigned
to an agent, and no such query exists anywhere in the ticketing or
orchestration modules. Also finishes wiring Agent.userId (added in
010-identity-auth as schema-only, never consumed by any workflow) so a
logged-in session can resolve to its own agent roster row at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:55:41 +05:30
saqib mirandClaude Sonnet 5 40687f68fa feat(010-identity-auth): real staff login, session verification, and role gating
Replaces the no-op fastify.authenticate stub and the never-implemented
identity/auth login with real bcrypt password verification, JWT session
issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed
revocation denylist for logout. Adds requireRole('ADMIN') to admin-only
configuration writes across 002-009 that previously relied on a decorator
that never actually checked anything. Adds self-identity (GET /auth/me,
re-validated against live account state) and admin-provisioned accounts
(POST /admin/users).

Making the auth check genuinely reject invalid/missing tokens exposed that
~18 pre-existing integration test files called already-gated routes with no
Authorization header (safe against the old no-op stub, broken against a real
one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a
file-by-file pass, plus two related SLA-run cleanup races exposed once admin
setup calls in those files' own beforeAll blocks started actually succeeding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:45:37 +05:30
saqib mirandClaude Sonnet 5 8327fafac2 tasks: task breakdown for identity and authentication feature (010)
36 tasks across 8 phases (5 user stories + setup/foundational/polish).
US1 (real login) and US2 (real route/role gating) are the P1 MVP; the
one task that touches code outside identity/* (T020, adding
requireRole('ADMIN') across 002-009's existing admin routes) is called
out explicitly to run each touched module's own test suite immediately
after, not only in the final regression pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:15:45 +05:30
saqib mirandClaude Sonnet 5 3b4c220a45 plan: design for identity and authentication feature (010)
Phase 0 research resolves the library choices (jsonwebtoken + bcryptjs,
chosen partly to avoid native-build friction on Windows dev
environments), the Redis-backed revocation-denylist shape (reusing
002's own jti-replay-protection pattern exactly), a 4-hour token
lifetime, and why fastify.authenticate populating the already-shared
reqContext.actorId/actorType retroactively makes every audit trail
since 007 accurate for real agent/admin actions instead of always
'unknown'.

Also surfaces and scopes a real gap found along the way: User (login
identity) and Agent (routing/skills profile) have never been linked.
Adds Agent.userId as a nullable FK now (cheap, additive) without
building the actual linking workflow, which belongs in 006's own
identity/agents admin screens as a later, separate piece of work.

Phase 1 adds data-model.md, the login/self-identity/account-creation/
logout contract, and five quickstart scenarios including a specific
requirement to re-verify at least one already-shipped admin route per
module (002-009), not just this feature's own new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:14:42 +05:30
saqib mirandClaude Sonnet 5 a49389dc2c docs: spec for identity and authentication (010)
Not on the original roadmap -- surfaced as a genuine blocking gap while
planning supporthub-web's own agent/admin UI feature: fastify.authenticate
has been a complete no-op stub since 002, and identity/auth's login
endpoint has never taken a password. User/UserRole (two seeded-but-
passwordless demo accounts) and the AuthUser/JwtPayload types were all
already scaffolded and clearly intended for exactly this -- this finishes
that original wiring rather than inventing a new design.

Scope: real login (password hash + JWT), fastify.authenticate actually
rejecting invalid sessions, role-based route gating, a self-identity
endpoint, admin-created accounts, and logout. Password reset, MFA, and
login rate-limiting are explicitly deferred to Phase 11's own security
hardening pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:09:46 +05:30
saqib mir 4e0c90e2cf fix 2026-09-03 16:48:27 +05:30
saqib mir d9bd970910 have add tthe port number 2026-09-03 16:40:19 +05:30
saqib mirandClaude Sonnet 5 16daf8d32d feat: implement problem resolution (009)
Populates the five real problem-management stubs (investigation,
root-causes, solutions, verification, resolutions -- problems is
confirmed dead/unwired scaffold and stays untouched) with doc04's
sequential workflow engine:

- investigation: version-row-per-attempt (never overwritten), with a
  customer-safe read path that always strips internalNotes.
- root-causes/solutions/verification: a strict existence chain
  (investigation -> root cause -> solution -> approval -> implementation
  -> verification), each step resolve-or-409 on its own precondition,
  matching doc06's schema field-for-field with no invented columns.
- resolutions: gated on a successfully verified solution (no stored
  solutionId FK, per doc06 -- resolved via a join at write time), moving
  the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer
  confirmation and a durable auto-close sweep (the previously-unregistered
  CLEANUP queue stub, mirroring 008's breach-detection job) both resolve
  it from there.
- reopen (ticketing/tickets): two real, separately-audited transitions
  (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior
  problem-resolution record and no SLARun -- closes the loop 008's own
  spec.md left open.

Verification-failure escalation reuses 003/007's existing
HUMAN_ESCALATION transition directly rather than adding an eleventh
trigger type to 008's already-shipped escalation rules.

Customer-facing confirm-resolution/reopen needed a body-shape variant of
002's inbound trust boundary that didn't previously exist:
fastify.authenticateProductIntegration hard-required a full
ticket-creation-shaped body. Extracted the shared token/scope/replay
verification into verifyIntegrationIdentity and added a narrower
authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema
on top of it -- purely additive, ticket creation's own behavior is
unchanged.

Also fixes a real test-data-hygiene bug surfaced by running this
feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an
intentionally-global SLAPolicy in 008's own test fixtures were silently
affecting other test files' tickets sharing the same live Postgres.

Verified against throwaway Docker Postgres/Redis: typecheck, lint,
architecture-check all clean; full regression (tests/unit +
tests/integration together, 172 tests) passes except the 2 pre-existing
MinIO-dependent attachment failures, unrelated to this feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:09:29 +05:30
saqib mirandClaude Sonnet 5 aaa51ef475 tasks: task breakdown for problem resolution feature (009)
49 tasks across 9 phases (6 user stories + setup/foundational/polish).
Unlike 007/008, this feature's user stories are genuinely sequential
(doc04's own investigation -> root cause -> solution -> verification ->
resolution -> reopen chain), so each story's dependency on the last is
real, not just priority-driven ordering -- called out explicitly since
User Story 4 (P2, verification) is a structural prerequisite of User
Story 5 (P1, resolution) despite the lower priority label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:39:11 +05:30
saqib mirandClaude Sonnet 5 9ce34d8ca4 plan: design for problem resolution feature (009)
Phase 0 research resolves module placement (problem-management/problems
confirmed dead/unwired, left untouched), Investigation's version-row-per-
attempt shape, the strict investigation->root-cause->solution->
implementation->verification existence chain, why Resolution has no
solutionId FK (matches doc06 exactly), why verification-failure
escalation reuses 003/007's plain HUMAN_ESCALATION transition instead of
adding an eleventh trigger type to 008's already-shipped escalation
rules, the customer-facing route design (reusing 002's inbound trust
boundary rather than fastify.authenticate), and the auto-close sweep
design (the already-defined-but-unused CLEANUP queue, mirroring 008's
breach-detection job).

Phase 1 adds data-model.md, the admin/customer-facing contract, and six
quickstart scenarios covering the full sequential workflow through
customer confirmation, auto-close, and reopen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:37:42 +05:30
saqib mirandClaude Sonnet 5 14c6793460 docs: spec for problem resolution feature (009)
Investigation -> Root Cause -> Solution -> Implementation ->
Verification -> Resolution, plus customer confirmation and reopen,
matching doc04 SS3-9's workflow narrative and doc06's "Domain: Problem
Resolution" schema exactly. Explicitly closes a loop 008's own spec left
open (reopen never restarts an SLA run); verification-failure escalation
reuses 003/007's existing HUMAN_ESCALATION transition rather than adding
an eleventh escalation-rule trigger type to 008's system.

Also documents a real scope-boundary finding: src/modules/problem-management/problems
is a dead, unwired duplicate scaffold for Problem (the real one has lived
in ticketing/tickets since 003) and is not touched by this feature -- only
the five investigation/root-causes/solutions/resolutions/verification
stub directories are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:33:55 +05:30
saqib mir 7e3d2ae29f add the new feaures 2026-09-03 14:19:21 +05:30
saqib mirandClaude Sonnet 5 9357f03e1d feat: implement SLA and escalation (008)
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:02:05 +05:30
saqib mirandClaude Sonnet 5 bb31e9d641 tasks: task breakdown for SLA and escalation feature (008)
48 tasks across 9 phases (6 user stories + setup/foundational/polish),
sequenced US1 (policy definition) -> US2 (calendar-aware run creation) ->
US3 (durable pause/resume, P1-complete MVP) -> US4 (breach detection) ->
US5 (breach-triggered escalation) -> US6 (manual escalation), each
dependent on the last since every story builds on the previous one's
mechanism rather than being independently orderable.

Also folds in two design refinements found while cross-checking the
existing scaffold against research.md's plan: TICKET_ASSIGNED (defined in
domain-events.ts since 007, never published) becomes the real wiring point
for SLA-run creation, and src/jobs/sla//src/jobs/escalation/ turn out to
already exist as their own stub scaffolding, reused rather than
duplicated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:27:40 +05:30
saqib mirandClaude Sonnet 5 199bd4eb4e plan: design for SLA and escalation feature (008)
Phase 0 research resolves the business-calendar working-hours algorithm
(day-by-day walk via luxon, the first date/timezone dependency in this
codebase), the workingHours JSON shape, most-specific SLA-policy match
(reusing 005/006's resolution pattern), durable pause/resume (absolute
due-date shift, no in-memory state), and a repeatable-job breach-detection
design over per-run delayed jobs. Phase 1 adds data-model.md (one additive
refinement beyond doc06: SLARun.firstResponseBreachedAt), the admin/read
contract, and six quickstart scenarios including a genuine process-restart
boundary test for Constitution Principle VII.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:23:35 +05:30
saqib mirandClaude Sonnet 5 1ad9007e79 docs: spec for SLA and escalation feature (008)
Phase 8 of the roadmap: SLA policy engine (most-specific-match, calendar-
aware due dates), durable pause/resume and breach detection (never an
in-memory timer), and a rule-driven escalation engine that re-assigns via
007's engine scoped to a specific target node. Bounded to the two
SLA-derived trigger types this feature can compute a real signal for;
the other eight doc 05 §6 trigger types remain valid rule configuration
without a wired event source yet. First feature to give 006's
HierarchyNode.slaPolicyId/escalationPolicyId fields a real target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:16:05 +05:30
saqibmir 10f59a7d5b Merge pull request '007-orchestration-assignment' (#8) from 007-orchestration-assignment into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/8
2026-09-03 06:33:07 +00:00
275 changed files with 12096 additions and 587 deletions
Vendored
-226
View File
@@ -1,226 +0,0 @@
// CI/CD pipeline for supporthub-api.
//
// Stage order and guarantees are defined by:
// - .specify/memory/constitution.md -> "Testing, Observability & CI/CD Gates"
// - specs/001-ci-pipeline/contracts/pipeline-stage-contract.md
//
// Prerequisites (configured on the Jenkins side, never in this repo):
// - Agents with Docker and Node.js 20+ available (label: 'docker && node20')
// - Credentials (Secret text unless noted) per target environment <env> in [test, prod]:
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
// <env>-aws-access-key-id, <env>-aws-secret-access-key
// <env>-integration-credential-encryption-key (64 hex chars / 32 bytes — see
// specs/002-saas-integration/research.md "Credential storage")
// Plus one Username/Password credential: docker-registry-credentials
// - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var,
// or override the default below) pointing at the org's actual image registry.
//
// This file intentionally contains no secret values, only credential IDs — see
// specs/001-ci-pipeline/research.md "Environment/secrets handling in the pipeline".
pipeline {
agent { label 'docker && node20' }
options {
timestamps()
buildDiscarder(logRotator(numToKeepStr: '30'))
// Each build gets its own workspace by default; Compose project names below are
// additionally scoped by BUILD_NUMBER so concurrent runs never share containers,
// networks, or volumes (FR-009 / SC-005).
ansiColor('xterm')
}
environment {
DOCKER_REGISTRY = "${env.CI_DOCKER_REGISTRY ?: 'registry.example.com/supporthub'}"
IMAGE_NAME = 'supporthub-api'
COMPOSE_PROJECT = "supporthub-ci-${env.BUILD_NUMBER}"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Environment validation') {
steps {
script {
resolveDeployTarget()
writeTargetEnvFile(env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV)
}
// Reuses the existing Zod schema in src/config/env.ts as-is: it throws a
// specific, descriptive error on safeParse failure, so this fails fast with
// no new validation logic (FR-002).
sh "npx tsx --env-file=.env.${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV} -e \"import('./src/config/env.ts').then(() => console.log('Environment OK'))\""
}
}
stage('Generate Prisma client') {
steps {
sh 'npm run prisma:generate'
}
}
stage('Typecheck') {
steps {
sh 'npm run typecheck'
}
}
stage('Lint') {
steps {
sh 'npm run lint'
// Enforces Constitution Principle III (module boundaries) server-side —
// mirrors .husky/pre-commit so a bypassed/missing local hook can't merge
// a boundary violation.
sh 'npx tsx scripts/check-architecture.ts'
}
}
stage('Format check') {
steps {
sh 'npm run format:check'
}
}
stage('Unit test') {
steps {
sh 'npm run test:unit'
}
}
stage('Integration test') {
steps {
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis minio"
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm --build app npm run test:integration"
}
}
stage('E2E test') {
steps {
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm app npm run test:e2e"
}
}
stage('Build') {
steps {
script {
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
sh "npm run build:${buildTarget}"
}
}
}
stage('Docker build') {
steps {
script {
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
env.IMAGE_TAG = "${env.DOCKER_REGISTRY}/${env.IMAGE_NAME}:${buildTarget}-${env.BUILD_NUMBER}"
}
sh "docker build --build-arg BUILD_COMMAND=\"npm run build:${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV}\" -t ${env.IMAGE_TAG} ."
}
}
stage('Publish') {
when { expression { env.TARGET_ENV != 'none' } }
steps {
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
echo "$REGISTRY_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$REGISTRY_USER" --password-stdin
docker push "$IMAGE_TAG"
docker logout "$DOCKER_REGISTRY"
'''
}
}
}
stage('Deploy') {
when { expression { env.TARGET_ENV != 'none' } }
steps {
script {
writeTargetEnvFile(env.TARGET_ENV)
}
sh "docker compose --env-file .env.${env.TARGET_ENV} -f docker-compose.${env.TARGET_ENV == 'prod' ? 'prod' : 'test'}.yml up -d"
}
}
}
post {
always {
// Tear down the ephemeral integration/E2E stack regardless of outcome, and never
// leave a generated .env.* file behind in the workspace. --env-file must be passed
// to `down` too, or Compose can fail to resolve service config and leave containers
// running (confirmed while validating this pipeline locally).
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml down -v --remove-orphans || true"
sh 'rm -f .env.test .env.prod .env.development'
cleanWs()
}
failure {
echo "Pipeline failed at stage: ${currentBuild.result}. See the failing stage's log above for the exact command and output — no local reproduction should be necessary (FR-004)."
}
}
}
// Resolves env.TARGET_ENV from the branch being built:
// main -> prod
// develop/test -> test
// anything else -> none (validate-only; Publish/Deploy stages are skipped, not failed)
void resolveDeployTarget() {
def branch = env.BRANCH_NAME ?: ''
if (branch == 'main') {
env.TARGET_ENV = 'prod'
} else if (branch == 'develop' || branch == 'test') {
env.TARGET_ENV = 'test'
} else {
env.TARGET_ENV = 'none'
}
echo "Resolved deploy target for branch '${branch}': ${env.TARGET_ENV}"
}
// Writes .env.<target> into the workspace from Jenkins credentials — never read from a
// file committed to the repository (constitution governance: "secrets never committed").
void writeTargetEnvFile(String target) {
withCredentials([
string(credentialsId: "${target}-postgres-password", variable: 'POSTGRES_PASSWORD'),
string(credentialsId: "${target}-redis-password", variable: 'REDIS_PASSWORD'),
string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'),
string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'),
string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'),
string(credentialsId: "${target}-integration-credential-encryption-key", variable: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'),
]) {
def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501')
def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev')
def dbUser = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_user')
writeFile file: ".env.${target}", text: """
NODE_ENV=${target == 'prod' ? 'production' : target}
PORT=${port}
BUILD_COMMAND=npm run build:${target}
POSTGRES_HOST=postgres
POSTGRES_DB=${dbName}
POSTGRES_USER=${dbUser}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
DATABASE_URL=postgresql://${dbUser}:${POSTGRES_PASSWORD}@postgres:5432/${dbName}
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=${REDIS_PASSWORD}
JWT_SECRET=${JWT_SECRET}
AWS_REGION=us-east-1
AWS_S3_BUCKET=supporthub-attachments-${target}
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
${target == 'prod' ? '' : 'AWS_S3_ENDPOINT=http://minio:9000'}
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=${INTEGRATION_CREDENTIAL_ENCRYPTION_KEY}
CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'}
""".stripIndent().trim()
}
}
+61 -12
View File
@@ -1,17 +1,66 @@
### Development
- docker compose --env-file .env.development -f docker-compose.development.yml up -d --build
# SupportHub API
### Test
- docker compose --env-file .env.test -f docker-compose.test.yml up --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`
### Production
- docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
### Test (Docker)
- `docker compose --env-file .env.test -f docker-compose.test.yml up --build`
### Stop
- docker compose -f docker-compose.prod.yml down
### Production (Docker)
- `docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d`
### list containers
- docker compose --env-file .env.development -f docker-compose.development.yml ps
### 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 & Logs
- List containers: `docker compose --env-file .env.development -f docker-compose.development.yml ps`
- Follow logs: `docker compose --env-file .env.development -f docker-compose.development.yml logs -f`
---
### Local Development (Host)
1. Start database & cache in Docker:
```bash
docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis
```
2. Start API server in watch mode:
```bash
npm run dev
```
---
### Database Migrations & Prisma
- **Generate Prisma Client**:
```bash
npm run prisma:generate
```
- **Run / Apply Dev Migrations**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:migrate
```
- **Deploy Migrations (Production/CI)**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:deploy
```
- **Push Schema directly (Sync schema without migration files)**:
```bash
npx dotenv-cli -e .env.development -- npx prisma db push
```
---
### Database Seeding
- **Seed Database (Roles, Products, Categories, Hierarchy & Demo data)**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:seed
```
### logs
- docker compose --env-file .env.development -f docker-compose.development.yml logs -f
+6 -29
View File
@@ -27,8 +27,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
@@ -43,6 +41,9 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5434:5432"
volumes:
- postgres_development_data:/var/lib/postgresql
@@ -68,6 +69,9 @@ services:
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_development_data:/data
@@ -86,33 +90,6 @@ services:
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: minio-development
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_development_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
postgres_development_data:
redis_development_data:
minio_development_data:
+6 -23
View File
@@ -27,8 +27,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
@@ -41,6 +39,9 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- postgres_test_data:/var/lib/postgresql
@@ -64,6 +65,9 @@ services:
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_test_data:/data
@@ -82,27 +86,6 @@ services:
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
volumes:
- minio_test_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
postgres_test_data:
redis_test_data:
minio_test_data:
+147
View File
@@ -19,17 +19,23 @@
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
"dotenv": "^16.4.5",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
"prom-client": "^15.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/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",
@@ -1966,6 +1972,13 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1973,6 +1986,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -2538,6 +2576,15 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2609,6 +2656,12 @@
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bullmq": {
"version": "5.81.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
@@ -3061,6 +3114,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -4310,6 +4372,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4535,6 +4640,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4542,6 +4683,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+6
View File
@@ -56,17 +56,23 @@
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
"dotenv": "^16.4.5",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
"prom-client": "^15.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/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",
@@ -0,0 +1,147 @@
-- CreateTable
CREATE TABLE "sla_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"categoryId" TEXT,
"problemTypeId" TEXT,
"priority" TEXT,
"firstResponseMinutes" INTEGER NOT NULL,
"investigationMinutes" INTEGER,
"resolutionMinutes" INTEGER NOT NULL,
"customerResponseMinutes" INTEGER,
"businessCalendarId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sla_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sla_runs" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"firstResponseDueAt" TIMESTAMP(3),
"resolutionDueAt" TIMESTAMP(3),
"status" TEXT NOT NULL,
"pausedAt" TIMESTAMP(3),
"resumedAt" TIMESTAMP(3),
"breachedAt" TIMESTAMP(3),
"firstResponseBreachedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
CONSTRAINT "sla_runs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "business_calendars" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"timezone" TEXT NOT NULL,
"workingHours" JSONB NOT NULL,
CONSTRAINT "business_calendars_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "holidays" (
"id" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"description" TEXT,
CONSTRAINT "holidays_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_rules" (
"id" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"triggerType" TEXT NOT NULL,
"condition" JSONB NOT NULL,
"targetNodeId" TEXT NOT NULL,
"notify" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_events" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"ruleId" TEXT,
"fromNodeId" TEXT,
"toNodeId" TEXT,
"reason" TEXT NOT NULL,
"triggeredBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "escalation_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "sla_policies_productId_categoryId_active_idx" ON "sla_policies"("productId", "categoryId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "sla_runs_ticketId_key" ON "sla_runs"("ticketId");
-- CreateIndex
CREATE INDEX "sla_runs_status_resolutionDueAt_idx" ON "sla_runs"("status", "resolutionDueAt");
-- CreateIndex
CREATE INDEX "sla_runs_status_firstResponseDueAt_idx" ON "sla_runs"("status", "firstResponseDueAt");
-- CreateIndex
CREATE INDEX "holidays_calendarId_date_idx" ON "holidays"("calendarId", "date");
-- CreateIndex
CREATE INDEX "escalation_policies_productId_active_idx" ON "escalation_policies"("productId", "active");
-- CreateIndex
CREATE INDEX "escalation_rules_policyId_triggerType_active_idx" ON "escalation_rules"("policyId", "triggerType", "active");
-- CreateIndex
CREATE INDEX "escalation_events_ticketId_createdAt_idx" ON "escalation_events"("ticketId", "createdAt");
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_businessCalendarId_fkey" FOREIGN KEY ("businessCalendarId") REFERENCES "business_calendars"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "sla_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "holidays" ADD CONSTRAINT "holidays_calendarId_fkey" FOREIGN KEY ("calendarId") REFERENCES "business_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_policies" ADD CONSTRAINT "escalation_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "escalation_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_targetNodeId_fkey" FOREIGN KEY ("targetNodeId") REFERENCES "hierarchy_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_events" ADD CONSTRAINT "escalation_events_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,105 @@
-- CreateTable
CREATE TABLE "investigations" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"investigator" TEXT NOT NULL,
"findings" JSONB NOT NULL,
"evidence" JSONB,
"internalNotes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "investigations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "root_causes" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "root_causes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solutions" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"proposed" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solutions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_implementations" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"notes" TEXT,
"implementedBy" TEXT NOT NULL,
"implementedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_implementations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_verifications" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"result" TEXT NOT NULL,
"evidence" JSONB,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resolutions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"resolvedBy" TEXT NOT NULL,
"resolvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "resolutions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "investigations_problemId_createdAt_idx" ON "investigations"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "root_causes_problemId_createdAt_idx" ON "root_causes"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "solutions_problemId_createdAt_idx" ON "solutions"("problemId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "solution_implementations_solutionId_key" ON "solution_implementations"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "solution_verifications_solutionId_key" ON "solution_verifications"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "resolutions_ticketId_key" ON "resolutions"("ticketId");
-- AddForeignKey
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solutions" ADD CONSTRAINT "solutions_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_implementations" ADD CONSTRAINT "solution_implementations_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_verifications" ADD CONSTRAINT "solution_verifications_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "resolutions" ADD CONSTRAINT "resolutions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "agents" ADD COLUMN "userId" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
-- The default above exists only to satisfy the NOT NULL constraint against this (empty)
-- table at migration time — application code always provides a real bcryptjs hash on every
-- User row it creates (specs/010-identity-auth/data-model.md), so the default itself is
-- dropped immediately below to keep schema.prisma and the live database in agreement (no
-- default declared in the Prisma schema).
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP DEFAULT;
-- CreateIndex
CREATE UNIQUE INDEX "agents_userId_key" ON "agents"("userId");
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
+229
View File
@@ -18,9 +18,14 @@ model User {
email String @unique
name String
role UserRole @default(CUSTOMER)
passwordHash String // bcryptjs hash — never the plaintext password; see
// specs/010-identity-auth/data-model.md
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
@@map("users")
}
@@ -43,6 +48,8 @@ model Product {
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
slaPolicies SLAPolicy[]
escalationPolicies EscalationPolicy[]
@@map("products")
}
@@ -93,6 +100,7 @@ model Category {
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
problems Problem[]
tickets Ticket[]
slaPolicies SLAPolicy[]
@@map("categories")
}
@@ -114,6 +122,10 @@ model Problem {
category Category? @relation(fields: [categoryId], references: [id])
tickets Ticket[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
@@map("problems")
}
@@ -145,6 +157,9 @@ model Ticket {
aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
resolution Resolution?
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -407,6 +422,11 @@ model Agent {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Nullable link to the login identity this routing/skills profile belongs to — schema
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
userId String? @unique
user User? @relation(fields: [userId], references: [id])
skills AgentSkill[]
availability AgentAvailability?
assignments Assignment[]
@@ -465,6 +485,8 @@ model HierarchyNode {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escalationRules EscalationRule[]
@@index([parentId, order])
@@index([active])
@@map("hierarchy_nodes")
@@ -485,6 +507,7 @@ model Assignment {
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@index([agentId, isCurrent])
@@map("assignments")
}
@@ -502,3 +525,209 @@ model AssignmentHistory {
@@index([ticketId, createdAt])
@@map("assignment_history")
}
model SLAPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard when null — see data-model.md "Resolution"
product Product? @relation(fields: [productId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
problemTypeId String? // free-text — no ProblemType table exists in this codebase
priority String? // free-text, matches Ticket.priority
firstResponseMinutes Int
investigationMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
resolutionMinutes Int
customerResponseMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
businessCalendarId String? // null = 24/7, no exclusions — an explicit policy choice
businessCalendar BusinessCalendar? @relation(fields: [businessCalendarId], references: [id])
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slaRuns SLARun[]
@@index([productId, categoryId, active])
@@map("sla_policies")
}
model SLARun {
id String @id @default(cuid())
ticketId String @unique // one run per ticket — no reopen-cycle support (spec.md Assumptions)
ticket Ticket @relation(fields: [ticketId], references: [id])
policyId String
policy SLAPolicy @relation(fields: [policyId], references: [id])
firstResponseDueAt DateTime?
resolutionDueAt DateTime?
status String // running | paused | warning | breached | completed
pausedAt DateTime?
resumedAt DateTime?
breachedAt DateTime?
// Additive refinement beyond doc06 (research.md/data-model.md): records a first-response
// breach separately from the resolution-timer breach status above, and doubles as the
// idempotency guard for the breach-detection sweep (never re-fires on the same run).
firstResponseBreachedAt DateTime?
completedAt DateTime?
@@index([status, resolutionDueAt])
@@index([status, firstResponseDueAt])
@@map("sla_runs")
}
model BusinessCalendar {
id String @id @default(cuid())
name String
timezone String // IANA zone name, e.g. "America/New_York"
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
holidays Holiday[]
policies SLAPolicy[]
@@map("business_calendars")
}
model Holiday {
id String @id @default(cuid())
calendarId String
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
date DateTime // compared by calendar date only, in the calendar's own timezone
description String?
@@index([calendarId, date])
@@map("holidays")
}
model EscalationPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
product Product? @relation(fields: [productId], references: [id])
active Boolean @default(true)
rules EscalationRule[]
@@index([productId, active])
@@map("escalation_policies")
}
model EscalationRule {
id String @id @default(cuid())
policyId String
policy EscalationPolicy @relation(fields: [policyId], references: [id])
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
// (research.md)
condition Json // stored, not evaluated, by this feature (research.md)
targetNodeId String
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
active Boolean @default(true)
@@index([policyId, triggerType, active])
@@map("escalation_rules")
}
model EscalationEvent {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
ruleId String? // null for a manual escalation or a breach with no matching rule
fromNodeId String?
toNodeId String?
reason String
triggeredBy String // system | <agentId> | <adminId>
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("escalation_events")
}
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String? // never exposed on a customer-facing read — see
// specs/009-problem-resolution/spec.md FR-003
status String @default("open") // open | complete
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("investigations")
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business |
// contributing_factor
description String
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("root_causes")
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
@@index([problemId, createdAt])
@@map("solutions")
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
@@map("solution_implementations")
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
@@map("solution_verifications")
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
outcome String
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
resolvedAt DateTime @default(now())
@@map("resolutions")
}
+7
View File
@@ -1,9 +1,15 @@
import { randomUUID } from 'crypto';
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding demo environment data...');
// Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is
// never a real login identity (customer identity is exclusively SaaS-delegated, see
// specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy
// the column's NOT NULL constraint; this account can never authenticate via /auth/login.
await prisma.user.upsert({
where: { email: 'john.doe@example.com' },
update: {},
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
email: 'john.doe@example.com',
name: 'John Doe (Demo Customer)',
role: UserRole.CUSTOMER,
passwordHash: await bcrypt.hash(randomUUID(), 10),
},
});
}
+8
View File
@@ -1,4 +1,10 @@
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) —
// never used for a real deployment, which provisions its own first admin out of band.
const DEV_ADMIN_PASSWORD = 'ChangeMe123!';
const DEV_AGENT_PASSWORD = 'ChangeMe123!';
export async function seedRoles(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'admin@supporthub.internal',
name: 'System Admin',
role: UserRole.ADMIN,
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
},
});
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'agent@supporthub.internal',
name: 'Default Support Agent',
role: UserRole.AGENT,
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
},
});
}
@@ -0,0 +1,85 @@
# Specification Quality Checklist: SLA and Escalation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Scope is Phase 8 per `docs/10-implementation-roadmap.md`: the SLA policy engine, business
calendar/holiday support, durable pause/resume, and the rule-driven escalation engine —
explicitly bounded to the two SLA-derived trigger types (`resolution_breach`/
`first_response_breach`) this feature can compute a real signal for, out of doc 05 §6's ten;
the other eight remain valid, storable rule configuration with no event source feeding them
yet, same convention 006 already established for `HierarchyNode.assignmentStrategy`/
`slaPolicyId` being real data before 007/008 gave them real consumers.
- This is the first feature to give 006's `HierarchyNode.slaPolicyId`/`escalationPolicyId` fields
(stored as free-text references since 006, unvalidated) a real target to resolve against.
- Escalation's re-assignment path reuses 007's `AssignmentEngine` scoped to a *specific* target
node, not 007's general unscoped resolution — a genuinely different call shape 007 doesn't
expose yet, to be added during planning.
- Constitution Principle VII is directly load-bearing here in a new way: this is the first
feature whose entire second half (SLA pause/resume/breach) is *only* correct if it survives a
process restart — 003's ticket-status concurrency and 007's round-robin concurrency both
guarded against corruption under concurrent requests within a running process; this guards
against silent loss of state across the process not running at all for a while.
- All items pass; no revision iterations were needed.
## Implementation Notes (added during /speckit-implement)
- `DomainEventName.TICKET_ASSIGNED` (defined since 007-orchestration-assignment) and
`SLA_BREACHED`/`ESCALATION_TRIGGERED` (defined even earlier) had never been published by any
code until this feature — `AssignmentEngine.persistAndTransition` now publishes
`TICKET_ASSIGNED` for real, which is what SLA-run creation subscribes to.
- `src/jobs/sla/index.ts` and `src/jobs/escalation/index.ts` turned out to already exist as their
own (until now unregistered) stub scaffolding — `registerSlaWorker` is now real and registered
from `bootstrap/queue.bootstrap.ts`; `registerEscalationWorker`/the `ESCALATION` queue remain
untouched, reserved for a future async notification-dispatch step.
- `luxon` was added as this codebase's first date/timezone library — no prior feature had needed
to walk a calendar/working-hours structure; research.md documents the choice over `date-fns`
and hand-rolled arithmetic.
- Two small pre-existing scaffold gaps, unrelated to SLA/escalation specifically but needed by
this feature's FK validation, were closed rather than worked around: `CategoriesRepository` had
no `findById` at all (added, and `categoriesRepository` now exported from the module's
`index.ts`, matching every other catalog repository).
- `EscalationEvent.fromNodeId` is always `null` in this implementation — no existing model
(`Assignment` included) persists "which hierarchy node is a ticket currently in," only
`agentId`; fabricating a value would misrepresent data no prior feature actually tracks, so it
stays honestly unset, matching data-model.md's own "if any" phrasing.
- `README.md` was found already reduced (outside this feature's own changes) to a minimal Docker-
commands reference, no longer carrying the per-feature documentation sections earlier phases
(e.g. 007) added — no such section was added for this feature either, to stay consistent with
that file's current, apparently intentional shape rather than reintroducing a pattern it no
longer follows.
- Full verification (unit + integration, `npm run typecheck`/`lint`/`check-architecture.ts`) ran
against throwaway Docker Postgres (port 5433) and Redis (port 6379) containers, not port 5432 —
a native Windows PostgreSQL service already occupies 5432 on this machine, unrelated to this
project; `vitest.config.ts`'s hardcoded `DATABASE_URL` was updated from 5432 to 5433 to match.
148 of 150 relevant tests pass; the only 2 failures (`ticket-attachments.test.ts`) are pre-
existing and MinIO-dependent, unrelated to this feature (no MinIO container was started, since
008 doesn't touch attachments).
@@ -0,0 +1,77 @@
# Contract: SLA and Escalation
Every admin CRUD/manual-escalation route below is gated by `fastify.authenticate` (research.md —
known limitation inherited from 002/003/004/005/006/007). SLA-run creation, pause/resume, and
breach detection have no public trigger endpoint — they run automatically off the domain event
bus and the breach-detection BullMQ job (research.md), matching 007's "orchestration has no
manual trigger endpoint" precedent.
## SLA Policy admin
- `POST /admin/sla-policies` — body `{ name, productId?, categoryId?, problemTypeId?, priority?,
firstResponseMinutes, investigationMinutes?, resolutionMinutes, customerResponseMinutes?,
businessCalendarId? }`. `404` if `productId`/`categoryId`/`businessCalendarId` is given but
doesn't exist.
- `GET /admin/sla-policies` — list, optionally filtered by `productId`.
- `GET /admin/sla-policies/:id` — `404` if not found.
- `PATCH /admin/sla-policies/:id` — partial update, same existence checks as create.
- `DELETE /admin/sla-policies/:id` — soft delete (`active: false`), never a hard delete (matches
005/006 precedent for policy-shaped config the system may still reference).
## Business Calendar admin
- `POST /admin/business-calendars` — body `{ name, timezone, workingHours }`. `400` if
`timezone` isn't a valid IANA zone name, or if any `workingHours` entry's `start`/`end` isn't a
valid `HH:mm` pair with `start < end`.
- `GET /admin/business-calendars` / `GET /admin/business-calendars/:id` — `404` if not found.
- `PATCH /admin/business-calendars/:id` — same validation as create.
- `POST /admin/business-calendars/:id/holidays` — body `{ date, description? }`.
- `DELETE /admin/business-calendars/:id/holidays/:holidayId`.
## Escalation Policy / Rule admin
- `POST /admin/escalation-policies` — body `{ name, productId? }`. `404` if `productId` given
but doesn't exist.
- `GET /admin/escalation-policies` / `GET /admin/escalation-policies/:id`.
- `POST /admin/escalation-policies/:id/rules` — body `{ triggerType, condition, targetNodeId,
notify, active? }`. `triggerType` validated against doc 05 §6's full 10-value set (research.md
— only 2 are ever evaluated, all 10 are valid config). `404` if `targetNodeId` doesn't
reference an existing `HierarchyNode` (FR-012).
- `PATCH /admin/escalation-policies/:id/rules/:ruleId` — same validation as create.
- `DELETE /admin/escalation-policies/:id/rules/:ruleId` — soft delete (`active: false`).
## SLA run reads
- `GET /tickets/:ticketId/sla-run` — the current `SLARun` for the ticket, or `404` if none was
ever created (e.g. the ticket was never assigned, or no policy matched at assignment time).
## Manual escalation
- `POST /tickets/:ticketId/escalate` — body `{ targetNodeId, reason }`. `404` if `ticketId` or
`targetNodeId` doesn't exist (FR-017). Records an `EscalationEvent` with `triggeredBy` set to
the calling actor and re-assigns via the same scoped-assignment path a rule-fired escalation
uses (research.md).
## Guarantees (callable contract)
1. **An `SLARun` is created the moment a ticket receives its first successful assignment (007),
if and only if an active `SLAPolicy` matches the ticket's context** — never for an unassigned
ticket, never inventing a default policy when none matches (FR-005, US2).
2. **`firstResponseDueAt`/`resolutionDueAt` are always computed by walking the resolved policy's
business calendar**, excluding non-working hours, weekends, and holidays — never a naive
`createdAt + N hours` addition (FR-004, SC-001).
3. **A ticket entering `WAITING_FOR_CUSTOMER` pauses its running `SLARun`; leaving it resumes
with the remaining time preserved exactly** — the paused duration is neither double-counted
nor dropped, and this holds even if the process restarts while paused (FR-007/FR-008, SC-002).
4. **A breach is detected within one breach-detection job cycle of its due date passing**, even
if the process wasn't running at the exact due instant — never silently missed (FR-009,
SC-003).
5. **A run that completes before its due date is never marked breached; a paused run is never
marked breached** (FR-010/FR-011).
6. **Every `resolution_breach` or `first_response_breach` detection evaluates every active
`EscalationRule` matching that trigger type under the ticket's resolved `EscalationPolicy`,
firing one `EscalationEvent` (and one scoped re-assignment) per matching rule** — a breach
with no matching rule is still recorded as breached, with no `EscalationEvent` (FR-013/FR-014/
FR-015, SC-004).
7. **A manual escalation to a nonexistent `targetNodeId` always returns `404` and creates neither
an `EscalationEvent` nor a reassignment** (FR-017, SC-005).
+127
View File
@@ -0,0 +1,127 @@
# Data Model: SLA and Escalation
Field shapes below match `docs/06-database-schema.md` "Domain: SLA" / "Domain: Escalation"
exactly, with two additive refinements called out explicitly (both purely additive — nothing in
doc 06's shape is removed or narrowed).
## SLAPolicy
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `name` | `String` | |
| `productId` | `String?` | wildcard when `null` — FK to `Product.id` |
| `categoryId` | `String?` | wildcard when `null` — FK to `Category.id` |
| `problemTypeId` | `String?` | wildcard when `null` — free-text reference, no `ProblemType` table exists in this codebase (problem taxonomy lives on `Problem` directly, per 004/005); stored and matched as opaque text |
| `priority` | `String?` | wildcard when `null` — free-text, matches `Ticket.priority` |
| `firstResponseMinutes` | `Int` | required — every policy must define a first-response target |
| `investigationMinutes` | `Int?` | stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) |
| `resolutionMinutes` | `Int` | required |
| `customerResponseMinutes` | `Int?` | stored per doc 06; not read by any calculation in this feature (spec.md Assumptions) |
| `businessCalendarId` | `String?` | FK to `BusinessCalendar.id`; `null` means "24/7, no exclusions" (an explicit policy choice, not a missing-calendar error) |
| `active` | `Boolean @default(true)` | inactive policies are excluded from resolution |
| `createdAt` / `updatedAt` | `DateTime` | `updatedAt` used as the resolution tie-break (research.md) |
**Validation** (Zod, at the schema layer): `firstResponseMinutes > 0`, `resolutionMinutes > 0`,
`investigationMinutes`/`customerResponseMinutes` positive when present; `productId`/`categoryId`/
`businessCalendarId` must reference an existing row when provided (repository-level existence
check, same convention as every prior feature's FK-shaped free-form input).
**Resolution** (`findApplicablePolicy(ticket)`): among active policies where each set scope field
equals the ticket's corresponding value and each unset field is a wildcard, return the one with
the fewest wildcards; tie-break by latest `updatedAt`. No match → no `SLARun` is created (FR-005).
## SLARun
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `ticketId` | `String @unique` | one run per ticket — no reopen-cycle support (spec.md Assumptions) |
| `policyId` | `String` | FK to `SLAPolicy.id`, the policy resolved at creation time |
| `firstResponseDueAt` | `DateTime?` | computed via the calendar walk from `assignedAt`; `null` when the policy has no `firstResponseMinutes`... (always present per policy validation, so effectively always set) |
| `resolutionDueAt` | `DateTime?` | computed the same way from `resolutionMinutes` |
| `status` | `String` | `running \| paused \| warning \| breached \| completed` — matches doc 06 exactly |
| `pausedAt` | `DateTime?` | set when `status` transitions to `paused`; cleared on resume |
| `resumedAt` | `DateTime?` | last resume timestamp, informational (audit convenience, mirrors `AssignmentHistory`'s always-append style) |
| `breachedAt` | `DateTime?` | set once, the first time `resolutionDueAt` is detected passed while `running` |
| `completedAt` | `DateTime?` | set when the ticket reaches a resolved/closed status; a completed run is never later marked breached (FR-011) |
| **`firstResponseBreachedAt`** | `DateTime?` | **additive refinement, not in doc 06's literal listing** — records the first-response breach separately from `status`/`breachedAt`, which this feature reserves for the resolution timer; doubles as the idempotency guard for the breach-detection job (research.md) |
**Status transitions** (enforced in the service layer, not a DB constraint — same convention as
`Ticket.status`'s 12-state machine in 003): `running → paused` (on ticket entering
`WAITING_FOR_CUSTOMER`) → `running` (on leaving it, due dates shifted forward by the pause
duration) → `breached` (resolution due date passed while running) → `completed` (ticket resolved/
closed, from any of `running`/`paused`/`breached`). `warning` is reserved by doc 06's enum for a
future near-breach signal; no code path in this feature sets it (documented, not implemented —
same discipline as the 8 inert `EscalationRule.triggerType` values).
## BusinessCalendar
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `name` | `String` | |
| `timezone` | `String` | IANA zone name (e.g. `"America/New_York"`), validated against `Intl.supportedValuesOf('timeZone')` at the schema layer |
| `workingHours` | `Json` | shape: `{ mon?: {start: "HH:mm", end: "HH:mm"}, tue?: ..., wed?: ..., thu?: ..., fri?: ..., sat?: ..., sun?: ... }` — a missing key means zero working hours that weekday (research.md) |
| `holidays` | `Holiday[]` | |
## Holiday
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `calendarId` | `String` | FK to `BusinessCalendar.id` |
| `date` | `DateTime` | compared by calendar date only (year/month/day in the calendar's own timezone), not by exact instant |
| `description` | `String?` | |
## EscalationPolicy
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `name` | `String` | |
| `productId` | `String?` | wildcard (global) when `null` |
| `active` | `Boolean @default(true)` | |
| `rules` | `EscalationRule[]` | |
## EscalationRule
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `policyId` | `String` | FK to `EscalationPolicy.id` |
| `triggerType` | `String` | one of doc 05 §6's 10 values; schema accepts all 10, only `resolution_breach`/`first_response_breach` are ever evaluated (research.md) |
| `condition` | `Json` | stored, not evaluated, by this feature (research.md) |
| `targetNodeId` | `String` | FK to `HierarchyNode.id`, validated to exist at creation time (FR-017's rejection rule applies identically here) |
| `notify` | `Json` | who/how to notify — stored and returned only; no delivery mechanism exists (spec.md Assumptions, `platform/notifications` untouched) |
| `active` | `Boolean @default(true)` | |
## EscalationEvent
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `ticketId` | `String` | FK to `Ticket.id` |
| `ruleId` | `String?` | `null` for a manual escalation or a breach with no matching rule |
| `fromNodeId` | `String?` | the node the ticket was assigned to immediately before this event, if any |
| `toNodeId` | `String?` | the rule's `targetNodeId` (or the manually-specified node); `null` when no rule matched |
| `reason` | `String` | free text — for a rule firing, a generated description (e.g. `"resolution SLA breached"`); for manual escalation, the caller-supplied reason |
| `triggeredBy` | `String` | `system \| <agentId> \| <adminId>` — never a bare `"customer"` literal in this feature's own write paths (doc 06 lists it as a valid value for a future customer-initiated trigger type, not one this feature fires) |
| `createdAt` | `DateTime @default(now())` | |
## Relations added to existing models
- `Ticket.slaRun SLARun?` (inverse of `SLARun.ticketId @unique`)
- `Ticket.escalationEvents EscalationEvent[]`
- `Product.slaPolicies SLAPolicy[]`, `Product.escalationPolicies EscalationPolicy[]`
- `Category.slaPolicies SLAPolicy[]`
- `HierarchyNode.escalationRules EscalationRule[]` (inverse of `targetNodeId`)
## Out of scope for this data model (per spec.md Assumptions)
- No `investigationDueAt`/`customerResponseDueAt` fields — doc 06's `SLARun` doesn't define them,
and nothing in spec.md's acceptance scenarios exercises them; `investigationMinutes`/
`customerResponseMinutes` remain stored-but-unused on `SLAPolicy`, same as doc 06 itself defines.
- No FK tightening of `HierarchyNode.slaPolicyId`/`escalationPolicyId` (still free-text, per 006) —
SLA policy resolution in this feature is scope-based (product/category/problemType/priority),
not looked up through those two fields; they remain unvalidated free text, unchanged from 006.
+163
View File
@@ -0,0 +1,163 @@
# Implementation Plan: SLA and Escalation
**Branch**: `008-sla-escalation` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/008-sla-escalation/spec.md`
## Summary
Populate the existing `platform/business-calendars`, `orchestration/sla`, and
`orchestration/escalation` stub directories (today: `isWorkingHour` hardcoded `true`, a
`SlaDueDateCalculator` doing naive `createdAt + hours` addition, an `EscalationEngine` that
always returns `{ escalated: false }`) with the real engine: `business-calendars` walks a
`BusinessCalendar`'s `workingHours`/`Holiday` records via `luxon` to compute calendar-aware
durations; `sla` resolves the most-specific matching `SLAPolicy` on a ticket's first successful
007 assignment, computes `firstResponseDueAt`/`resolutionDueAt` through the calendar walk,
durably pauses/resumes on `WAITING_FOR_CUSTOMER` transitions (a `TICKET_UPDATED` domain-event
subscriber), and detects breaches via a repeatable BullMQ job; `escalation` resolves the
applicable `EscalationPolicy`, fires an `EscalationEvent` per matching active `EscalationRule` on
a breach (or on a manual request), and re-assigns through a new, specifically-scoped entry point
added to 007's `AssignmentEngine`.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod, BullMQ (already a dependency — new
repeatable job, same queue infrastructure as 003's attachment scan and 005's AI session queues),
`luxon` (**new** — the first date/timezone library in this codebase; research.md).
**Storage**: PostgreSQL via Prisma (new `SLAPolicy`, `SLARun`, `BusinessCalendar`, `Holiday`,
`EscalationPolicy`, `EscalationRule`, `EscalationEvent` models). No new infrastructure — reuses
`src/infrastructure/queue` for the breach-detection job, same as every prior BullMQ consumer.
**Testing**: Vitest — unit tests for the calendar-walk algorithm (weekend/holiday exclusion,
partial-day clipping, timezone correctness), the most-specific SLA-policy match, and pause/resume
arithmetic; integration tests for the full assignment→SLA-run→pause/resume→breach→escalation
flow against real Postgres/Redis, including one test that rebuilds `buildApp()` mid-test to
verify pause/resume state survives a genuine process-restart boundary (Constitution Principle
VII, quickstart Scenario 3) — the first feature in this codebase whose correctness depends on
that guarantee specifically, not just within-process concurrency safety.
**Target Platform**: Same Fastify modular monolith. Populates existing module directories:
`src/modules/platform/business-calendars/`, `src/modules/orchestration/{sla,escalation}/`. Adds
one new BullMQ worker registration alongside the existing ones in `src/infrastructure/queue`.
**Project Type**: Backend service — single project.
**Performance Goals**: The breach-detection job must complete a full scan-and-mark pass in
well under its own tick interval even as `SLARun` rows accumulate — indexed on
`(status, resolutionDueAt)` so the query stays a targeted range scan, not a table scan. Not
otherwise performance-sensitive.
**Constraints**: MUST NOT compute due dates naively (FR-004); MUST create an `SLARun` only on a
successful assignment with a matching policy (FR-005); MUST survive a process restart for
pause/resume and breach detection (FR-007/FR-008/FR-009, Constitution Principle VII); MUST never
mark a completed-in-time or paused run breached (FR-010/FR-011); MUST re-assign scoped to the
rule's exact `targetNodeId`, not a fresh unscoped resolution (FR-014).
**Scale/Scope**: Three populated modules, one new dependency, three admin CRUD surfaces (SLA
policies, business calendars, escalation policies/rules), one manual-escalation endpoint, one
new BullMQ repeatable job, one new method on 007's `AssignmentEngine`. Explicitly excludes:
notification delivery, 8 of doc 05's 10 escalation trigger types, investigation/customer-response
timers, SLA restart on ticket reopen (see spec.md Assumptions).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | SLA/escalation reference `Ticket`/`HierarchyNode`/`Agent` — all SupportHub's own domain. No SaaS identity touched. | PASS |
| II. Configuration Over Hardcoding | Every SLA target, calendar, and escalation rule is admin-configured data, not a hardcoded constant — replacing the literal hardcoded-`true`/naive-arithmetic stubs is the point of this feature. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Three modules follow the standard shape; `orchestration/sla``platform/business-calendars`, `orchestration/sla``orchestration/escalation` (a breach sweep calls escalation firing directly, research.md), and `orchestration/escalation``orchestration/assignments` (007, for the new scoped-assignment method) are all one-directional — no cycle, since 007 doesn't import anything from 008 and escalation never imports sla back. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | No AI involvement in this feature at all — every decision (policy match, breach, escalation) is deterministic. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | `EscalationEvent` is the durable, append-only record doc 06 defines for every escalation, automatic or manual — mirrors `AssignmentHistory`'s established shape. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | This principle's "state must survive a process restart" clause is directly load-bearing here for the first time as the primary correctness requirement (not just a concurrent-request race) — pause/resume and breach detection are both pure-DB-state-plus-polling-job, no in-memory timer anywhere (research.md, quickstart Scenario 3). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | SLA/escalation reference `Ticket`, not `Problem` — doesn't touch the distinction. | PASS — N/A |
| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure, plus the one new `luxon` dependency (justified in research.md — no timezone-correct alternative already exists in this codebase). | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Worth calling out against Principle VII
explicitly: pause/resume shifts a single absolute `DateTime` column and breach detection is a
plain polling query — by design there is no code path in this feature that could even *appear*
to depend on in-memory state surviving a restart, which is what makes the restart-boundary
integration test (quickstart Scenario 3) a meaningful verification rather than a formality.
## Project Structure
### Documentation (this feature)
```text
specs/008-sla-escalation/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── package.json # MODIFIED — add luxon, @types/luxon
├── prisma/
│ └── schema.prisma # MODIFIED — add SLAPolicy, SLARun,
│ BusinessCalendar, Holiday, EscalationPolicy,
│ EscalationRule, EscalationEvent
├── src/
│ ├── events/
│ │ └── handlers/index.ts # MODIFIED — first real publish of the
│ │ existing-but-unused TICKET_ASSIGNED event
│ │ (from 007's persistAndTransition), plus two
│ │ new TICKET_UPDATED subscribers (pause/
│ │ resume, completion) — research.md
│ ├── jobs/
│ │ └── sla/index.ts # REPLACED stub — schedules the repeatable
│ │ breach-detection job (research.md); jobs/
│ │ escalation/ stays untouched (reserved for a
│ │ future notification-dispatch step)
│ └── modules/
│ ├── platform/
│ │ └── business-calendars/ # REPLACED stub — full standard shape +
│ │ ├── controller/ routes/ schema/ calculators/ for the day-walk algorithm
│ │ │ repository/ service/ types/
│ │ │ mapper/ constants/ index.ts
│ │ └── calculators/
│ └── orchestration/
│ ├── assignments/ # 007, MODIFIED — persistAndTransition
│ │ └── engine/assignment.engine.ts publishes TICKET_ASSIGNED; new
│ │ assignToSpecificNode() method for
│ │ escalation's scoped re-assignment
│ ├── sla/ # REPLACED stub — full standard shape, keeps
│ │ ├── controller/ routes/ schema/ its existing calculators/ dir (due-date
│ │ │ repository/ service/ types/ calculator replaced, not removed) and adds
│ │ │ mapper/ constants/ index.ts engine/ for breach evaluation
│ │ ├── engine/ (policy resolution + breach detection)
│ │ └── calculators/ (due-date calculator, replaced)
│ └── escalation/ # REPLACED stub — full standard shape, keeps
│ ├── controller/ routes/ schema/ engine/ for rule matching + firing
│ │ repository/ service/ types/
│ │ mapper/ constants/ index.ts
│ └── engine/
└── tests/
├── unit/
│ ├── platform/business-calendars/ # calendar-walk algorithm
│ └── orchestration/{sla,escalation}/ # policy match, breach logic, rule match
└── integration/ # full flow incl. restart-boundary test
```
**Structure Decision**: Single project. `business-calendars` gets a full standard shape (not
internal-only) since it needs its own CRUD surface for calendars/holidays, unlike 007's
internal-only `routing` module. `sla` and `escalation` each keep the `engine/` extension doc 07
§8 reserves for modules with real decision logic, matching 005/007 precedent.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+77
View File
@@ -0,0 +1,77 @@
# Quickstart: Validating SLA and Escalation
Prerequisites: migrations applied; at least one hierarchy node/agent/product set up per
006-support-organization's and 007-orchestration-assignment's own quickstarts, since this feature
starts an `SLARun` on a successful 007 assignment and escalation re-assigns through 007's engine.
## Scenario 1 — policy definition and most-specific match (User Story 1)
1. Create a global `SLAPolicy` (`productId: null`, ...) and a second, product-scoped policy for
the same product with tighter minutes.
2. Assign a ticket for that product (triggers Scenario 2's creation path).
3. **Expected**: the `SLARun` resolves the product-scoped policy, not the global one.
4. Delete the product-scoped policy's applicability (set `active: false`). Assign a new ticket for
the same product. **Expected**: falls back to the global policy.
## Scenario 2 — calendar-aware due dates on assignment (User Story 2)
1. Create a `BusinessCalendar` with `workingHours` only Mon-Fri 09:00-17:00, `timezone`
`"America/New_York"`, and one `Holiday` next Monday. Attach it to an `SLAPolicy` with
`resolutionMinutes: 480` (one working day).
2. Assign a ticket late on a Friday afternoon so that a naive `createdAt + 480min` would land on
Saturday.
3. **Expected**: `resolutionDueAt` lands the following Tuesday (Monday excluded as a holiday),
never on the weekend.
4. Assign a ticket for a product/category/priority combination matching no active policy.
**Expected**: no `SLARun` is created; `GET /tickets/:ticketId/sla-run` returns `404`.
## Scenario 3 — durable pause/resume across a process restart (User Story 3)
1. Assign a ticket (Scenario 2), note `resolutionDueAt`.
2. Transition the ticket to `WAITING_FOR_CUSTOMER`. **Expected**: `SLARun.status` becomes
`paused`, `pausedAt` set.
3. Restart the application process (rebuild `buildApp()` fresh, simulating the restart the
constitution's Principle VII requires surviving).
4. Wait a real interval, then transition the ticket out of `WAITING_FOR_CUSTOMER`.
**Expected**: `SLARun.status` becomes `running`; the new `resolutionDueAt` equals the original
plus exactly the paused wall-clock duration — never reset to a fresh full duration.
## Scenario 4 — durable breach detection (User Story 4)
1. Assign a ticket against a policy with a very short `resolutionMinutes` (e.g. `1`) and a 24/7
calendar (`businessCalendarId: null`).
2. Wait past `resolutionDueAt` without resolving the ticket.
3. **Expected**: within one breach-detection job tick, `SLARun.status` becomes `breached`,
`breachedAt` set.
4. Repeat, but resolve the ticket before `resolutionDueAt` passes. **Expected**: `status` reaches
`completed` and is never later flipped to `breached` by a subsequent job tick.
5. Repeat, but pause the run before `resolutionDueAt` passes. **Expected**: the run is never
marked `breached` while paused, even after the due instant passes.
## Scenario 5 — breach-triggered escalation and scoped re-assignment (User Story 5)
1. Create an `EscalationPolicy` scoped to the ticket's product with an active `EscalationRule`
(`triggerType: "resolution_breach"`, `targetNodeId` set to a second hierarchy node with a
different eligible agent).
2. Reach a `breached` run (Scenario 4). **Expected**: exactly one `EscalationEvent` is created
(`ruleId` set, `toNodeId` the rule's `targetNodeId`), and the ticket is reassigned to an agent
eligible under that specific node — not re-resolved from the ticket's original context.
3. Repeat with no matching `EscalationRule` for the resolved policy. **Expected**: the run is
still marked `breached`; no `EscalationEvent` is created.
## Scenario 6 — manual escalation (User Story 6)
1. `POST /tickets/:ticketId/escalate` with a valid `targetNodeId` and a reason.
2. **Expected**: an `EscalationEvent` is created (`ruleId: null`, `triggeredBy` the calling
actor), and the ticket is reassigned through the same scoped path as Scenario 5.
3. Repeat with a nonexistent `targetNodeId`. **Expected**: `404`, no `EscalationEvent` created.
4. Trigger a manual escalation on a ticket whose run is concurrently being auto-escalated by
Scenario 5's breach path. **Expected**: both `EscalationEvent` rows are recorded; the final
assignment reflects 007's already-tested concurrency handling, not a corrupted double-write.
## What "done" looks like
All six scenarios pass, and together they demonstrate every functional requirement and success
criterion in `spec.md` — including SC-002's explicit restart-survival requirement, which must be
verified by an actual fresh `buildApp()` in the middle of the test, not merely by asserting on
stored field values without ever exercising a real process boundary.
+240
View File
@@ -0,0 +1,240 @@
# Phase 0 Research: SLA and Escalation
## Decision: Module placement — three existing stubs, mapped directly
- **Decision**: `platform/business-calendars` (currently a one-file stub,
`isWorkingHour` hardcoded `true`), `orchestration/sla` (stub `SlaEngine.evaluateSlaTargets`
always returns `NORMAL`; stub `SlaDueDateCalculator` does naive `createdAt + hours`), and
`orchestration/escalation` (stub `EscalationEngine.triggerEscalation` always returns
`{ escalated: false }`) are populated directly, matching doc 07's placement exactly — no new
module locations invented.
- **Rationale**: Documented layout, not an open choice; every stub's current behavior is exactly
what doc 05 §5 explicitly warns against (`SlaDueDateCalculator`'s naive addition is the literal
anti-pattern FR-004 forbids) — replacing it is the point of this feature.
- **Alternatives considered**: None.
## Decision: A real timezone-aware date library — `luxon` — is a genuinely new dependency
- **Decision**: Add `luxon` (a single package, no companion timezone package needed, IANA
timezone support built in) for every calendar-aware date computation in this feature.
- **Rationale**: No date/timezone library exists anywhere in this codebase yet — every prior
feature's `DateTime`/`Json`-typed "schedule" fields (e.g. 006's `AgentAvailability.
workingHours`) were stored but never actually walked by any code. This feature is the first to
need to *compute* against calendar time correctly (FR-004's explicit "MUST NOT... ignore the
calendar"), and hand-rolling DST-correct, IANA-timezone-aware business-hour arithmetic without
a library is exactly the kind of mistake this system's own constitution warns against elsewhere
("don't reinvent what a library already solves correctly" is this codebase's working norm, even
if not literally in the constitution's text) — matching the same "one new dependency for the
one new genuinely-needed capability" precedent 005 set for `@anthropic-ai/sdk`.
- **Alternatives considered**: `date-fns` + `date-fns-tz` (two packages for the same
capability) — rejected in favor of the single-package option. Hand-rolled arithmetic —
rejected; timezone/DST correctness is precisely the kind of subtly-wrong-most-of-the-time code
a library exists to prevent.
## Decision: `BusinessCalendar.workingHours` shape — one window per weekday
- **Decision**: `{ mon?: { start: "09:00", end: "17:00" }, tue?: ..., ..., sun?: ... }` — three-
letter weekday keys, `HH:mm` 24-hour strings interpreted in the calendar's own `timezone`, a
missing key meaning "not a working day" (FR's "unconfigured day contributes zero time").
- **Rationale**: Doc 05 §5's stated need ("business hours, weekends... per-team schedules") is
satisfied by one contiguous window per day — doc 06 doesn't specify a richer shape (split
shifts), and nothing in spec.md asks for one; a single window per day is the simplest structure
that satisfies every acceptance scenario without speculative complexity.
- **Alternatives considered**: An array of windows per day (split-shift support) — rejected as
unrequested scope; the shape can be extended later (an array is a strict superset) without a
breaking change to a single-window calendar's own data.
## Decision: Calendar-aware due-date arithmetic — a day-by-day walk
- **Decision**: `addBusinessMinutes(start, minutes, calendar, holidays)` walks forward from
`start` one calendar day at a time (in the calendar's timezone): a holiday date or a weekday
with no configured window contributes zero available minutes; otherwise the day's working
window (clipped by `start`'s own time on the first day) contributes up to its own duration,
consumed from the running `minutes` total; the walk ends the moment `minutes` reaches zero,
returning that exact timestamp.
- **Rationale**: This directly implements FR-004 — every acceptance scenario (weekend/holiday
exclusion) is a direct consequence of this algorithm, not a special case bolted on. A
day-granularity loop is bounded (even a multi-week SLA window is, at most, a few dozen
iterations) and easy to unit-test exhaustively.
- **Alternatives considered**: Minute-by-minute simulation — rejected as needlessly slow and
harder to reason about for the same result; day-granularity with within-day clipping is exactly
as correct and far simpler.
## Decision: SLA policy resolution — most-specific match, same shape as 005's confidence policy
- **Decision**: Given a ticket's `productId`/`categoryId`/`problemTypeId`/`priority`, an active
`SLAPolicy` matches when each of its own scope fields is either `null` (wildcard) or equal to
the ticket's corresponding value. Among matches, the one with the fewest `null` scope fields
(most specific) wins; a tie is broken by most-recently-`updatedAt`.
- **Rationale**: FR-002 requires most-specific-match, not first-found — this is the same
resolution shape 005's `AIConfidencePolicy` and 006's hierarchy scope matching already
established in this codebase, reused rather than reinvented a third time.
- **Alternatives considered**: A single global default policy with per-scope overrides (005's
`(productId, categoryId)` two-level shape) — rejected; SLA policy has four independent scope
dimensions doc 06 itself defines, so a strict specificity count (not a fixed lookup order) is
the correct generalization.
## Decision: Pause/resume — shift the absolute due date by the paused wall-clock duration
- **Decision**: Pausing records `pausedAt = now()` (status → `paused`); resuming shifts
`resolutionDueAt` (and `firstResponseDueAt`, if still pending) forward by `now() - pausedAt`
and clears `pausedAt` (status → `running`). No separate "remaining minutes" bookkeeping field
is needed — the absolute due-date field itself, shifted, *is* the remaining-time record.
- **Rationale**: FR-007/FR-008/SC-002 require the paused duration to be excluded, durably, across
a restart — shifting an absolute timestamp already stored in Postgres satisfies both with the
simplest possible mechanism; no in-memory state exists at any point.
- **Alternatives considered**: Storing remaining minutes and recomputing the due date via the
calendar walk on every resume — rejected as unnecessary; the pause window itself doesn't need
calendar-awareness (a paused SLA isn't "elapsing" business time by definition, so shifting by
real wall-clock pause duration is exactly correct, not an approximation).
## Decision: Breach detection — one repeatable BullMQ job, not one delayed job per run
- **Decision**: A single repeatable job (e.g. every 60 seconds) queries every `SLARun` with
`status: 'running'` whose `resolutionDueAt <= now()`, marking each `breached` — and separately,
every running run with `firstResponseDueAt <= now()` and no `firstResponseBreachedAt` yet
(data-model.md refinement) and no `AGENT_MESSAGE` recorded for the ticket, marking
`firstResponseBreachedAt`. Each newly-detected breach triggers escalation-rule evaluation
(research.md below).
- **Rationale**: Constitution Principle VII requires durability, not sub-second precision — a
short-interval polling job is trivially durable (BullMQ's repeatable jobs are themselves
persisted, and a missed tick is caught by the next one) and avoids the bookkeeping a
per-run delayed-job approach would need on every pause/resume (canceling and rescheduling a
delayed job each time, versus just updating a timestamp a polling query already reads).
- **Alternatives considered**: One delayed BullMQ job scheduled per `SLARun`, rescheduled on every
pause/resume — rejected; every pause/resume would need to cancel and re-add a job, doubling the
operations pause/resume already does, for a precision (sub-minute breach detection) nothing in
spec.md actually requires.
## Decision: Escalation firing reuses 007's `AssignmentEngine`, scoped to a specific node
- **Decision**: `AssignmentEngine` (007) gains a new method, `assignToSpecificNode(ticketId,
hierarchyNodeId, strategyOverride?, actor, reason?)` — resolves the eligible-agent set the same
way `RoutingService` already does, but scoped to exactly the given node (its own `skills`
unioned with the ticket's derived required skills, per 007's existing composition rule) rather
than 007's general "find whichever node matches the ticket's context" resolution. Runs the
node's own configured strategy (or `strategyOverride`) and persists through the same
`Assignment`/`AssignmentHistory` mechanism 007 already built and tested for concurrent writes.
- **Rationale**: FR-014 requires escalation to land the ticket specifically at the rule's
`targetNodeId` — 007's existing `evaluateAndAssign` always re-derives the applicable node from
ticket context, which could resolve to a *different* node than the one the rule targeted (the
ticket's context hasn't changed, only its status has). A new, explicit "assign to this node"
entry point is the correct extension, not a workaround.
- **Alternatives considered**: Having 008 duplicate 007's eligible-agent-resolution and
`Assignment`-persistence logic — rejected; directly against this codebase's repeated "extend an
existing module's public surface for a later feature" precedent (004's `productsRepository`,
005's `problemsRepository`, 007's own reuse of 006's `capabilityLookupService`).
## Decision: `EscalationRule.triggerType` is stored broadly; only two types are ever evaluated
- **Decision**: The Zod schema for creating a rule accepts any of doc 06's ten `triggerType`
values — an admin can configure a rule for `inactivity` or `critical_incident` today, and it
will simply never fire (no code path evaluates those triggers yet), rather than being rejected
at creation time.
- **Rationale**: spec.md's Assumptions state this explicitly — storing configuration ahead of the
event source that will eventually feed it is this codebase's established pattern (006's
`slaPolicyId` stored before this feature existed to validate it); rejecting valid doc-06-shaped
configuration at the schema layer would be a regression from that pattern, not a safety
improvement (nothing unsafe happens from an inert rule sitting unfired).
- **Alternatives considered**: Restricting the schema to only the two implemented trigger types —
rejected; would force a breaking schema change on every future phase that wires up one more
trigger type, for no correctness benefit today.
## Decision: Escalation policy resolution — product match or global, most-specific first
- **Decision**: `EscalationPolicy.productId` is the only scope dimension doc 06 gives it (unlike
`SLAPolicy`'s four). Resolution: prefer an active policy whose `productId` equals the ticket's
product; fall back to an active policy with `productId: null` (a global policy) if no
product-specific one exists. A breach with neither is recorded breached with no rule evaluated
(spec.md Edge Cases: "a breach with no matching rule is still recorded as breached").
- **Rationale**: Same most-specific-first shape as `SLAPolicy`, degenerately simple because doc 06
only gives `EscalationPolicy` one scope field — no new resolution mechanism invented.
- **Alternatives considered**: None; doc 06's shape leaves no other reasonable reading.
## Decision: `EscalationRule.condition` is stored, not evaluated, by this feature
- **Decision**: Every active `EscalationRule` under the resolved policy whose `triggerType`
matches the firing breach type (`resolution_breach` or `first_response_breach`) fires — the
`condition` Json field is persisted as given at creation but not parsed or evaluated as a
filter.
- **Rationale**: spec.md's FR-013 says "every matching active EscalationRule fires" scoped by
trigger type alone; nothing in spec.md defines a `condition` grammar to evaluate, and inventing
one now would be exactly the kind of unrequested scope this codebase's established discipline
(005's inert trigger types, 006's unvalidated `slaPolicyId`) consistently avoids. `condition` is
accepted and returned by the CRUD schema so a future feature can give it real meaning without a
breaking change.
- **Alternatives considered**: A minimal condition-matching evaluator (e.g. `{ minPriority }`) —
rejected as speculative; spec.md never asked for conditional rule filtering beyond trigger type.
## Decision: SLA-run lifecycle is wired entirely through the existing domain-event bus
- **Decision**: `DomainEventName.TICKET_ASSIGNED` — defined in `src/events/domain-events.ts`
since 007 but never actually published by any code — is published for the first time by
`AssignmentEngine.persistAndTransition` (007's single shared success path for automatic,
manual, and this feature's new scoped-escalation assignment) with `{ ticketId, agentId,
strategy, actor }`. A new subscriber in `src/events/handlers/index.ts` reacts by resolving the
applicable `SLAPolicy` and creating the `SLARun` — but only if `ticketId` doesn't already have
one (`SLARun.ticketId @unique` makes this a natural existence check), so a re-escalation's
second `TICKET_ASSIGNED` publish (spec.md Assumptions: 1:1 with the *first* assignment only)
is correctly a no-op. Two further `TICKET_UPDATED` subscribers (same file, same pattern as
005's and 007's own) watch for `newStatus === 'WAITING_FOR_CUSTOMER'` (pause) /
`previousStatus === 'WAITING_FOR_CUSTOMER'` (resume), and for `newStatus === 'RESOLVED'`
(complete, per 003's state machine — `RESOLVED` is the terminal status every path reaches
before `CLOSED`/`REOPENED`).
- **Rationale**: Same "a module never needs to import another module it affects" decoupling this
codebase has used consistently since 005 — `orchestration/assignments` doesn't need to know
`orchestration/sla` exists, and `ticketing/tickets` already doesn't know about any of its
status-change consumers. Publishing `TICKET_ASSIGNED` for real is the natural use of an event
this codebase already named and reserved for exactly this purpose.
- **Alternatives considered**: A direct call from `AssignmentEngine.persistAndTransition` into an
`orchestration/sla` service method — rejected; would create the exact cross-module coupling
007→008 the event bus exists to avoid, and would need every future consumer of "a ticket got
assigned" to be added as another direct call in 007's own code.
## Decision: The breach-detection job reuses `src/jobs/sla/`'s existing stub; escalation firing reuses `src/jobs/escalation/`'s
- **Decision**: `registerSlaWorker()` (`src/jobs/sla/index.ts`, currently just a log line) is
extended to, on startup, schedule one BullMQ repeatable job (`queueManager.getQueue(QueueName
.SLA).add('detect-breaches', {}, { repeat: { every: 60_000 } })`) whose processor calls a
single, directly-callable, side-effect-only method — `slaService.runBreachDetectionSweep()` —
containing 100% of the actual logic: the two polling queries from research.md's breach-
detection decision, marking runs breached/first-response-breached, and, for each new breach,
calling `escalationService.handleBreach(ticketId, triggerType)` directly (a plain in-process
call, not a second queued job) since escalation firing has no meaningful reason to be
async-relative-to-detection. `src/jobs/escalation/`'s existing `registerEscalationWorker()`
stub, and its `ESCALATION` queue, are left untouched — reserved, per their own existing
scaffold, for a possible future async notification-dispatch step (spec.md Assumptions: no
notification delivery is built by this feature).
- **Rationale**: `runBreachDetectionSweep()` being a plain importable async function (not
reachable only through a running BullMQ worker) is what makes it possible to write an
integration test for "one job tick" without a real running worker process or a real 60-second
wait — the exact "no worker process in this test, call the job's own logic inline" convention
already established by `tests/integration/ticket-attachments.test.ts` for the malware-scan job.
- **Alternatives considered**: Splitting detection and escalation firing into two separately
queued BullMQ jobs (using the `ESCALATION` queue for the firing step) — rejected as an
unnecessary indirection; nothing in spec.md requires escalation firing to be decoupled in time
from the breach that caused it, and a single sweep function is simpler to test and reason about.
## Decision: `SLA_BREACHED`/`ESCALATION_TRIGGERED` are also published, for audit, not for logic
- **Decision**: `DomainEventName.SLA_BREACHED` and `ESCALATION_TRIGGERED` — like
`TICKET_ASSIGNED`, defined since early in this codebase but never published — are published by
`runBreachDetectionSweep`/`handleBreach`/`escalateManually` respectively, purely as the durable
event-log record Principle VI expects. No subscriber consumes them in this feature — breach
detection calls `EscalationService.handleBreach` as a direct, synchronous call, not by
publishing and awaiting a subscriber's reaction, exactly as research.md's job-design decision
already settled.
- **Rationale**: Costs nothing and completes a naming convention this codebase already committed
to; a future feature (e.g. `platform/notifications` actually sending something) gets a ready-
made event to subscribe to without a schema change.
- **Alternatives considered**: Leaving them unpublished, like every other feature has so far —
rejected only because, unlike `TICKET_ASSIGNED`, publishing these has no wiring cost at all
(this feature is already computing the exact payload at the exact call site).
## Decision: SLA/Escalation admin endpoints reuse the existing auth stub
- **Decision**: Every admin CRUD endpoint (policies, calendars, escalation rules) and the manual-
escalation endpoint are gated by `fastify.authenticate`, same known-limitation stub as every
prior feature.
- **Rationale**: Consistency with established precedent.
- **Alternatives considered**: None.
+308
View File
@@ -0,0 +1,308 @@
# Feature Specification: SLA and Escalation
**Feature Branch**: `008-sla-escalation`
**Created**: 2026-09-03
**Status**: Draft
**Input**: User description: "Phase 8 of docs/10-implementation-roadmap.md: SLA policy engine,
business calendar/holiday support, durable pause/resume via BullMQ, rule-driven escalation
engine, escalation event audit. Per docs/05-orchestration-sla-escalation.md §5-6 and
docs/06-database-schema.md 'Domain: SLA' / 'Domain: Escalation'."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An admin defines SLA policies as configuration (Priority: P1)
An administrator defines an SLA policy — first-response/investigation/resolution/customer-
response time limits — optionally scoped to a product, category, problem type, and/or priority,
and tied to a business calendar. Nothing about SLA thresholds is hardcoded anywhere in the
system.
**Why this priority**: Every later capability in this feature reads a policy that has to exist
first.
**Independent Test**: Create an SLA policy with a resolution limit; confirm it's retrievable and
its fields are stored exactly as given.
**Acceptance Scenarios**:
1. **Given** an admin creates an SLA policy with a resolution time limit, **When** it's saved,
**Then** it's retrievable with every field exactly as given, `active: true` by default.
2. **Given** a policy scoped to a product/category/problem type/priority, **When** two policies
could both apply to the same ticket, **Then** the more specific one is preferred — same
most-specific-match convention this system already uses for confidence policy (005) and
capability scope (006).
3. **Given** an admin creates a business calendar with working hours and holidays, **When** it's
referenced by a policy, **Then** due-date calculations for tickets under that policy use it.
---
### User Story 2 - An SLA run starts automatically when a ticket is assigned, with calendar-aware due dates (Priority: P1)
When orchestration (007) assigns a ticket, an SLA run starts for it automatically, with due dates
computed against the applicable policy's business calendar — never a naive `createdAt + N hours`
that ignores weekends, holidays, or working hours.
**Why this priority**: Nothing else in this feature — breach detection, pause/resume, escalation
— has anything to act on until a real, calendar-correct due date exists.
**Independent Test**: Assign a ticket under a policy with a resolution limit and a calendar whose
working hours exclude a weekend; confirm the computed due date skips the excluded time rather
than counting straight through it.
**Acceptance Scenarios**:
1. **Given** a ticket is assigned (007), **When** an applicable SLA policy resolves for it (User
Story 1's most-specific-match rule), **Then** an SLA run starts with a resolution due date
computed against that policy's calendar.
2. **Given** no policy matches, **When** a ticket is assigned, **Then** no SLA run is created
rather than applying an arbitrary default — this feature does not invent a policy that was
never configured.
3. **Given** a calendar with defined working hours and a holiday, **When** a due date is
computed, **Then** time outside working hours and on holidays is excluded from the countdown.
---
### User Story 3 - SLA pause and resume are durable, not in-memory (Priority: P1)
When a ticket's status indicates it's waiting on the customer, its SLA run pauses — the clock
stops counting against the agent. When it resumes, the clock continues from where it left off,
never from zero and never having kept counting while paused. This state survives a process
restart.
**Why this priority**: Constitution Principle VII names this exact scenario — an SLA that keeps
counting during a customer-caused delay, or that resets on a restart, actively misrepresents
whether a real commitment was honored.
**Independent Test**: Start an SLA run, pause it, wait, resume it; confirm the resulting due date
reflects the paused duration being excluded, not counted twice.
**Acceptance Scenarios**:
1. **Given** a running SLA, **When** the ticket transitions to a waiting-for-customer state,
**Then** the run's status becomes `paused` and its due date stops approaching.
2. **Given** a paused SLA, **When** the ticket transitions back to in-progress, **Then** the run's
status becomes `running` again and the remaining time is preserved from the pause point.
3. **Given** a paused or running SLA run, **When** the process restarts, **Then** its state is
unchanged on restart — pause/resume state is never held only in memory.
---
### User Story 4 - A breach is detected durably, never silently missed (Priority: P2)
When a running SLA's due date passes without the corresponding milestone happening, the run is
marked breached — detected by a durable background job, not a timer that only fires if the
process happens to still be running at the right moment.
**Why this priority**: Depends on User Stories 2-3 (a real due date, correctly paused/resumed)
existing first. A breach that's never detected is worse than no SLA at all — it's a false sense
of a commitment being tracked.
**Independent Test**: Start an SLA run with a very short resolution limit; confirm it's marked
`breached` once the due date passes, even simulating the checking job running in a separate
process invocation from the one that started the run.
**Acceptance Scenarios**:
1. **Given** a running SLA run whose due date has passed, **When** the breach-detection job next
runs, **Then** the run's status becomes `breached` and its `breachedAt` timestamp is recorded.
2. **Given** an SLA run that completes (its milestone actually happens) before its due date,
**When** breach detection later runs, **Then** it is not marked breached — completion is
checked against the actual event, not assumed from elapsed time alone.
3. **Given** a paused SLA run, **When** breach detection runs while it's paused, **Then** it is
never marked breached — a paused clock cannot breach.
---
### User Story 5 - A breach automatically triggers rule-driven escalation (Priority: P2)
An administrator defines escalation rules — trigger condition, target support-hierarchy node, who
to notify. When an SLA breach (or another configured trigger) occurs, the matching rule fires
automatically: the ticket moves toward the rule's target node and orchestration (007)
re-assigns it there, and an escalation event is durably recorded. This is never a hardcoded
`if L1 then L2` — it's evaluated against configured rules.
**Why this priority**: Depends on User Story 4 (a real breach signal to trigger on). This is the
other half of "durable SLA enforcement actually matters" — detecting a breach that nobody acts on
isn't meaningfully different from not detecting it.
**Independent Test**: Configure an escalation rule for `resolution_breach` targeting a specific
hierarchy node; breach an SLA run; confirm an escalation event is recorded, the ticket is
re-assigned via 007 scoped to that specific node, and the rule's configured notification target
is recorded (not necessarily delivered — see Assumptions).
**Acceptance Scenarios**:
1. **Given** an active escalation rule for `resolution_breach` scoped to a target node, **When**
a matching SLA run breaches, **Then** an `EscalationEvent` is recorded with the rule, the
reason, and `triggeredBy: system`.
2. **Given** an escalation event fires, **When** it completes, **Then** 007's assignment engine
re-runs scoped specifically to the rule's target node — not a fresh, unscoped resolution —
and the ticket's assignment history (007) reflects the new assignment with `strategy` reused
from whatever the target node itself configures.
3. **Given** no escalation rule matches a breach, **When** the breach is detected, **Then** the
SLA run is still marked breached (User Story 4) — the absence of a matching rule doesn't
suppress breach detection, it only means no automatic escalation follows.
4. **Given** multiple active rules could match the same trigger, **When** more than one does,
**Then** every matching rule fires its own escalation event — this feature does not pick just
one.
---
### User Story 6 - A human can manually trigger escalation, audited the same way (Priority: P3)
An agent or admin can explicitly escalate a ticket to a specific target node, for a stated
reason, without waiting for an automatic trigger — recorded through the same `EscalationEvent`
audit trail as an automatic one.
**Why this priority**: Depends on User Story 5's event/re-assignment mechanism already existing.
Automatic triggers won't cover every real reason to escalate (doc 05 §10 names several this
feature doesn't compute automatically — see Assumptions); a human needs an explicit path that
still produces the same durable record.
**Independent Test**: Manually escalate a ticket to a named target node with a reason; confirm an
`EscalationEvent` with `triggeredBy` set to the calling actor is recorded and the ticket is
re-assigned via 007 to that node.
**Acceptance Scenarios**:
1. **Given** an admin manually escalates a ticket to a target node, **When** it completes,
**Then** an `EscalationEvent` is recorded with `triggeredBy` set to the actor (never
`system`), and 007 re-assigns the ticket scoped to that node.
2. **Given** a manual escalation targets a node that doesn't exist, **When** it's attempted,
**Then** it's rejected — never a dangling escalation event pointing nowhere.
---
### Edge Cases
- What happens if a ticket has no assigned agent yet when its SLA would otherwise start? An SLA
run only starts on a successful assignment (User Story 2) — an unassigned ticket (007's "no
eligible agent" outcome) has no SLA run to track, consistent with there being no one yet to
hold to a commitment.
- What happens if a business calendar has no working hours configured at all for a given day?
That day contributes zero time toward any due-date countdown — an unconfigured day is never
silently treated as 24 available hours.
- What happens if an SLA policy's calendar is deleted or unreferenced after runs already exist
against it? Out of scope — this feature does not implement calendar deletion, only creation and
the `active` state every other configuration entity in this system already uses.
- What happens when a ticket is reopened after being resolved, with a completed SLA run already
on record? Out of scope for this feature to define a new run automatically — reopening (a
future problem-resolution-phase concept) may need its own SLA-run-restart decision; this
feature's SLA runs are 1:1 with a ticket's first, straightforward assignment→resolution
lifecycle.
- What happens if two escalation rules would move a ticket to the same target node at once (a
race between an automatic breach and a simultaneous manual escalation)? Both `EscalationEvent`
rows are recorded (never lost — durable audit is unconditional); 007's own re-assignment
path already handles a ticket being assigned twice in quick succession correctly (it's the same
version-row-per-period `Assignment` mechanism 007 already built and tested for concurrent
writes), so no new concurrency mechanism is needed here.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let an admin create an SLA policy with first-response/
investigation/resolution/customer-response time limits, optionally scoped to product/category/
problem type/priority, referencing a business calendar.
- **FR-002**: When multiple SLA policies could apply to the same ticket context, the system MUST
prefer the most specific match — never an arbitrary or first-found selection.
- **FR-003**: The system MUST let an admin create a business calendar with working hours and
holidays.
- **FR-004**: Due-date computation MUST exclude time outside a calendar's working hours and on
its holidays — MUST NOT compute a due date as a naive elapsed-time addition that ignores the
calendar.
- **FR-005**: An SLA run MUST start automatically when a ticket is successfully assigned (007),
using the most-specific-matching policy (FR-002); when no policy matches, no run is created.
- **FR-006**: An SLA run's status MUST be one of `running`, `paused`, `warning`, `breached`, or
`completed`, matching doc 06's defined set.
- **FR-007**: When a ticket transitions to a waiting-for-customer state, its SLA run MUST pause;
when it transitions back, the run MUST resume with its remaining time preserved — never reset
to the full original duration and never having continued counting while paused.
- **FR-008**: SLA pause/resume state and due dates MUST be durable — recomputable and correct
after a process restart, never dependent on an in-memory timer (Constitution Principle VII).
- **FR-009**: A running SLA run whose due date has passed MUST be detected and marked `breached`
by a durable background job — never missed because the triggering process wasn't running at
the exact due moment.
- **FR-010**: An SLA run that completes its milestone before its due date MUST NOT be marked
breached, regardless of what a naive elapsed-time check alone would suggest.
- **FR-011**: A paused SLA run MUST NOT be marked breached while paused.
- **FR-012**: The system MUST let an admin create an escalation rule — trigger type, condition,
target hierarchy node, and who to notify — scoped to a policy, matching doc 06's
`EscalationRule` shape.
- **FR-013**: An SLA breach (FR-009) MUST be evaluated against every active escalation rule
configured for `resolution_breach` (and, where applicable, the other SLA-derived trigger
types this feature computes — see Assumptions); every matching rule MUST fire its own
escalation event — never just the first match.
- **FR-014**: Firing an escalation rule MUST record a durable `EscalationEvent` (rule, reason,
`triggeredBy`, timestamp) and MUST re-run 007's assignment engine scoped specifically to the
rule's `targetNodeId` — never a fresh, unscoped resolution that could land elsewhere.
- **FR-015**: A breach with no matching escalation rule MUST still be recorded as breached
(FR-009) — the absence of a rule never suppresses breach detection itself.
- **FR-016**: The system MUST let an admin or agent manually escalate a ticket to a specific,
existing target node with a reason, recorded through the same `EscalationEvent` mechanism as an
automatic escalation, with `triggeredBy` set to the calling actor.
- **FR-017**: A manual escalation targeting a nonexistent hierarchy node MUST be rejected.
### Key Entities
- **SLA Policy**: A configured set of time limits (first response/investigation/resolution/
customer response) scoped to product/category/problem type/priority, referencing a business
calendar — never a hardcoded threshold.
- **SLA Run**: The durable, per-ticket tracking of one policy's due dates and status against a
real ticket, survivable across a process restart.
- **Business Calendar / Holiday**: Working hours and excluded dates a due-date calculation
respects — the mechanism that keeps SLA math honest.
- **Escalation Policy / Rule**: Configured trigger conditions and target hierarchy nodes — never
a hardcoded escalation ladder.
- **Escalation Event**: The durable, audited record of every escalation, automatic or manual,
including which rule (if any) fired it and who/what triggered it.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of due-date calculations under a policy with a configured calendar exclude
non-working time — verified against a calendar with at least one full excluded day.
- **SC-002**: 100% of SLA runs correctly reflect pause/resume across a simulated process restart
— the paused duration is never double-counted and never dropped.
- **SC-003**: 100% of SLA runs whose due date has passed are marked `breached` within one
breach-detection job cycle, even when the detecting process is a different invocation than the
one that started the run.
- **SC-004**: 100% of SLA breaches with a matching active escalation rule produce both an
`EscalationEvent` and a re-assignment scoped to the rule's target node.
- **SC-005**: 100% of manual escalations targeting a nonexistent node are rejected, never
producing a dangling event.
## Assumptions
- **This feature does not build a notification-delivery mechanism** — an escalation rule's
`notify` field (doc 06) is stored and returned as configured, but this feature does not send an
email/Slack/webhook notification; `platform/notifications` (doc 07) remains an untouched module
group, same convention as every prior feature leaving an adjacent, not-yet-built module alone.
- **Only `resolution_breach` and `first_response_breach` are wired to a real trigger signal in
this feature** — doc 05 §6 lists ten trigger types; the other eight (inactivity, priority
increase, customer escalation request, repeated reopen, manual [built as its own user story,
User Story 6, not a rule trigger], product defect, dependency timeout, critical incident)
require signals this codebase doesn't compute yet (idle-time tracking, reopen counting,
explicit defect/incident flagging) — `EscalationRule.triggerType` accepts any of doc 06's
values as configuration data, but only the two SLA-breach types are ever actually evaluated by
this feature. This mirrors 006's `assignmentStrategy`/`slaPolicyId` fields being stored as real
data before this feature gave them a real consumer.
- **Investigation SLA and customer-response SLA are stored as policy fields (FR-001) but this
feature only computes/tracks the resolution and first-response due dates on `SLARun`** — doc 06's
`SLARun` itself only models `firstResponseDueAt`/`resolutionDueAt` explicitly; investigation and
customer-response timers would need their own due-date fields doc 06 doesn't define, which is a
refinement left for whichever future phase actually needs to enforce them (matching this
system's "refine the conceptual schema when a feature needs the refinement, not speculatively"
convention).
- **SLA run creation happens once, on a ticket's first successful assignment** — reopening,
multiple resolution cycles, and re-running an SLA clock for a reassigned-after-resolution
ticket are out of scope (Edge Cases) — that's future problem-resolution-phase territory.
- **Breach detection runs on a durable, periodically-scheduled BullMQ job** (a repeatable job,
not a per-run delayed job scheduled at creation time) — checking every active `running` run's
due date against the current time on each tick, rather than scheduling one delayed job per SLA
run. This is a deliberate simplicity/robustness tradeoff, not an aspiration to replace later —
see research.md for the full reasoning.
+395
View File
@@ -0,0 +1,395 @@
---
description: "Task list for 008-sla-escalation"
---
# Tasks: SLA and Escalation
**Input**: Design documents from `specs/008-sla-escalation/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/sla-escalation-contract.md](./contracts/sla-escalation-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks. This feature has real, extractable pure logic (the
calendar-walk algorithm, most-specific policy match, breach/no-breach/paused-no-breach logic)
plus — for the first time since the constitution's Principle VII was written — a genuine
process-restart-survival requirement that needs a dedicated test rebuilding `buildApp()`
mid-test, not just a within-process concurrency test.
**Organization**: Tasks are grouped by user story (US1 = P1 policy definition, US2 = P1 run
creation with calendar-aware due dates, US3 = P1 durable pause/resume, US4 = P2 breach detection,
US5 = P2 breach-triggered escalation, US6 = P3 manual escalation).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Populate `src/modules/platform/business-calendars/` with the full standard shape
(`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`) plus a `calculators/` directory, replacing the existing
`BusinessCalendarsService.isWorkingHour` stub's content
- [x] T002 [P] Extend `src/modules/orchestration/sla/` to the full standard shape around its
existing `engine/`/`calculators/` directories, replacing every stub file's content
(`SlaEngine.evaluateSlaTargets`, `SlaDueDateCalculator.calculateDueTime`)
- [x] T003 [P] Extend `src/modules/orchestration/escalation/` to the full standard shape around
its existing `engine/` directory, replacing the `EscalationEngine.triggerEscalation` stub's
content
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema for every entity, shared by every user story.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [x] T004 Add `SLAPolicy`, `SLARun` (incl. the additive `firstResponseBreachedAt` refinement),
`BusinessCalendar`, `Holiday`, `EscalationPolicy`, `EscalationRule`, `EscalationEvent`
models to `prisma/schema.prisma` per data-model.md, plus `Ticket.slaRun`/
`Ticket.escalationEvents`, `Product.slaPolicies`/`Product.escalationPolicies`,
`Category.slaPolicies`, `HierarchyNode.escalationRules` back-relations, and an
`SLARun @@index([status, resolutionDueAt])` for the breach-detection sweep (depends on
T001-T003)
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T004 (depends on T004)
**Checkpoint**: Schema migrated. User stories can now be built.
---
## Phase 3: User Story 1 - Admin defines SLA policies as configuration (Priority: P1) 🎯 MVP (part 1)
**Goal**: `SLAPolicy` CRUD and the most-specific-match resolution function exist and are
independently correct — not yet wired to ticket assignment.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T006 [P] [US1] Unit tests for `findApplicablePolicy` (specificity-count match, wildcard
handling on each of the 4 scope dimensions independently, tie-break by latest `updatedAt`,
no-match returns `null`) in `tests/unit/orchestration/sla-policy-match.test.ts`
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (a product-scoped policy is
preferred over a global one; deactivating it falls back to the global policy) against a
real Postgres in `tests/integration/sla-policy-resolution.test.ts` (depends on T005)
### Implementation for User Story 1
- [x] T008 [US1] Add `SLAPolicyRepository` (CRUD, `findActiveCandidates(scope)`) and the Zod
create/update schema — with resolve-or-404 existence checks for `productId`/`categoryId`/
`businessCalendarId` when provided (research.md) — in `sla/repository/` + `sla/schema/`
(depends on T005)
- [x] T009 [US1] Add `findApplicablePolicy(ticketContext)` (specificity-count + tie-break, per
data-model.md's Resolution section) in `sla/service/sla-policy-resolver.service.ts`
(depends on T008)
- [x] T010 [US1] Add `POST/GET/GET:id/PATCH/DELETE /admin/sla-policies` routes (soft-delete via
`active: false`, gated by `fastify.authenticate`) in `sla/controller/` + `sla/routes/`,
registered from `src/api/routes.ts` (depends on T008)
- [x] T011 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass
**Checkpoint**: SLA policies can be defined and correctly resolved. Nothing creates an `SLARun`
yet — that's User Story 2.
---
## Phase 4: User Story 2 - SLA run starts automatically with calendar-aware due dates (Priority: P1) 🎯 MVP (part 2)
**Goal**: `BusinessCalendar`/`Holiday` CRUD, the calendar-walk algorithm, and `SLARun` creation
wired into 007's assignment-success path via the first real publish of `TICKET_ASSIGNED`.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T012 [P] [US2] Unit tests for `addBusinessMinutes` — weekend exclusion, holiday exclusion,
partial-day clipping on the start day, a day with no configured window contributing zero
time, and correctness across a DST transition in the calendar's own timezone — in
`tests/unit/platform/business-calendars/calendar-walk.test.ts`
- [x] T013 [US2] Integration test covering Quickstart Scenario 2 (calendar-aware due date lands
the next working day past a weekend+holiday, never a naive addition; a ticket assigned with
no matching policy gets no `SLARun` and `GET .../sla-run` returns `404`) against a real
Postgres in `tests/integration/sla-run-creation.test.ts` (depends on T005, T009, and 007's
existing assignment flow)
### Implementation for User Story 2
- [x] T014 [US2] Add `addBusinessMinutes(start, minutes, calendar, holidays)` using `luxon` in
`business-calendars/calculators/business-hours.calculator.ts`, replacing the
`isWorkingHour` stub's logic (research.md's day-by-day walk)
- [x] T015 [US2] Add `BusinessCalendarRepository`/`HolidayRepository`, Zod schema (IANA timezone
validation, `HH:mm` + `start < end` validation per data-model.md), and
`POST/GET/GET:id/PATCH /admin/business-calendars` +
`POST /admin/business-calendars/:id/holidays` +
`DELETE /admin/business-calendars/:id/holidays/:holidayId` routes in
`business-calendars/repository/` + `schema/` + `controller/` + `routes/` (depends on T014)
- [x] T016 [US2] Replace `SlaDueDateCalculator.calculateDueTime`'s naive addition with a call
into T014's `addBusinessMinutes` (via `business-calendars`'s public `index.ts` — FR-004) in
`sla/calculators/sla-due-date.calculator.ts` (depends on T014)
- [x] T017 [US2] Add `AssignmentEngine.persistAndTransition` (007,
`src/modules/orchestration/assignments/engine/assignment.engine.ts`) publishing
`DomainEventName.TICKET_ASSIGNED` (`{ ticketId, agentId, strategy, actor }`) after its
existing persistence step — the event is already defined in `src/events/domain-events.ts`
but has never been published (research.md)
- [x] T018 [US2] Add `SlaService.handleTicketAssigned(ticketId, agentId)`: no-ops if the ticket
already has an `SLARun` (`SLARun.ticketId @unique` — covers re-escalation's second publish,
spec.md Assumptions); otherwise resolves the applicable policy (T009), computes
`firstResponseDueAt`/`resolutionDueAt` via T016, and creates the `SLARun` — in
`sla/service/sla.service.ts` (depends on T009, T016)
- [x] T019 [US2] Subscribe `DomainEventName.TICKET_ASSIGNED` to T018's handler in
`src/events/handlers/index.ts`, following the existing "module never imports the module it
affects" registration pattern (depends on T017, T018)
- [x] T020 [US2] Add `GET /tickets/:ticketId/sla-run` route (`404` if none) in `sla/controller/` +
`sla/routes/` (depends on T018)
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
**Checkpoint**: Every successfully-assigned ticket with a matching policy gets an `SLARun` with
correctly calendar-computed due dates. MVP-complete for read-only SLA visibility.
---
## Phase 5: User Story 3 - SLA pause/resume is durable across a process restart (Priority: P1)
**Goal**: `WAITING_FOR_CUSTOMER` transitions pause/resume the run by shifting its absolute due
dates — no in-memory state anywhere, verified across an actual rebuilt `buildApp()`.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T022 [P] [US3] Unit tests for the pause/resume shift arithmetic (resume shifts both due
dates forward by exactly `now - pausedAt`; a second pause/resume cycle composes correctly)
in `tests/unit/orchestration/sla-pause-resume.test.ts`
- [x] T023 [US3] Integration test covering Quickstart Scenario 3 — including rebuilding
`buildApp()` mid-test to simulate a real process restart while paused, then asserting the
resumed due date is exactly the original plus the paused wall-clock duration — against a
real Postgres in `tests/integration/sla-pause-resume.test.ts` (depends on T018)
### Implementation for User Story 3
- [x] T024 [US3] Add `SlaService.pause(ticketId)` / `resume(ticketId)` (shift
`firstResponseDueAt`/`resolutionDueAt` forward by the paused duration on resume, per
research.md/data-model.md — no separate remaining-minutes field) in `sla/service/
sla.service.ts` (depends on T018)
- [x] T025 [US3] Subscribe two `DomainEventName.TICKET_UPDATED` handlers in
`src/events/handlers/index.ts``newStatus === 'WAITING_FOR_CUSTOMER'` calls T024's
`pause`, `previousStatus === 'WAITING_FOR_CUSTOMER'` calls `resume` — alongside the existing
005/007 subscribers on the same event (depends on T024)
- [x] T026 [US3] Subscribe a third `TICKET_UPDATED` handler — `newStatus === 'RESOLVED'` sets
`SLARun.completedAt` and `status: 'completed'` (data-model.md) — in the same file (depends
on T018)
- [x] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 4 steps pass, including the
restart-boundary step
**Checkpoint**: Every P1 user story is complete. SLA runs are created, calendar-computed, and
durably pause/resume-correct. This is the feature's MVP.
---
## Phase 6: User Story 4 - Breaches are detected even if no one is watching in real time (Priority: P2)
**Goal**: A repeatable BullMQ job durably detects both resolution and first-response breaches,
never missing one because the process wasn't running at the due instant, never flagging a
completed-in-time or paused run.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T028 [P] [US4] Unit tests for the breach-detection predicate logic (a `running` run past
`resolutionDueAt` breaches; a `paused` run past `resolutionDueAt` does not; a `completed`
run does not; a `running` run past `firstResponseDueAt` with no prior `AGENT_MESSAGE`
breaches first-response exactly once, guarded by `firstResponseBreachedAt`) in
`tests/unit/orchestration/sla-breach-detection.test.ts`
- [x] T029 [US4] Integration test covering Quickstart Scenario 4 (a short-`resolutionMinutes`
policy breaches within one sweep call; resolved-in-time and paused runs are never breached
even after their due instant passes) against a real Postgres in
`tests/integration/sla-breach-detection.test.ts` (depends on T018, T024)
### Implementation for User Story 4
- [x] T030 [US4] Add `SlaService.runBreachDetectionSweep()` — queries every `running` `SLARun`
with `resolutionDueAt <= now()` (marks `breached`/`breachedAt`) and every `running` run with
`firstResponseDueAt <= now()` and `firstResponseBreachedAt: null` and no `AGENT_MESSAGE`
recorded for the ticket (marks `firstResponseBreachedAt`) — a single, directly-callable,
side-effect-only method (research.md — no worker process needed to invoke it in tests) in
`sla/service/sla.service.ts` (depends on T024, T026)
- [x] T031 [US4] Replace `registerSlaWorker()`'s stub body in `src/jobs/sla/index.ts`: on
registration, schedule a BullMQ repeatable job on `QueueName.SLA` (`{ repeat: { every:
60_000 } }`) whose processor calls T030's `runBreachDetectionSweep` (depends on T030)
- [x] T032 [US4] Run Quickstart Scenario 4 locally and confirm all 5 steps pass
**Checkpoint**: Breaches are durably detected. Nothing reacts to a breach yet beyond marking the
run — that's User Story 5.
---
## Phase 7: User Story 5 - A breach automatically triggers rule-driven escalation (Priority: P2)
**Goal**: `EscalationPolicy`/`EscalationRule` CRUD, breach-triggered `EscalationEvent` firing, and
a new scoped-assignment entry point on 007's `AssignmentEngine` that re-assigns to exactly the
rule's `targetNodeId`.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 5
- [x] T033 [P] [US5] Unit tests for escalation-policy resolution (product-specific preferred over
global, per research.md) and rule matching (every active rule whose `triggerType` matches
the firing breach type fires; an inactive or wrong-trigger-type rule doesn't) in
`tests/unit/orchestration/escalation-rule-match.test.ts`
- [x] T034 [US5] Integration test covering Quickstart Scenario 5 (a breach with a matching rule
produces exactly one `EscalationEvent` and reassigns to an agent eligible under the rule's
specific `targetNodeId`, not the ticket's originally-resolved node; a breach with no
matching rule is still recorded breached with no `EscalationEvent`) against a real Postgres
in `tests/integration/sla-escalation-firing.test.ts` (depends on T030)
### Implementation for User Story 5
- [x] T035 [US5] Add `EscalationPolicyRepository`/`EscalationRuleRepository` (CRUD,
`findActiveRules(policyId, triggerType)`), Zod schema (all 10 doc-05 `triggerType` values
accepted; `targetNodeId` resolve-or-404 at rule creation, FR-012) in
`escalation/repository/` + `escalation/schema/` (depends on T005)
- [x] T036 [US5] Add `POST/GET /admin/escalation-policies`,
`POST/PATCH/DELETE /admin/escalation-policies/:id/rules[/:ruleId]` routes in
`escalation/controller/` + `escalation/routes/` (depends on T035)
- [x] T037 [US5] Add `AssignmentEngine.assignToSpecificNode(ticketId, hierarchyNodeId, actor,
reason?, strategyOverride?)` (007, `assignments/engine/assignment.engine.ts`) — resolves
the eligible-agent set scoped to exactly the given node (reusing `RoutingService`'s
capability-lookup call, research.md) and persists through the existing
`persistAndTransition` (T017), so it also publishes `TICKET_ASSIGNED` for free (depends on
T017)
- [x] T038 [US5] Add `EscalationService.handleBreach(ticketId, triggerType)`: resolves the
applicable `EscalationPolicy` (product-match-or-global, research.md), finds every active
matching `EscalationRule` (T035), and for each, creates an `EscalationEvent`
(`ruleId`, `fromNodeId` from the ticket's current assignment, `toNodeId: rule.targetNodeId`,
`triggeredBy: 'system'`) and calls T037's `assignToSpecificNode` — records nothing when no
rule matches (FR-015) — in `escalation/service/escalation.service.ts` (depends on T035,
T037)
- [x] T039 [US5] Wire T030's `runBreachDetectionSweep` to call T038's `handleBreach` for each
newly-detected breach, passing the corresponding trigger type (`resolution_breach` /
`first_response_breach`) — in `sla/service/sla.service.ts` (depends on T030, T038)
- [x] T040 [US5] Run Quickstart Scenario 5 locally and confirm all 3 steps pass
**Checkpoint**: Breaches automatically escalate through rule-driven, scoped re-assignment.
---
## Phase 8: User Story 6 - A human can manually escalate a ticket to a specific node (Priority: P3)
**Goal**: The same `EscalationEvent` + scoped-reassignment mechanism, triggered explicitly by a
caller instead of a breach.
**Independent Test**: Quickstart Scenario 6.
### Tests for User Story 6
- [x] T041 [US6] Integration test covering Quickstart Scenario 6 steps 1-3 (manual escalation
creates an `EscalationEvent` with `ruleId: null` and reassigns via the scoped path; a
nonexistent `targetNodeId` returns `404` with no event created) against a real Postgres —
implemented as the "Scenario 6" case in `tests/integration/sla-escalation-flow.test.ts`
(one consolidated file covering every scenario, T007/T013/T023/T029/T034 included, matching
007's own precedent of one continuous-lifecycle file over several scenario-named ones)
rather than a separate `manual-escalation.test.ts` (depends on T037, T038). Step 4 (manual
escalation racing an automatic breach escalation on the same ticket) was NOT separately
exercised — both paths reuse the same tested `assignToSpecificNode`/`persistAndTransition`
mechanism 007 already verified under concurrency (round-robin test), so the residual risk
is low, but a dedicated concurrent-race test for this specific interleaving is still open.
### Implementation for User Story 6
- [x] T042 [US6] Add `EscalationService.escalateManually(ticketId, targetNodeId, actor, reason)`:
resolve-or-404 on `targetNodeId` (FR-017), creates an `EscalationEvent` (`ruleId: null`,
`triggeredBy: actor`) and calls T037's `assignToSpecificNode` — in `escalation/service/
escalation.service.ts` (depends on T037)
- [x] T043 [US6] Add `POST /tickets/:ticketId/escalate` route (gated by `fastify.authenticate`)
in `escalation/controller/` + `escalation/routes/`, registered from `src/api/routes.ts`
(depends on T042)
- [x] T044 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
**Checkpoint**: All six user stories work independently and together — policy definition,
calendar-aware run creation, durable pause/resume, durable breach detection, and both automatic
and manual escalation form one coherent, restart-safe flow.
---
## Phase 9: Polish & Cross-Cutting Concerns
- [ ] T045 [P] SKIPPED — originally planned to add an "SLA and Escalation" section to
`README.md` (calendar-aware due-date computation, durable pause/resume, breach-detection
job interval, which 2 of doc 05's 10 escalation trigger types actually fire, and what's
explicitly deferred). `README.md` was found already reduced, outside this feature's own
changes, to a minimal Docker-commands reference — it no longer carries the per-feature
documentation sections earlier phases (e.g. 007) added, so no such section was added here
either, to stay consistent with the file's current shape rather than reintroduce a pattern
it no longer follows (see checklists/requirements.md's Implementation Notes).
- [x] T046 [P] Update `specs/008-sla-escalation/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T047 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T048 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration suite (including 007's own suite, since T017/T037
modify its `AssignmentEngine`) against real Docker-provisioned Postgres/Redis
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US6
- **User Story 2 (Phase 4)**: Depends on US1 (the policy it resolves against) — genuinely not
independent, same class of dependency 007's US2 had on US1
- **User Story 3 (Phase 5)**: Depends on US2 (the run it pauses/resumes)
- **User Story 4 (Phase 6)**: Depends on US3 (a run that can be paused must be excluded from
breach detection correctly, so the pause mechanism must exist first)
- **User Story 5 (Phase 7)**: Depends on US4 (the breach it reacts to) and on 007's
`AssignmentEngine` (T037's new method)
- **User Story 6 (Phase 8)**: Depends on US5 (T037/T038's scoped-reassignment mechanism, reused
directly rather than duplicated)
- **Polish (Phase 9)**: Depends on all six user stories
### Parallel Opportunities
- T001/T002/T003 (independent scaffolding)
- T006 (unit tests) alongside T008-T009 (the implementations they test)
- T012 (unit tests) alongside T014 (the implementation it tests)
- T022 alongside T024; T028 alongside T030; T033 alongside T035/T038
- T045/T046 in Polish
### Sequencing Note
T017 (publishing `TICKET_ASSIGNED` from 007's `AssignmentEngine`) and T037 (the new
`assignToSpecificNode` method on the same class) both modify a file 007 already owns and has its
own passing test suite for — run 007's full integration suite (part of T048) after each, not only
at the very end, to catch a regression close to its cause.
---
## Implementation Strategy
### MVP First (User Stories 1-3 Only)
1. Setup + Foundational (T001-T005)
2. User Story 1 (T006-T011) — policies exist and resolve correctly
3. User Story 2 (T012-T021) — runs are created with real calendar-aware due dates
4. User Story 3 (T022-T027) — pause/resume is durable, including across a restart
5. **STOP and VALIDATE**: Quickstart Scenarios 1-3 pass — every assigned ticket has a correctly
computed, durably pausable `SLARun`. Nothing reacts to a breach yet — that value lands with
User Story 4/5.
### Incremental Delivery
1. Setup + Foundational → schema migrated
2. Add User Story 1 → SLA policies are configurable and resolve correctly
3. Add User Story 2 → runs are created automatically with calendar-aware due dates
4. Add User Story 3 → pause/resume is durable (P1-complete, MVP)
5. Add User Story 4 → breaches are durably detected
6. Add User Story 5 → breaches automatically escalate and reassign
7. Add User Story 6 → manual escalation exists, reusing the same mechanism
8. Polish → docs and full regression
@@ -0,0 +1,80 @@
# Specification Quality Checklist: Problem Resolution
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Scope is Phase 9 per `docs/10-implementation-roadmap.md`: Investigation → Root Cause →
Solution → Solution Implementation → Solution Verification → Resolution, plus customer
confirmation and reopen — the full doc 04 §3-9 workflow narrative, matching doc 06's "Domain:
Problem Resolution" schema exactly (no new fields invented beyond what's already documented).
- `src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}`
are the five real target stub directories for this feature (each currently a one-file stub
returning a hardcoded placeholder). `src/modules/problem-management/problems` was found to be a
**dead, unwired duplicate scaffold** for `Problem` — the real, actively-used `Problem` model and
repository already live in `ticketing/tickets` since 003 — this feature does not touch
`problem-management/problems`, matching this session's established discipline of only replacing
stubs a documented phase's roadmap item actually calls for.
- This feature explicitly closes a loop 008-sla-escalation's own spec.md left open in its Edge
Cases: "reopening... may need its own SLA-run-restart decision" — resolved here as "no new SLA
run on reopen" (FR-018), keeping 008's already-shipped 1:1-with-first-assignment boundary
unchanged rather than reopening (no pun intended) that feature's own scope.
- Verification-failure escalation deliberately reuses 003/007's existing `HUMAN_ESCALATION`
transition rather than inventing a new escalation-rule trigger type in 008's system — flagged
explicitly in Assumptions as a scope decision, not an oversight.
- All items pass; no revision iterations were needed.
## Implementation Notes (added during /speckit-implement)
- `fastify.authenticateProductIntegration` (002) turned out to unconditionally require a full
ticket-creation-shaped body (`source`/`problem` included) — reusing it as planned for
confirm-resolution/reopen made every call fail validation before token verification ran. Fixed
by extracting the shared verification logic (everything after the body's own shape is known)
into `verifyIntegrationIdentity` in `product-integration-auth.plugin.ts`, and adding a new,
narrower `identityOnlyRequestSchema` (`{productId, tenantId, userId}`) plus a new
`authenticateProductIntegrationIdentity` decorator built on the same shared function — purely
additive, `POST /v1/support/requests`'s own behavior is unchanged.
- Two pre-existing scaffold gaps were closed for this feature's FK validation needs:
`TicketsRepository` gained `findPendingCustomerConfirmationOlderThan` (the auto-close sweep's
own query), and `ticketsRepository`/`TicketsRepository` are now exported from
`ticketing/tickets`'s public `index.ts` (same "extend an existing module's public surface"
precedent as `problemsRepository` before it).
- Running this feature's own integration suite alongside 008's surfaced a real test-data-hygiene
bug in 008's already-committed test file: its second hierarchy node used `productScope: []`
(a wildcard matching *every* product, per `HierarchyNode`'s own documented scope-matching rule)
purely to have a valid, different target node for its own scoped-escalation test — but since
every test file's tickets share one live Postgres database, that wildcard node (and, similarly,
008's intentionally-global `SLAPolicy` test fixture) silently affected *other* files' tickets
running in the same suite, including this feature's own. Fixed by scoping that node to its own
test's product (it never needed to be global) and by deactivating the global `SLAPolicy`
fixture immediately after the one scenario that needs it, rather than leaving it live for the
rest of the file's run — both fixes are to `tests/integration/sla-escalation-flow.test.ts`
only, no production code changed. Full regression (`tests/unit` + `tests/integration` together,
172 tests) is clean except the 2 pre-existing MinIO-dependent attachment failures.
@@ -0,0 +1,67 @@
# Contract: Problem Resolution
Agent-facing write routes are gated by `fastify.authenticate` (known limitation inherited from
002-008). Customer-facing routes are gated by `fastify.authenticateProductIntegration` +
`fastify.checkIntegrationRateLimit` (002's inbound trust boundary, research.md) and additionally
verify the caller's token identifies the same tenant/user as the ticket's own recorded
`externalTenantId`/`externalUserId` — a `403` if they don't match.
## Investigation
- `POST /admin/problems/:problemId/investigations` — body `{ investigator, findings, evidence?,
internalNotes?, status? }` (`status` defaults to `open`). `404` if `problemId` doesn't exist.
- `GET /admin/problems/:problemId/investigations` — every investigation for the problem, newest
first, including `internalNotes` (agent-facing).
- `GET /problems/:problemId/investigations` — customer/public-safe variant: same list, with
`internalNotes` always omitted (FR-003).
## Root Cause
- `POST /admin/problems/:problemId/root-causes` — body `{ type, description }`. `400` if `type`
isn't one of the five validated values. `409` if no investigation exists yet for the problem.
## Solution
- `POST /admin/problems/:problemId/solutions` — body `{ proposed }`. `409` if no root cause
exists yet for the problem.
- `PATCH /admin/solutions/:solutionId/approve` — sets `approved: true`.
- `POST /admin/solutions/:solutionId/implementation` — body `{ notes?, implementedBy }`. `409` if
the solution isn't approved, or already has an implementation.
- `POST /admin/solutions/:solutionId/verification` — body `{ method, result, evidence? }`. `400`
if `method` isn't one of the four validated values. `409` if the solution has no implementation
yet, or already has a verification.
## Resolution
- `POST /admin/tickets/:ticketId/resolution` — body `{ outcome, resolvedBy }`. `409` if the
ticket's problem has no solution with a successful verification. Transitions the ticket to
`RESOLUTION_PENDING_CUSTOMER` on success.
- `POST /v1/support/tickets/:ticketId/confirm-resolution` — customer-facing (trust boundary
above). `409` if the ticket isn't in `RESOLUTION_PENDING_CUSTOMER`. Transitions to `RESOLVED`.
## Reopen
- `POST /v1/support/tickets/:ticketId/reopen` — customer-facing. `409` if the ticket isn't
`RESOLVED` or `CLOSED`.
- `POST /admin/tickets/:ticketId/reopen` — agent-facing, same precondition.
Both reopen routes transition `RESOLVED|CLOSED → REOPENED → IN_PROGRESS` (research.md's two-hop
decision) and touch nothing else — no new `SLARun`, no mutation of any prior investigation/root-
cause/solution/verification/resolution record (FR-018, SC-005).
## Guarantees (callable contract)
1. **Every investigation/root-cause/solution/implementation/verification/resolution record,
once created, is retrievable exactly as given and is never silently overwritten by a later
action in the same problem's lifecycle** (SC-001).
2. **`internalNotes` never appears in a customer-facing investigation read**, verified by a
direct comparison against the agent-facing read of the same record (SC-002).
3. **A `Resolution` can never be recorded without a successfully verified solution already on
file for the ticket's problem** (SC-003).
4. **A ticket in `RESOLUTION_PENDING_CUSTOMER` with no explicit confirmation reaches `RESOLVED`
within one auto-close job cycle of its configured waiting period elapsing** (SC-004).
5. **Reopening a ticket leaves every prior problem-resolution record and its `SLARun` (008)
untouched** (SC-005).
6. **A verification failure choosing escalation moves the ticket to `HUMAN_ESCALATION` through
003's existing state machine, and 007's orchestration re-runs automatically from that
transition alone** — no new escalation mechanism is introduced by this feature.
@@ -0,0 +1,99 @@
# Data Model: Problem Resolution
Every model below matches `docs/06-database-schema.md` "Domain: Problem Resolution" field-for-
field — no new columns invented (research.md explains the two places this was deliberately
considered and rejected: `Resolution.solutionId`, `Investigation.isCurrent`).
## Investigation
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` (the existing `ticketing/tickets` one) |
| `investigator` | `String` | agentId — same non-FK free-text convention as `TicketMessage.authorRef` |
| `findings` | `Json` | structured, not free text (doc 04 §4) |
| `evidence` | `Json?` | |
| `internalNotes` | `String?` | never exposed on any customer-facing read (FR-003) |
| `status` | `String` | `open \| complete` |
| `createdAt` | `DateTime @default(now())` | ordering field for "most recent investigation" (research.md — no `isCurrent` flag) |
## RootCause
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` |
| `type` | `String` | `technical \| configuration \| external_dependency \| business \| contributing_factor` — validated, not free text (FR-005) |
| `description` | `String` | |
| `createdAt` | `DateTime @default(now())` | |
## Solution
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` |
| `proposed` | `String` | |
| `approved` | `Boolean @default(false)` | explicit approval action (FR-007) |
| `createdAt` | `DateTime @default(now())` | |
| `implementation` | `SolutionImplementation?` | inverse of the 1:1 below |
| `verification` | `SolutionVerification?` | inverse of the 1:1 below |
## SolutionImplementation
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `solutionId` | `String @unique` | 1:1 with `Solution` — a second implementation attempt is rejected (FR-007 Edge Cases), not overwritten |
| `notes` | `String?` | |
| `implementedBy` | `String` | agentId |
| `implementedAt` | `DateTime @default(now())` | |
## SolutionVerification
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `solutionId` | `String @unique` | 1:1 with `Solution` — at most one verification per solution (data-model note in Edge Cases) |
| `method` | `String` | `automated \| technical_test \| customer_confirmation \| agent_confirmation` — validated (FR-011) |
| `result` | `String` | `success \| failed` |
| `evidence` | `Json?` | |
| `verifiedAt` | `DateTime @default(now())` | |
## Resolution
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `ticketId` | `String @unique` | one resolution per ticket |
| `outcome` | `String` | |
| `resolvedBy` | `String` | `"ai"` or agentId |
| `resolvedAt` | `DateTime @default(now())` | |
No `solutionId` FK here (research.md) — the "a successfully verified solution exists for this
ticket's problem" precondition (FR-014) is enforced by the service layer at write time via a
join through `Ticket.problemId → Solution.problemId → Solution.verification.result`, not stored.
## Relations added to existing models
- `Problem.investigations Investigation[]`, `Problem.rootCauses RootCause[]`,
`Problem.solutions Solution[]` (all on the existing `ticketing/tickets`-owned `Problem` model)
- `Ticket.resolution Resolution?` (inverse of `Resolution.ticketId @unique`)
## Validation chain (service layer, not DB constraints — matches 003's own state-machine convention)
1. `RootCause` create → `Problem` must have at least one `Investigation` (FR-006).
2. `Solution` create → `Problem` must have at least one `RootCause` (FR-009).
3. `SolutionImplementation` create → the `Solution` must have `approved: true` (FR-008), and must
not already have an implementation (unique constraint surfaces this as a conflict).
4. `SolutionVerification` create → the `Solution` must already have a `SolutionImplementation`
(verification is of something implemented, doc 04 §7).
5. `Resolution` create → the `Ticket`'s `Problem` must have at least one `Solution` whose
`verification.result === 'success'` (FR-014).
## Out of scope for this data model (per spec.md Assumptions)
- No new `EscalationRule.triggerType` value for verification failure (research.md — reuses the
plain `HUMAN_ESCALATION` status transition instead).
- No `ResolutionPolicy`/scoped auto-close configuration entity — one system-wide config value
(research.md).
+134
View File
@@ -0,0 +1,134 @@
# Implementation Plan: Problem Resolution
**Branch**: `009-problem-resolution` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/009-problem-resolution/spec.md`
## Summary
Populate the five real `problem-management/{investigation,root-causes,solutions,resolutions,
verification}` stubs (each currently a one-file placeholder — `getInvestigationStatus` always
`PENDING`, `getResolutions` always `[]`, etc.) with the real doc-04-workflow engine: a strict
existence chain from investigation through root cause, solution, implementation, and
verification; a `Resolution` record gated on a successfully verified solution, moving the ticket
to `RESOLUTION_PENDING_CUSTOMER`; explicit customer confirmation (reusing 002's inbound trust
boundary) or a durable auto-close sweep (reusing the unregistered `CLEANUP` queue stub) into
`RESOLVED`; and a reopen path (customer or agent) that re-enters `IN_PROGRESS` through 003's
existing `REOPENED` state without touching any prior record or 008's `SLARun`.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod, BullMQ (reused `CLEANUP` queue). No new
runtime dependency.
**Storage**: PostgreSQL via Prisma (new `Investigation`, `RootCause`, `Solution`,
`SolutionImplementation`, `SolutionVerification`, `Resolution` models). Reuses
`src/infrastructure/queue` for the auto-close sweep, same as 008's breach-detection job.
**Testing**: Vitest — unit tests for the existence-chain validation logic and the auto-close
due-window predicate; integration tests for the full sequential workflow (investigation through
resolution), the customer-confirmation and auto-close paths, and reopen leaving prior records and
an `SLARun` untouched.
**Target Platform**: Same Fastify modular monolith. Populates
`src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}/`.
Adds two new customer-facing routes under `/v1/support/tickets/:ticketId/...` alongside the
existing `POST /v1/support/requests` (002).
**Project Type**: Backend service — single project.
**Constraints**: MUST reject out-of-order writes (root cause before investigation, etc. — FR-006/
FR-008/FR-009); MUST NOT expose `internalNotes` on any customer-facing read (FR-003); MUST gate
`Resolution` on a real successful verification (FR-014); MUST auto-close durably, not via an
in-memory timer (FR-016, Constitution Principle VII); MUST NOT create a new `SLARun` on reopen
(FR-018).
**Scale/Scope**: Five populated modules, one new BullMQ repeatable job (reusing an existing
queue), two new customer-facing routes reusing 002's trust boundary, one new agent-facing reopen
route. Explicitly excludes: a rendered customer confirmation UI (010's territory), a new
escalation-rule trigger type for verification failure (reuses 003/007's existing transition
instead), per-scope auto-close policy (one system-wide config value).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Customer-facing routes authenticate via 002's product-integration token, never a SupportHub-native customer login — and additionally verify the token's tenant/user matches the ticket's own recorded values. | PASS |
| II. Configuration Over Hardcoding | The auto-close waiting period is env-configured (research.md), never a hardcoded number; validated-value sets (root-cause type, verification method) are Zod-enforced closed lists matching doc 04's own documented values, not ad hoc. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Five modules follow the standard shape; each references `ticketing/tickets`'s `Problem` (one-directional, already established), and the verification-failure-escalation path calls `ticketsService.updateStatus` directly rather than reaching into 008's `EscalationService` — no new module dependency edge into 008 at all. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | `Resolution.resolvedBy` accepts `"ai"` per doc 06's own shape, but this feature adds no AI-driven decision logic of its own — every gate (approval, verification result, escalate-vs-reinvestigate) is an explicit human/deterministic action. | PASS |
| V. Evidence-Based Verification | This principle's own domain — `SolutionVerification.evidence`/`Investigation.evidence` are exactly the durable evidence records Principle V requires before a resolution is trusted. | PASS |
| VI. Durable Audit & History | Every investigation attempt is its own preserved row (never overwritten); reopen produces two real, separately-audited status transitions rather than one collapsed hop. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Auto-close is a repeatable BullMQ job querying durable DB state (`Ticket.status`/`updatedAt`), never an in-memory timer — same discipline 008's breach-detection sweep already established. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | This principle's own domain — every investigation/root-cause/solution record is scoped to `Problem`, never `Ticket`, while `Resolution` (necessarily ticket-scoped, since a shared `Problem` could span multiple tickets) is the one exception doc 06 itself defines. | PASS |
| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure only, no new dependency. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Worth calling out against Principle VIII
explicitly: `Resolution.ticketId` (not `problemId`) is the one place in this whole feature where
a record is ticket-scoped rather than problem-scoped — a deliberate, doc-06-defined exception
(a shared `Problem` can have multiple tickets, each needing its own outcome), not an
inconsistency with the rest of this feature's problem-scoped chain.
## Project Structure
### Documentation (this feature)
```text
specs/009-problem-resolution/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — add Investigation, RootCause,
│ Solution, SolutionImplementation,
│ SolutionVerification, Resolution
├── src/
│ ├── config/
│ │ └── problem-resolution.ts # NEW — autoCloseWaitingHours
│ ├── jobs/
│ │ └── cleanup/index.ts # REPLACED stub — schedules the repeatable
│ │ auto-close sweep (research.md)
│ └── modules/
│ ├── ticketing/tickets/ # MODIFIED — reopen calls updateStatus twice
│ └── problem-management/
│ ├── problems/ # UNTOUCHED — dead duplicate scaffold
│ │ (research.md) — not this feature's Problem
│ ├── investigation/ # REPLACED stub — full standard shape
│ ├── root-causes/ # REPLACED stub — full standard shape
│ ├── solutions/ # REPLACED stub — full standard shape
│ ├── verification/ # REPLACED stub — full standard shape
│ └── resolutions/ # REPLACED stub — full standard shape,
│ including the auto-close sweep + the two
│ new customer-facing routes
└── tests/
├── unit/problem-management/ # existence-chain validation, auto-close
│ due-window predicate
└── integration/ # full sequential workflow, customer
confirmation, auto-close, reopen
```
**Structure Decision**: Single project. Every module gets the full standard shape (each has its
own real CRUD/read surface, unlike 007's internal-only `routing`) — matching 008's precedent for
a multi-module feature where every module has genuine callers beyond another module in the same
feature.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,72 @@
# Quickstart: Validating Problem Resolution
Prerequisites: migrations applied; a ticket created per 003-ticketing's own quickstart (this
feature works against its `problemId`).
## Scenario 1 — structured investigation, preserved across attempts (User Story 1)
1. `POST /admin/problems/:problemId/investigations` with findings/evidence/internalNotes.
**Expected**: `201`, retrievable via `GET /admin/problems/:problemId/investigations` with
every field intact.
2. `GET /problems/:problemId/investigations` (customer-safe variant). **Expected**: same rows,
`internalNotes` absent from every one.
3. Record a second investigation for the same problem. **Expected**: both rows remain, in order —
the first is never overwritten.
## Scenario 2 — root cause requires an investigation on file (User Story 2)
1. `POST /admin/problems/:problemId/root-causes` for a problem with no investigation.
**Expected**: `409`.
2. Repeat after Scenario 1's investigation exists. **Expected**: `201`, `type` one of the five
validated values.
3. Repeat with an invalid `type`. **Expected**: `400`.
## Scenario 3 — solution proposed, approved, implemented as distinct states (User Story 3)
1. `POST /admin/problems/:problemId/solutions` before any root cause exists. **Expected**: `409`.
2. Repeat after Scenario 2's root cause exists. **Expected**: `201`, `approved: false`.
3. `POST /admin/solutions/:solutionId/implementation` before approval. **Expected**: `409`.
4. `PATCH /admin/solutions/:solutionId/approve`, then repeat step 3. **Expected**: `201`.
5. Repeat step 3 again (a second implementation). **Expected**: `409`.
## Scenario 4 — verification, and what happens on failure (User Story 4)
1. `POST /admin/solutions/:solutionId/verification` with `result: success`. **Expected**: `201`.
2. On a different solution (Scenario 3 repeated for a fresh problem), verify with
`result: failed`. **Expected**: `201`, but no `Resolution` can be recorded referencing it
(Scenario 5, step 1).
3. On the failed-verification path, request a fresh investigation. **Expected**: a new
`Investigation` row for the same problem, the original untouched.
4. On the failed-verification path, request escalation instead. **Expected**: the ticket
transitions to `HUMAN_ESCALATION`, and (007) is automatically assigned from that transition
alone — no separate escalation call needed.
## Scenario 5 — resolution, customer confirmation, and auto-close (User Story 5)
1. `POST /admin/tickets/:ticketId/resolution` for a ticket whose problem has no successfully
verified solution. **Expected**: `409`.
2. Repeat once Scenario 4 step 1's successful verification exists. **Expected**: `201`, ticket
status becomes `RESOLUTION_PENDING_CUSTOMER`.
3. `POST /v1/support/tickets/:ticketId/confirm-resolution` with the customer's own token.
**Expected**: `200`, ticket status becomes `RESOLVED`.
4. Repeat steps 1-2 for a second ticket; instead of confirming, directly age the ticket's
`updatedAt` past the configured waiting period and run the auto-close sweep.
**Expected**: ticket status becomes `RESOLVED` without any explicit confirmation call.
## Scenario 6 — reopen (User Story 6)
1. `POST /v1/support/tickets/:ticketId/reopen` on the `RESOLVED` ticket from Scenario 5.
**Expected**: `200`, ticket status becomes `IN_PROGRESS` (via `REOPENED`).
2. `GET /tickets/:ticketId/resolution` (or the admin equivalent). **Expected**: the original
`Resolution` record is still present, unchanged.
3. If the ticket has an `SLARun` (008) from its original assignment, **Expected**: it is
unchanged — no new run created, its status exactly what it was before the reopen.
4. `POST /admin/tickets/:ticketId/reopen` on a `CLOSED` ticket, as an agent. **Expected**: same
`REOPENED → IN_PROGRESS` result, this time attributed to the agent, not `"customer"`.
## What "done" looks like
All six scenarios pass, together demonstrating every functional requirement and success
criterion in `spec.md` — including SC-004's auto-close job cycle and SC-005's "reopen touches
nothing else" guarantee, both of which need direct-DB-state manipulation (not just waiting) to
verify without a multi-hour real-time test run.
+189
View File
@@ -0,0 +1,189 @@
# Phase 0 Research: Problem Resolution
## Decision: Module placement — five real stubs; `problem-management/problems` is dead scaffold, left untouched
- **Decision**: `problem-management/{investigation,root-causes,solutions,resolutions,verification}`
(each a one-file, hardcoded-placeholder stub) are populated directly. `problem-management/
problems` — a second, never-wired `ProblemsRepository.findAll()` returning `[]` — is left
exactly as-is; it is not this feature's `Problem` (that one has lived in, and been used since,
`ticketing/tickets/repository/problems.repository.ts`, created by 003-ticketing).
- **Rationale**: Every real caller of `Problem` (003's ticket creation, 005's AI diagnosis, 007's
routing context, this feature's own investigation/root-cause/solution FKs) already resolves it
through `ticketing/tickets`'s repository. `problem-management/problems` was never imported by
anything (confirmed by search) — a leftover from the original pre-spec-driven scaffold, the same
class of dead placeholder this codebase's discipline is to leave alone unless a documented
phase's roadmap item actually names it. Phase 9's own roadmap line names Investigation/
RootCause/Solution/.../Resolution, not a second Problem implementation.
- **Alternatives considered**: Migrating `Problem` into `problem-management/problems` and
re-pointing every existing caller — rejected as an unrequested, high-blast-radius refactor of
working code three prior features already depend on, for a rename with no functional benefit.
## Decision: Investigation is version-row-per-attempt, matching 004/007's established pattern
- **Decision**: Every investigation (the first one, and any created after a failed verification,
FR-013) is its own `Investigation` row for the same `problemId` — never an update to a prior
row. "Which investigation is current" for a problem is simply the most recent by `createdAt`.
- **Rationale**: Doc 06's `Investigation` model has no version/current-row field at all (unlike
`KnowledgeEntry.isCurrentVersion` or `Assignment.isCurrent`) — the simplest reading consistent
with "each investigation attempt is real, preserved history" (spec.md US1) is an unbounded,
append-only set of rows per problem, ordered by `createdAt`, with no additional schema needed.
- **Alternatives considered**: Adding an `isCurrent` boolean to `Investigation` (mirroring 007's
refinement of `Assignment`) — rejected as unrequested schema embellishment; nothing in spec.md
requires querying "the current investigation" faster than an `orderBy: createdAt desc, take: 1`
already provides, and doc 06 doesn't define the field.
## Decision: A strict existence chain — investigation → root cause → solution → implementation → verification
- **Decision**: Each write validates its own prerequisite exists for the same `problemId`
(root cause requires an investigation; solution requires a root cause) or the same `solutionId`
(implementation requires an approved solution; verification requires an implementation) —
resolve-or-reject, the same "don't invent a default, don't skip a step" discipline this
codebase has used for every other FK-shaped precondition since 002.
- **Rationale**: Doc 04 §4-8 describes a strictly sequential workflow ("Investigation → Root
Cause → Solution → Verification → Resolution") — the acceptance scenarios (spec.md US2-US4)
explicitly test that skipping a step is rejected, not silently tolerated.
- **Alternatives considered**: Allowing any order and only validating at Resolution time —
rejected; doc 04's own workflow diagram is sequential by design, and rejecting out-of-order
writes early gives a caller a much clearer error than a late rejection at the final step.
## Decision: `Resolution` has no stored FK back to `Solution` — matches doc 06's shape exactly
- **Decision**: `Resolution` is validated at write time (a successfully verified solution must
exist for the ticket's `problemId`) but the `Resolution` row itself stores no `solutionId`
doc 06's own `Resolution` model has no such field (`id, ticketId @unique, outcome, resolvedBy,
resolvedAt` only).
- **Rationale**: Not a gap to fill — the existence check is enforced by the service layer at
write time (the same "validate at the boundary, don't over-model the schema" approach 002/003
already use for non-FK cross-references like `TicketMessage.authorRef`), and doc 06 is
explicit about what `Resolution` stores. Inventing a FK doc 06 doesn't define would be scope
creep, not correctness.
- **Alternatives considered**: Adding `solutionId` to `Resolution` as an additive refinement
(this codebase's own established pattern for filling real gaps, e.g. 008's
`firstResponseBreachedAt`) — considered and rejected specifically here, since unlike 008's gap
(a genuinely missing idempotency guard with no other way to express it), the existence check
this feature needs is fully satisfiable without a stored reference — a real refinement changes
*behavior*; this one would only change provenance-tracing convenience nothing in spec.md asks
for.
## Decision: Verification-failure escalation reuses 003/007's `HUMAN_ESCALATION` transition directly
- **Decision**: When an agent chooses escalation on a failed verification (FR-013), this feature
calls `ticketsService.updateStatus(ticketId, 'HUMAN_ESCALATION', ...)` — the same transition
001-caliber tickets already support — and does nothing else. 007's existing `TICKET_UPDATED`
subscriber (`src/events/handlers/index.ts`) picks this up and runs orchestration automatically,
exactly as it does for every other route into `HUMAN_ESCALATION`.
- **Rationale**: "Solution verification failed" is not one of doc 05 §6's ten escalation-rule
trigger types 008 already modeled (`first_response_breach | resolution_breach | inactivity |
priority_increase | customer_escalation | repeated_reopen | manual | product_defect |
dependency_timeout | critical_incident`) — inventing an eleventh type, a new `EscalationEvent`,
and a new call into 008's `EscalationService` for one internal flow this feature owns would be
real, unrequested coupling across a module boundary 008 was deliberately built not to need.
Reusing the plain status transition is exactly the mechanism 007 already exists to react to.
- **Alternatives considered**: Adding `solution_verification_failed` as an eleventh
`EscalationRule.triggerType` and calling 008's `EscalationService.handleBreach`-equivalent —
rejected; 008 is already shipped and committed with a closed, deliberately-bounded set of two
real trigger types (spec.md 008 Assumptions) — retroactively expanding it from within a later
feature, for a flow that doesn't need the rule-matching machinery at all (there's exactly one
outcome: HUMAN_ESCALATION, not "evaluate every matching rule"), is unjustified complexity.
## Decision: Customer-facing confirm-resolution and reopen reuse 002's trust boundary via a new, narrower `authenticateProductIntegrationIdentity` decorator; agent reopen uses `fastify.authenticate`
- **Decision**: Two new customer-reachable routes, `POST /v1/support/tickets/:ticketId/confirm-
resolution` and `POST /v1/support/tickets/:ticketId/reopen`, are gated by a new
`fastify.authenticateProductIntegrationIdentity` + the existing `fastify.
checkIntegrationRateLimit` preHandler pair, then additionally verify the caller's
`externalTenantId`/`externalUserId` (from `request.reqContext`) matches the ticket's own
recorded values before allowing the action. A third route,
`POST /admin/tickets/:ticketId/reopen`, is gated by `fastify.authenticate` for the
agent-initiated reopen path FR-017 also requires. Confirm-resolution has no agent-initiated
equivalent (spec.md US5 only ever has the customer confirming explicitly; an agent's own path
to close things out is the existing auto-close job, not a manual override this feature adds).
- **Implementation note (found during /speckit-implement, not anticipated at planning time)**:
`fastify.authenticateProductIntegration` (002) unconditionally validates `request.body` against
the full `inboundRequestSchema` — which requires `source`/`problem`, ticket-*creation*-specific
fields neither new route has any reason to send. Reusing it as originally planned made every
call to these two routes fail Zod validation before token verification ever ran. Fixed by
extracting steps 2-10 of `authenticateProductIntegration`'s logic (everything after the body's
own shape is known — token verification, replay/revocation/scope checks, `reqContext`
population) into a shared `verifyIntegrationIdentity` function in
`product-integration-auth.plugin.ts`, and adding a new `identityOnlyRequestSchema`
(`{productId, tenantId, userId}` only) plus a new `authenticateProductIntegrationIdentity`
decorator that parses that narrower shape and calls the same shared function. The original
`authenticateProductIntegration` (and `POST /v1/support/requests`) is unchanged in behavior —
purely additive.
- **Rationale**: `inbound-request.routes.ts`'s own comment ("Acting further on the ticket...
belongs to later features that don't exist yet") names exactly this need — 002's trust boundary
was already built to be extended, just not with a body shape that happened to fit an action on
an *existing* ticket. Requiring the caller's own token to match the ticket's tenant/user
prevents one customer from confirming or reopening another tenant's ticket.
- **Alternatives considered**: A single unauthenticated or `fastify.authenticate`-gated endpoint
for both actor types — rejected; a customer is never an authenticated SupportHub principal
(Constitution Principle I — SaaS is the sole identity authority for its own end users), so reusing
the internal-agent auth mechanism for a customer-initiated action would be a security regression,
not a simplification. Sending a dummy `source`/`problem` value to satisfy the existing schema —
rejected as a hack that would misrepresent the request and pollute `validatedInboundBody` for a
handler that was never meant to receive it.
## Decision: Auto-close is a repeatable BullMQ job on the existing, unclaimed `CLEANUP` queue
- **Decision**: `src/jobs/cleanup/index.ts` (currently a log-only stub registered on
`QueueName.CLEANUP`, never wired into `queue.bootstrap.ts`) is extended the same way 008
extended `src/jobs/sla/index.ts` — a repeatable job (every 5 minutes; less time-sensitive than
008's breach detection, since this only ever fires after a multi-hour/day waiting period) whose
processor calls a single, directly-callable `ResolutionsService.runAutoCloseSweep()` — querying
every ticket with `status: 'RESOLUTION_PENDING_CUSTOMER'` whose most recent status-change
(`Ticket.updatedAt`) is older than the configured waiting period, transitioning each to
`RESOLVED`.
- **Rationale**: `CLEANUP` is exactly this kind of periodic housekeeping sweep, and — like
`SLA`/`ESCALATION` before this feature — was defined and left completely unregistered since the
original scaffold. Reusing it needs no new `QueueName` value. A directly-callable sweep method
(not only reachable through a running worker) is what let 008's breach-detection tests avoid a
real wait; the same shape applies here.
- **Alternatives considered**: A per-ticket delayed job scheduled at the moment `Resolution` is
recorded — rejected for the same reason 008 rejected the equivalent per-run design: a
reopened-then-re-resolved ticket, or a resolution recorded twice in error, would each need
their own cancel/reschedule bookkeeping a polling sweep avoids entirely.
## Decision: The auto-close waiting period is one system-wide config value, not a per-scope policy
- **Decision**: `env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS` (default `72`, i.e. 3 days), exposed via
a new `src/config/problem-resolution.ts` — `problemResolutionConfig.autoCloseWaitingHours` —
mirroring `orchestrationConfig.defaultStrategy`'s exact shape.
- **Rationale**: Doc 04 §9 describes "a configured waiting period" in the singular, system-wide
sense — not a per-product/category policy table the way 008's `SLAPolicy` is; doc 06 defines no
entity for a scoped auto-close policy. A single env-configured default (Constitution Principle
II — never hardcoded, but not over-modeled into a policy table nothing asks for) is the
proportionate reading.
- **Alternatives considered**: A `ResolutionPolicy` table scoped like `SLAPolicy` — rejected as
speculative; nothing in doc 04/06 describes per-context auto-close variation, unlike SLA's
explicit product/category/priority scoping in doc 06's own `SLAPolicy` shape.
## Decision: The reopen transition is two real, separately-audited status updates
- **Decision**: Reopening calls `ticketsService.updateStatus(ticketId, 'REOPENED', ...)` followed
immediately by `ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ...)` — two real
transitions through 003's existing state machine (both already valid edges:
`RESOLVED|CLOSED → REOPENED` and `REOPENED → IN_PROGRESS`), each producing its own
`SYSTEM_EVENT` ticket message and `TICKET_UPDATED` publish, rather than a single hop straight
to `IN_PROGRESS` that would skip recording the reopen milestone itself.
- **Rationale**: Doc 04 §9's own phrasing — "reopen... should re-enter the appropriate lifecycle
stage" — matches the state machine's own two-hop shape exactly; both hops are independently
meaningful audit events (Constitution Principle VI), not one compound action worth collapsing.
- **Alternatives considered**: A single, direct `RESOLVED|CLOSED → IN_PROGRESS` transition
(bypassing `REOPENED` as a status value entirely) — rejected; 003's state machine doesn't even
define that edge (only `REOPENED → IN_PROGRESS`), and skipping the `REOPENED` status would
erase a real lifecycle milestone doc 04 explicitly names.
## Decision: 008's SLA run is explicitly left untouched by reopen — no new decision needed here
- **Decision**: Reopening a ticket does not create, restart, or modify its existing `SLARun`
(008) in any way — the run (if one exists) simply remains in whatever terminal state it was
already in (`completed` or `breached`).
- **Rationale**: 008's own spec.md already closed this decision from its side ("SLA runs are 1:1
with a ticket's first successful assignment only... out of scope for this feature to define a
new run automatically") — this feature's job is only to confirm that boundary still holds, not
to re-litigate it. FR-018/SC-005 make this an explicit, tested guarantee rather than an
accidental side effect of simply not writing any `SLARun`-touching code.
- **Alternatives considered**: Restarting the SLA run on reopen — explicitly out of scope per
008's own spec; would require this feature to modify 008's already-shipped module, which
nothing in Phase 9's roadmap line asks for.
+315
View File
@@ -0,0 +1,315 @@
# Feature Specification: Problem Resolution
**Feature Branch**: `009-problem-resolution`
**Created**: 2026-09-03
**Status**: Draft
**Input**: User description: "Phase 9 of docs/10-implementation-roadmap.md: Investigation/
RootCause/Solution/SolutionImplementation/SolutionVerification/Resolution models and workflows,
customer confirmation + reopen flow. Per docs/04-ticketing-and-problem-management.md §3-9 and
docs/06-database-schema.md 'Domain: Problem Resolution'."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An agent records structured investigation findings (Priority: P1)
An agent investigating a problem records findings, evidence, and internal notes as a structured
record — not a free-text blob buried in a message — with its own status (`open`/`complete`). A
problem can have more than one investigation attempt over its lifetime, each preserved, not
overwritten.
**Why this priority**: Everything downstream (root cause, solution, verification) reads from or
references an investigation; nothing else in this feature can start without one existing first.
**Independent Test**: Record an investigation with findings and evidence for a problem; confirm
it's retrievable exactly as given, with its own investigator and timestamp.
**Acceptance Scenarios**:
1. **Given** an agent records an investigation with findings, **When** it's saved, **Then** it's
retrievable with `investigator`, `findings`, `evidence`, `internalNotes`, and `status` exactly
as given.
2. **Given** a problem already has a completed investigation, **When** a new investigation is
started for the same problem (e.g., after a failed verification, User Story 4), **Then** the
prior investigation's record is preserved unchanged — a new investigation is its own row, never
an overwrite of the earlier one.
3. **Given** `internalNotes` on an investigation, **When** any customer-facing view is composed,
**Then** that data is never included — internal notes are agent/admin-only, same "never shown
to customers" discipline as ticketing's `INTERNAL_NOTE` message type (004 §10).
---
### User Story 2 - An agent records a root cause, separate from the investigation (Priority: P1)
Once findings point to a cause, the agent records a root cause as its own record — distinct from
the investigation that surfaced it — typed as technical, configuration, external-dependency,
business, or a contributing factor.
**Why this priority**: A solution (User Story 3) is a response to a specific, recorded cause —
without one, "solving" a problem has nothing to be checked against.
**Independent Test**: Record a root cause of a given type for a problem with an investigation
already on file; confirm it's retrievable and distinct from the investigation record.
**Acceptance Scenarios**:
1. **Given** a problem with an investigation on file, **When** an agent records a root cause with
a type and description, **Then** it's retrievable as its own record, never merged into the
investigation's own fields.
2. **Given** a root cause type outside the five documented values, **When** it's submitted,
**Then** it's rejected — the type is a closed, validated set, not free text.
---
### User Story 3 - An agent proposes, approves, and implements a solution, each as its own state (Priority: P1)
A solution moves through distinct states — proposed, approved, implemented — never collapsed into
one mutable blob. Implementation is its own record: who implemented it, when, and any notes,
kept separate from the proposal itself.
**Why this priority**: Verification (User Story 4) and resolution (User Story 5) both need a
concrete, dated implementation record to verify and resolve against.
**Independent Test**: Propose a solution, approve it, then record its implementation; confirm all
three states are independently visible on the same solution record/its implementation relation.
**Acceptance Scenarios**:
1. **Given** a root cause on file, **When** an agent proposes a solution, **Then** it's stored
with `approved: false` by default.
2. **Given** a proposed solution, **When** it's approved, **Then** `approved` becomes `true`
approval is a distinct, explicit action, never implied by implementation happening.
3. **Given** an approved solution, **When** an agent records its implementation (notes,
implementer, timestamp), **Then** a `SolutionImplementation` record is created, one-to-one
with the solution — attempting a second implementation record for the same solution is
rejected, not silently overwritten.
4. **Given** a solution that has not been approved, **When** an implementation is attempted,
**Then** it's rejected — implementation without approval is never allowed.
---
### User Story 4 - A solution is verified; failure re-opens investigation or escalates (Priority: P2)
After implementation, the solution is verified by one of several methods (automated check,
technical test, customer confirmation, agent confirmation). A successful verification clears the
way to resolution (User Story 5). A failed verification either re-opens investigation (a fresh
investigation record for the same problem) or escalates the ticket — an agent's explicit choice,
not an automatic guess.
**Why this priority**: Depends on User Story 3 (something implemented to verify). Recording a
resolution without ever having verified anything would misrepresent what was actually confirmed.
**Independent Test**: Verify an implemented solution as failed; confirm no `Resolution` can be
recorded from it, and that either a fresh investigation exists or the ticket has been escalated,
per the agent's chosen path.
**Acceptance Scenarios**:
1. **Given** an implemented solution, **When** it's verified with `result: success`, **Then** a
`SolutionVerification` record is created (method, result, evidence, timestamp), one-to-one
with the solution.
2. **Given** an implemented solution, **When** it's verified with `result: failed`, **Then** no
resolution can reference this solution's verification as successful — a failed verification is
a real, recorded outcome, not silently discarded.
3. **Given** a failed verification and the agent chooses re-investigation, **When** that choice is
made, **Then** a new `Investigation` record is created for the same problem (User Story 1's own
"each attempt is its own row" rule).
4. **Given** a failed verification and the agent chooses escalation instead, **When** that choice
is made, **Then** the ticket transitions to `HUMAN_ESCALATION` through 003-ticketing's existing
state machine — 007's orchestration re-runs automatically from that transition alone, exactly
as it already does for any other route into `HUMAN_ESCALATION`; this feature does not invent a
second escalation mechanism alongside 008's.
---
### User Story 5 - A resolution is recorded, with configurable customer confirmation or auto-close (Priority: P1)
Once a solution is verified successful, a `Resolution` record captures the final outcome for the
ticket. Depending on configuration, the ticket either waits for explicit customer confirmation
before closing, or auto-closes after a configured waiting period with no response.
**Why this priority**: This is the feature's actual deliverable from the customer's point of
view — everything before this is agent-facing work product.
**Independent Test**: Record a resolution for a ticket with a successfully verified solution;
confirm the ticket reaches `RESOLUTION_PENDING_CUSTOMER`, then either an explicit confirmation or
the configured waiting period elapsing moves it to `RESOLVED`.
**Acceptance Scenarios**:
1. **Given** a solution with a successful verification, **When** an agent records a resolution,
**Then** a `Resolution` record is created (`outcome`, `resolvedBy`) and the ticket transitions
to `RESOLUTION_PENDING_CUSTOMER`.
2. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER`, **When** the customer explicitly confirms,
**Then** the ticket transitions to `RESOLVED`.
3. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER` with no customer response, **When** the
configured auto-close waiting period elapses, **Then** the ticket transitions to `RESOLVED`
automatically — durably, via a background job, never an in-memory timer (Constitution
Principle VII, same discipline 008's breach-detection job already established).
4. **Given** a `Resolution` is attempted without a successfully verified solution on file,
**When** it's attempted, **Then** it's rejected — a resolution must be backed by real,
recorded verification, never asserted on its own.
---
### User Story 6 - A resolved or closed ticket can be reopened (Priority: P2)
A customer or agent can reopen a `RESOLVED` or `CLOSED` ticket, which re-enters the appropriate
point in the lifecycle rather than starting over from `NEW`.
**Why this priority**: Depends on User Story 5 (a ticket has to have reached a closeable state
before reopening it means anything). Closes the loop 008 explicitly left open ("reopening... may
need its own SLA-run-restart decision").
**Independent Test**: Reopen a `RESOLVED` ticket; confirm it transitions to `REOPENED` and then
into an active lifecycle state, and that the prior resolution record remains on file, unaltered.
**Acceptance Scenarios**:
1. **Given** a `RESOLVED` or `CLOSED` ticket, **When** the customer or an agent reopens it,
**Then** the ticket transitions to `REOPENED` and then to `IN_PROGRESS` (003's existing
`REOPENED → IN_PROGRESS` transition) — never back to `NEW`.
2. **Given** a ticket is reopened, **When** the prior `Resolution` record is checked, **Then** it
remains on file exactly as it was — reopening never deletes or mutates history.
3. **Given** a ticket already has an SLA run (008) from its original assignment, **When** it's
reopened, **Then** no new SLA run is created and the existing one is left exactly as it was
(008's own Assumptions: "SLA runs are 1:1 with a ticket's first successful assignment only") —
this feature does not retroactively expand that boundary.
---
### Edge Cases
- What happens if an agent tries to record a root cause before any investigation exists for the
problem? Rejected — a root cause without a preceding investigation has nothing to be grounded
in (FR-006).
- What happens if a solution is proposed for a problem with no root cause on file? Rejected, same
reasoning as above (FR-009).
- What happens if two verification attempts are recorded for the same solution? Rejected — like
`SolutionImplementation`, `SolutionVerification` is one-to-one with its solution (doc 06's own
`@unique` on `solutionId`); a second verification attempt on an already-verified solution is out
of scope for this feature (re-verification of a previously-verified solution is not a flow doc
04 describes).
- What happens to a ticket's messages/attachments/assignment history when it's reopened? Nothing
— reopening only affects `Ticket.status`; every other record (007's `Assignment`, 008's
`SLARun`, this feature's own `Investigation`/`RootCause`/`Solution`/`Resolution` records) is
untouched by the reopen transition itself.
- What happens if the configured auto-close waiting period is set to zero or is unconfigured?
Zero is a valid configuration (auto-close as soon as the sweep next runs); unconfigured falls
back to a system default (Constitution Principle II — configuration over hardcoding, but a
default value must exist so the sweep job always has something to compare against).
- What happens if a ticket is reopened more than once? Each reopen is its own `REOPENED →
IN_PROGRESS` transition — no cap on how many times a ticket can be reopened is introduced by
this feature (counting reopens toward an escalation trigger remains 008's already-documented,
deliberately deferred `repeated_reopen` trigger type — this feature does not wire it up).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let an agent record an investigation (investigator, findings,
evidence, internal notes, status) for a problem.
- **FR-002**: Each investigation MUST be its own durable record — a new investigation for the
same problem (e.g., after a failed verification) MUST NOT overwrite a prior one.
- **FR-003**: Internal notes on an investigation MUST NEVER be exposed through any
customer-facing read path.
- **FR-004**: The system MUST let an agent record a root cause (type, description) for a
problem, as a record distinct from any investigation.
- **FR-005**: A root cause's type MUST be validated against the five documented values
(technical, configuration, external_dependency, business, contributing_factor) — never
free text.
- **FR-006**: Recording a root cause for a problem with no investigation on file MUST be
rejected.
- **FR-007**: The system MUST let an agent propose a solution for a problem (`approved: false`
by default), approve it explicitly, and record its implementation (notes, implementer,
timestamp) as a separate, one-to-one record.
- **FR-008**: Recording an implementation for a solution that has not been approved MUST be
rejected.
- **FR-009**: Proposing a solution for a problem with no root cause on file MUST be rejected.
- **FR-010**: The system MUST let an agent record a verification (method, result, evidence) for
an implemented solution, as a one-to-one record.
- **FR-011**: A verification's method MUST be validated against the four documented values
(automated, technical_test, customer_confirmation, agent_confirmation).
- **FR-012**: A failed verification MUST NOT permit a `Resolution` to be recorded against that
solution.
- **FR-013**: On a failed verification, the system MUST support either starting a fresh
investigation for the same problem (FR-002) or transitioning the ticket to `HUMAN_ESCALATION`
(003's existing state machine, triggering 007's existing orchestration subscriber
automatically) — the choice between the two is the recording agent's, not automatic.
- **FR-014**: The system MUST let an agent record a `Resolution` (outcome, resolvedBy) for a
ticket, only when a successfully verified solution exists for its problem — this transitions
the ticket to `RESOLUTION_PENDING_CUSTOMER`.
- **FR-015**: The system MUST let a customer explicitly confirm a pending resolution, transitioning
the ticket to `RESOLVED`.
- **FR-016**: The system MUST auto-transition a ticket from `RESOLUTION_PENDING_CUSTOMER` to
`RESOLVED` after a configured waiting period with no explicit customer confirmation — detected
by a durable background job, never an in-memory timer (Constitution Principle VII).
- **FR-017**: The system MUST let a customer or agent reopen a `RESOLVED` or `CLOSED` ticket,
transitioning it to `REOPENED` and then `IN_PROGRESS` — never back to `NEW`, and never
mutating any prior investigation/root-cause/solution/verification/resolution record.
- **FR-018**: Reopening a ticket MUST NOT create a new SLA run (008's existing 1:1-with-first-
assignment boundary is unchanged by this feature).
### Key Entities
- **Investigation**: A structured, per-attempt record of what an agent found while investigating
a problem — findings, evidence, internal notes — never free text buried in a message; a problem
can have more than one, each preserved.
- **Root Cause**: Why the problem happened, typed and recorded separately from what was found
(the investigation).
- **Solution**: What's proposed to fix the root cause, moving through proposed → approved states
explicitly.
- **Solution Implementation**: The one-to-one record of a solution actually being carried out —
who, when, and any notes — distinct from the proposal.
- **Solution Verification**: The one-to-one record of whether the implementation actually worked,
by which method.
- **Resolution**: The final, ticket-level outcome — distinct from the solution (what was done)
and the verification (whether it worked).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of investigation/root-cause/solution/implementation/verification/resolution
records, once created, remain retrievable exactly as given — no field silently dropped or
overwritten by a later action in the same problem's lifecycle.
- **SC-002**: 100% of internal-notes fields are absent from every customer-facing response,
verified by a direct comparison of the agent-facing and customer-facing read paths for the same
investigation.
- **SC-003**: 100% of resolutions recorded without a successfully verified solution on file are
rejected.
- **SC-004**: 100% of tickets reaching `RESOLUTION_PENDING_CUSTOMER` with no explicit customer
confirmation reach `RESOLVED` within one auto-close job cycle of their configured waiting
period elapsing.
- **SC-005**: 100% of reopened tickets leave every prior investigation/root-cause/solution/
verification/resolution record and SLA run untouched.
## Assumptions
- **This feature does not build a customer-facing confirmation UI** — "explicit customer
confirmation" (FR-015) is an API action a caller (a future customer portal, or 008/010's own
future UI work) can invoke; this feature's own scope is the backend transition and the
auto-close fallback, not a rendered confirmation page (010 — Agent/Admin UI — is a separate,
later roadmap phase).
- **Verification-failure escalation reuses 003-ticketing's existing `HUMAN_ESCALATION` state
transition and 007's already-automatic orchestration subscriber directly** — it does not create
a new `EscalationEvent` through 008's rule-based mechanism, since "solution verification failed"
is not one of doc 05 §6's ten escalation trigger types 008 modeled; inventing an eleventh type
for a single feature's own internal flow was judged unnecessary scope, not an oversight.
Re-escalation through the plain ticket-status transition is exactly what 007 was already built
to react to — no new coupling is introduced.
- **The auto-close waiting period is a single, system-wide configuration value** (Principle II —
configuration over hardcoding), not scoped per product/category the way 008's SLA policies are;
doc 04 §9 describes it as "a configured waiting period," not a per-context policy table, and
nothing in doc 06's schema defines a per-scope auto-close entity to resolve against.
- **`repeated_reopen` (008's already-inert escalation trigger type) is still not wired up by this
feature** — reopening increments no counter and triggers no escalation rule; this remains
future work exactly as 008's own Assumptions already documented, not something this feature
silently expands into.
- **A second verification attempt on an already-verified solution is out of scope** — doc 06's
`SolutionVerification.solutionId` is `@unique`, meaning at most one verification record per
solution; if a first verification fails and the agent chooses re-investigation (FR-013), any
new solution that comes out of that fresh investigation cycle gets its own new `Solution` row
(User Story 3) with its own verification slot — never a second write to the original one.
+355
View File
@@ -0,0 +1,355 @@
---
description: "Task list for 009-problem-resolution"
---
# Tasks: Problem Resolution
**Input**: Design documents from `specs/009-problem-resolution/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/problem-resolution-contract.md](./contracts/problem-resolution-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks. This feature's pure logic is the existence-chain
validation (each step's precondition) and the auto-close due-window predicate; the rest is
sequential-workflow wiring best proven end-to-end against real Postgres.
**Organization**: Tasks are grouped by user story (US1 = P1 investigation, US2 = P1 root cause,
US3 = P1 solution states, US4 = P2 verification, US5 = P1 resolution/confirmation/auto-close,
US6 = P2 reopen).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Populate `src/modules/problem-management/investigation/` with the full standard
shape (`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`), replacing the `InvestigationService.getInvestigationStatus` stub
- [x] T002 [P] Populate `src/modules/problem-management/root-causes/` the same way, replacing the
`RootCausesService.getRootCause` stub
- [x] T003 [P] Populate `src/modules/problem-management/solutions/` the same way, replacing the
`SolutionsService.getSolutions` stub
- [x] T004 [P] Populate `src/modules/problem-management/verification/` the same way, replacing
the `VerificationService.verifySolution` stub
- [x] T005 [P] Populate `src/modules/problem-management/resolutions/` the same way, replacing the
`ResolutionsService.getResolutions` stub — this module additionally gets the auto-close
sweep and the two new customer-facing routes (later tasks)
- [x] T006 [P] Add `src/config/problem-resolution.ts` (`problemResolutionConfig
.autoCloseWaitingHours`, reading a new `RESOLUTION_AUTO_CLOSE_WAITING_HOURS` env var,
default `72`) and register it in `src/config/index.ts`'s re-export list
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema for every entity, shared by every user story.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [x] T007 Add `Investigation`, `RootCause`, `Solution`, `SolutionImplementation`,
`SolutionVerification`, `Resolution` models to `prisma/schema.prisma` per data-model.md,
plus `Problem.investigations`/`Problem.rootCauses`/`Problem.solutions` and
`Ticket.resolution` back-relations (depends on T001-T005)
- [x] T008 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T007 (depends on T007)
**Checkpoint**: Schema migrated. User stories can now be built.
---
## Phase 3: User Story 1 - Structured investigation, preserved across attempts (Priority: P1) 🎯 MVP (part 1)
**Goal**: Investigation CRUD with the version-row-per-attempt guarantee and internal-notes
exclusion from customer-facing reads.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T009 [US1] Integration test covering Quickstart Scenario 1 (create, retrieve with every
field intact, customer-safe variant omits `internalNotes`, a second investigation preserves
the first) against a real Postgres in `tests/integration/problem-resolution-flow.test.ts`
(depends on T008)
### Implementation for User Story 1
- [x] T010 [US1] Add `InvestigationRepository` (`create`, `findAllForProblem` ordered newest
first, `findMostRecentForProblem`) in `investigation/repository/` (depends on T008)
- [x] T011 [US1] Add Zod create schema (`investigator`, `findings`, `evidence?`,
`internalNotes?`, `status?`) in `investigation/schema/`
- [x] T012 [US1] Add `InvestigationService.record`/`listForProblem` (agent-facing, includes
`internalNotes`) and `listForProblemCustomerSafe` (strips `internalNotes`, FR-003) in
`investigation/service/` (depends on T010, T011)
- [x] T013 [US1] Add `POST/GET /admin/problems/:problemId/investigations` (gated by
`fastify.authenticate`) and `GET /problems/:problemId/investigations` (ungated, customer-
safe) routes in `investigation/controller/` + `routes/`, registered from `src/api/routes.ts`
(depends on T012)
- [x] T014 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
**Checkpoint**: Investigations can be recorded and read correctly, with the customer-safe
redaction guarantee in place.
---
## Phase 4: User Story 2 - Root cause requires an investigation on file (Priority: P1) 🎯 MVP (part 2)
**Goal**: RootCause CRUD gated on an existing investigation, with a validated type enum.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (rejected with no investigation,
accepted after one exists, rejected with an invalid type) — implemented as the "Scenario 2"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T009, T014)
- [x] T016 [P] [US2] Unit test for the type-validation Zod schema (five valid values, everything
else rejected) in `tests/unit/problem-management/root-cause-schema.test.ts`
### Implementation for User Story 2
- [x] T017 [US2] Add `RootCauseRepository` (`create`, `findAllForProblem`) in
`root-causes/repository/` (depends on T008)
- [x] T018 [US2] Add Zod create schema (`type` as a 5-value enum, `description`) in
`root-causes/schema/`
- [x] T019 [US2] Add `RootCausesService.record`: resolve-or-`409` on the problem having at least
one investigation (T010's `findMostRecentForProblem`, via `investigation`'s public
`index.ts`) — in `root-causes/service/` (depends on T012, T017, T018)
- [x] T020 [US2] Add `POST /admin/problems/:problemId/root-causes` route (gated by
`fastify.authenticate`) in `root-causes/controller/` + `routes/`, registered from
`src/api/routes.ts` (depends on T019)
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
**Checkpoint**: Root causes are correctly gated on investigation existing first.
---
## Phase 5: User Story 3 - Solution proposed, approved, implemented as distinct states (Priority: P1) 🎯 MVP (part 3)
**Goal**: Solution CRUD gated on root cause existing; approval as an explicit action;
implementation gated on approval, one-to-one.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (rejected with no root cause,
created with `approved: false`, implementation rejected before approval, accepted after,
a second implementation rejected) — "Scenario 3" case in
`tests/integration/problem-resolution-flow.test.ts` (depends on T015, T021)
### Implementation for User Story 3
- [x] T023 [US3] Add `SolutionRepository` (`create`, `findById`, `approve`,
`findMostRecentForProblem`) and `SolutionImplementationRepository` (`create`, `findBySolutionId`)
in `solutions/repository/` (depends on T008)
- [x] T024 [US3] Add Zod schemas (`proposed`; implementation's `notes?`, `implementedBy`) in
`solutions/schema/`
- [x] T025 [US3] Add `SolutionsService.propose`: resolve-or-`409` on the problem having at least
one root cause (T017's repository, via `root-causes`'s public `index.ts`) — `approve` —
`recordImplementation`: resolve-or-`409` on `approved: true` and no existing implementation
— in `solutions/service/` (depends on T019, T023, T024)
- [x] T026 [US3] Add `POST /admin/problems/:problemId/solutions`,
`PATCH /admin/solutions/:solutionId/approve`,
`POST /admin/solutions/:solutionId/implementation` routes (gated by `fastify.authenticate`)
in `solutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
T025)
- [x] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 5 steps pass
**Checkpoint**: All three P1 record-keeping user stories are complete — the full investigation
through implementation chain is enforced and correct. This is the feature's structural MVP.
---
## Phase 6: User Story 4 - Verification, and failure re-investigates or escalates (Priority: P2)
**Goal**: SolutionVerification CRUD gated on implementation existing, one-to-one; a failed
verification supports either a fresh investigation or the existing `HUMAN_ESCALATION` transition.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T028 [US4] Integration test covering Quickstart Scenario 4 (successful verification;
failed verification recorded but unusable for resolution; failure + reinvestigate creates a
fresh Investigation row; failure + escalate transitions the ticket to `HUMAN_ESCALATION`
and 007 auto-assigns) — "Scenario 4" case in
`tests/integration/problem-resolution-flow.test.ts` (depends on T022)
### Implementation for User Story 4
- [x] T029 [US4] Add `SolutionVerificationRepository` (`create`, `findBySolutionId`) in
`verification/repository/` (depends on T008)
- [x] T030 [US4] Add Zod schema (`method` as a 4-value enum, `result`, `evidence?`) in
`verification/schema/`
- [x] T031 [US4] Add `VerificationService.record`: resolve-or-`409` on the solution having an
implementation (T023's repository) and no existing verification — in `verification/
service/` (depends on T023, T029, T030)
- [x] T032 [US4] Add `POST /admin/solutions/:solutionId/verification` route (gated by
`fastify.authenticate`) in `verification/controller/` + `routes/`, registered from
`src/api/routes.ts` (depends on T031)
- [x] T033 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass (steps 3-4 call
T012's `InvestigationService.record` and `ticketsService.updateStatus` directly — no new
production code beyond what US1/003/007 already provide, per research.md's decision to
reuse the existing transition rather than add new escalation machinery)
**Checkpoint**: Verification is correctly gated and its failure path reuses existing mechanisms
rather than inventing new ones.
---
## Phase 7: User Story 5 - Resolution, customer confirmation, and durable auto-close (Priority: P1)
**Goal**: Resolution gated on a successful verification; explicit customer confirmation via
002's trust boundary; a durable, directly-callable auto-close sweep as the fallback.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 5
- [x] T034 [P] [US5] Unit test for the auto-close due-window predicate (a pending ticket older
than the configured waiting period is due; a pending ticket younger than it is not; a
non-pending ticket is never selected) in
`tests/unit/problem-management/auto-close-sweep.test.ts`
- [x] T035 [US5] Integration test covering Quickstart Scenario 5 (resolution rejected without a
successful verification; accepted after, ticket reaches `RESOLUTION_PENDING_CUSTOMER`;
customer confirmation via the trust-boundary route reaches `RESOLVED`; a second ticket aged
past the configured window reaches `RESOLVED` via a direct call to the sweep) — "Scenario 5"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T028)
### Implementation for User Story 5
- [x] T036 [US5] Add `ResolutionRepository` (`create`, `findByTicketId`) in
`resolutions/repository/` (depends on T008)
- [x] T037 [US5] Add Zod schema (`outcome`, `resolvedBy`) in `resolutions/schema/`
- [x] T038 [US5] Add `ResolutionsService.record(ticketId, outcome, resolvedBy)`: resolves the
ticket's `problemId`, resolve-or-`409` on a `Solution` with `verification.result: 'success'`
existing for it (T023/T029's repositories), creates the `Resolution`, and transitions the
ticket to `RESOLUTION_PENDING_CUSTOMER` via `ticketsService.updateStatus` — in
`resolutions/service/resolutions.service.ts` (depends on T023, T029, T036, T037)
- [x] T039 [US5] Add `ResolutionsService.confirmByCustomer(ticketId)` /
`runAutoCloseSweep()`: the former transitions `RESOLUTION_PENDING_CUSTOMER → RESOLVED`
directly; the latter queries every `RESOLUTION_PENDING_CUSTOMER` ticket whose `updatedAt` is
older than `problemResolutionConfig.autoCloseWaitingHours` and transitions each the same way
— a single, directly-callable, side-effect-only method (research.md — no worker process
needed to invoke it in tests) — in `resolutions/service/resolutions.service.ts` (depends on
T006, T038)
- [x] T040 [US5] Add `POST /admin/tickets/:ticketId/resolution` (gated by `fastify.authenticate`)
and `POST /v1/support/tickets/:ticketId/confirm-resolution` (gated by
`fastify.authenticateProductIntegration` + `fastify.checkIntegrationRateLimit`, verifying
the token's tenant/user matches the ticket's own — research.md) routes in
`resolutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
T038, T039)
- [x] T041 [US5] Replace `registerCleanupWorker()`'s stub body in `src/jobs/cleanup/index.ts`:
schedule a repeatable job (every 5 minutes) on `QueueName.CLEANUP` whose processor calls
T039's `runAutoCloseSweep` — and register it from `src/bootstrap/queue.bootstrap.ts`
(depends on T039)
- [x] T042 [US5] Run Quickstart Scenario 5 locally and confirm all 4 steps pass
**Checkpoint**: Every P1 user story is complete. The full investigation-to-resolution chain
works, gated correctly at every step, with both an explicit and a durable-fallback path to
`RESOLVED`. This is the feature's MVP.
---
## Phase 8: User Story 6 - Reopen (Priority: P2)
**Goal**: A resolved or closed ticket can be reopened by the customer or an agent, re-entering
`IN_PROGRESS` through two real, audited transitions, touching nothing else.
**Independent Test**: Quickstart Scenario 6.
### Tests for User Story 6
- [x] T043 [US6] Integration test covering Quickstart Scenario 6 (customer reopen reaches
`IN_PROGRESS` via `REOPENED`; the prior `Resolution` and any `SLARun` are unchanged; agent
reopen of a `CLOSED` ticket produces the same result attributed to the agent) — "Scenario 6"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T035)
### Implementation for User Story 6
- [x] T044 [US6] Add `TicketsService.reopen(ticketId, actor)` (007/003's existing
`ticketing/tickets` module): resolve-or-`409` if status isn't `RESOLVED`/`CLOSED`, then two
sequential `updateStatus` calls (`REOPENED`, then `IN_PROGRESS`) — in `ticketing/tickets/
service/tickets.service.ts` (depends on T008 — no new schema, reuses 003's own state
machine and repository)
- [x] T045 [US6] Add `POST /v1/support/tickets/:ticketId/reopen` (customer, trust boundary) and
`POST /admin/tickets/:ticketId/reopen` (agent, `fastify.authenticate`) routes in
`ticketing/tickets/controller/` + `routes/` (depends on T044)
- [x] T046 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
**Checkpoint**: All six user stories work independently and together — the full doc 04 workflow,
from first investigation through resolution, confirmation, auto-close, and reopen.
---
## Phase 9: Polish & Cross-Cutting Concerns
- [x] T047 [P] Update `specs/009-problem-resolution/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T048 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T049 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration suite (including 003's and 007's own suites, since
T044 modifies `ticketing/tickets`) against real Docker-provisioned Postgres/Redis
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US6
- **User Story 2 (Phase 4)**: Depends on US1 (the investigation it's gated on)
- **User Story 3 (Phase 5)**: Depends on US2 (the root cause it's gated on)
- **User Story 4 (Phase 6)**: Depends on US3 (the implementation it's gated on)
- **User Story 5 (Phase 7)**: Depends on US4 (the successful verification it's gated on)
- **User Story 6 (Phase 8)**: Depends on US5 (a ticket has to reach `RESOLVED`/`CLOSED` before
reopening it means anything)
- **Polish (Phase 9)**: Depends on all six user stories
This feature's user stories are more strictly sequential than 007's or 008's — doc 04's own
workflow is a straight chain (investigation → root cause → solution → verification →
resolution → reopen), not a set of independently orderable capabilities, so each phase's
dependency here is real, not just priority-driven sequencing.
### Parallel Opportunities
- T001-T006 (independent scaffolding)
- T016 (unit test) alongside T017-T018 (the schema it tests)
- T034 (unit test) alongside T039 (the sweep it tests)
- T047 in Polish
---
## Implementation Strategy
### MVP First (User Stories 1-3, then 5)
1. Setup + Foundational (T001-T008)
2. User Story 1 (T009-T014) → investigations recorded and readable
3. User Story 2 (T015-T021) → root causes correctly gated
4. User Story 3 (T022-T027) → solutions proposed/approved/implemented correctly
5. **User Story 4 is P2** — skippable for a first MVP cut if verification's own gating isn't
needed yet, but User Story 5 (Resolution) depends on it structurally (a successful
verification is Resolution's own precondition), so in practice build order is 1→2→3→4→5
regardless of priority label — same "dependency order isn't always priority order" note 006
and 007's own tasks.md already made.
6. User Story 5 (T034-T042) → resolution, confirmation, and auto-close all work
7. **STOP and VALIDATE**: Quickstart Scenarios 1-5 pass.
### Incremental Delivery
1. Setup + Foundational → schema migrated
2. Add User Story 1 → investigations exist
3. Add User Story 2 → root causes correctly gated
4. Add User Story 3 → solutions move through real states
5. Add User Story 4 → verification gated, failure path reuses existing mechanisms
6. Add User Story 5 → resolution + confirmation + auto-close (P1-complete, MVP)
7. Add User Story 6 → reopen, closing the loop 008 left open
8. Polish → full regression
@@ -0,0 +1,68 @@
# Specification Quality Checklist: Identity and Authentication
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This feature was not on the original 11-phase roadmap — it surfaced as a genuine blocking gap
while planning supporthub-web's `001-agent-admin-ui`: `fastify.authenticate` has been a
complete no-op stub since 002, and `identity/auth`'s login endpoint has never taken a
password. Numbered 010 in supporthub-api's own sequence since it's a real, immediately-needed
backend prerequisite, not deferred hardening.
- `User`/`UserRole` (with two seeded-but-passwordless demo accounts,
`admin@supporthub.internal`/`agent@supporthub.internal`) and the `AuthUser`/`JwtPayload`
types in `src/common/types` were all found already scaffolded, unwired, and clearly intended
for exactly this feature since the original pre-speckit scaffold — this is a "finish the
originally-intended wiring" feature, not a new design invented from nothing.
- Scope is deliberately narrow: real login + real route gating + role checks + a self-identity
endpoint + admin-created accounts + logout. Password reset, MFA, rate-limiting, and
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
hardening pass" as the more appropriate later home for those.
- All items pass; no revision iterations were needed.
- **Implementation-time finding**: making `fastify.authenticate` genuinely reject invalid/missing
tokens (FR-004) had a far larger blast radius than this feature's own tasks.md anticipated.
Dozens of routes across features 002-009 were already declared with `fastify.authenticate` as
a preHandler — safe to write against a no-op stub, but every one of those pre-existing
integration tests had been calling them with no `Authorization` header. Making the check real
broke ~18 integration test files suite-wide, requiring a `tests/helpers/auth.ts` (`loginAs`/
`authHeader`) and a file-by-file pass adding real bearer tokens, well beyond the mechanical
`requireRole('ADMIN')` rollout research.md had scoped for. A related, recurring bug: several
files already declared a local `const token = issueIntegrationToken(...)` for the unrelated
002 customer-trust-boundary flow, and naming the new admin/agent token variable `token` in the
same scope produced a `ReferenceError: Cannot access 'token' before initialization` — a genuine
temporal-dead-zone collision, not a tooling bug — fixed by using a non-colliding name
(`authToken`/`adminToken`/`agentToken`) per file.
- A second, subtler implementation-time finding: once admin-setup calls in test `beforeAll`
blocks started actually succeeding (previously they silently 401'd against the no-op stub),
wildcard/global SLA policies created by one integration test file could genuinely match tickets
created by another file running against the same shared throwaway Postgres, leaving orphaned
`sla_run` rows that RESTRICT-violated the FK on cleanup. Fixed by widening the affected files'
`afterAll` cleanup to delete `sla_run` rows by `ticketId` *and* by `policyId`, not just one or
the other.
@@ -0,0 +1,48 @@
# Contract: Identity and Authentication
## Login
- `POST /auth/login` — body `{ email, password }`. `401` on any failure (wrong password, no
such account, or a deactivated account) with an identical response body/status in every case
(FR-002/SC-003) — never a distinguishable "no such user" vs "wrong password." `200` with
`{ token, user: { id, email, name, role } }` on success.
## Self-identity
- `GET /auth/me` — gated by `fastify.authenticate`. `401` if the token is missing/invalid/
expired/revoked. `401` if the account behind a structurally-valid token no longer exists or
is deactivated (re-validated against current state, not the token's own claims alone). `200`
with `{ id, email, name, role }` on success.
## Account creation (admin-only)
- `POST /admin/users` — gated by `fastify.authenticate` + `requireRole('ADMIN')`. Body
`{ email, name, role, password }` (`role` one of `ADMIN`/`AGENT`). `403` for a valid non-admin
session. `409` if `email` is already in use. `201` with the created `{ id, email, name, role
}` (never the password or its hash) on success.
## Logout
- `POST /auth/logout` — gated by `fastify.authenticate`. Revokes the calling token's own `jti`
(Redis denylist, TTL = remaining lifetime) so it's rejected on any further use even before its
natural expiry. `200` on success.
## Guarantees (callable contract)
1. **Every route already gated by `fastify.authenticate` across 002-009 continues to accept a
valid session and now genuinely rejects a missing/invalid/expired/revoked one** — the gate
itself changes from a no-op to a real check; which routes carry the gate is unchanged
(FR-006, SC-001).
2. **A route additionally gated by `requireRole('ADMIN')` rejects a structurally valid session
whose role isn't `ADMIN`, with a response distinguishable from "no valid session at all"**
(403 vs 401) (FR-005, SC-002).
3. **A login failure never reveals whether the submitted email corresponds to an existing
account** — verified by comparing the exact response for a wrong password against a wholly
nonexistent email (FR-002, SC-003).
4. **No password is ever stored, logged, or returned anywhere in plaintext** — only
`passwordHash` is persisted, and no response body (login, self-identity, account creation)
ever includes it (FR-003, SC-004).
5. **An admin-created account can log in immediately with the password it was created with, no
manual step in between** (FR-008, SC-005).
6. **A token revoked via logout is rejected on any further use, even before its natural expiry**
(FR-009).
+60
View File
@@ -0,0 +1,60 @@
# Data Model: Identity and Authentication
## User (modified — two additive columns)
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(uuid())` | unchanged |
| `email` | `String @unique` | unchanged |
| `name` | `String` | unchanged |
| `role` | `UserRole @default(CUSTOMER)` | unchanged enum (`ADMIN \| AGENT \| CUSTOMER`) — this feature never assigns `CUSTOMER` (research.md/spec.md Assumptions); every row this feature creates or updates is `ADMIN` or `AGENT` |
| **`passwordHash`** | **`String`** | **new** — bcryptjs hash, never the plaintext password; `NOT NULL` since every account this feature manages must be able to log in (FR-010 requires both seeded demo accounts to get a real one) |
| **`active`** | **`Boolean @default(true)`** | **new** — mirrors `Agent.active`'s existing convention exactly; a deactivated account's session is rejected on re-validation (Edge Cases/User Story 3), without a hard delete |
| `createdAt` / `updatedAt` | `DateTime` | unchanged |
## Agent (modified — one additive, nullable column)
| Field | Type | Notes |
|---|---|---|
| **`userId`** | **`String? @unique`** | **new** — nullable FK to `User.id`, the schema capability to identify which login identity a routing/skills profile belongs to (research.md). No endpoint in this feature sets it; a follow-up in `identity/agents` (006) is expected to. |
No new Prisma model for "Session" — a session is a signed JWT the server never persists
(research.md's short-lived-JWT-plus-revocation-denylist decision); the denylist itself lives in
Redis (`auth:revoked:<jti>`, TTL = remaining token lifetime), not Postgres.
## JwtPayload (existing type, `src/common/types/auth.types.ts` — one additive field)
| Field | Type | Notes |
|---|---|---|
| `sub` | `string` | the `User.id` |
| `email` | `string` | unchanged |
| `role` | `string` | unchanged — `User.role` at issuance time |
| `actorType` | `ActorType` | unchanged — always `ActorType.USER` for these sessions (research.md) |
| **`jti`** | **`string`** | **new** — random UUID per issued token, the revocation-denylist key |
| `iat` / `exp` | `number` | unchanged, standard JWT claims |
## AuthUser (existing type, unchanged)
`{ id, email, role, actorType }` — what `fastify.authenticate` sets on `request.user` after
verifying the token; the same fields returned by login (FR-001) and the self-identity endpoint
(FR-007), minus `jti`/`iat`/`exp` (those are token bookkeeping, not identity).
## Validation rules
- Login: `email` a valid email string, `password` non-empty. The response for "no such user"
and "wrong password" MUST be byte-for-byte identical (FR-002/SC-003) — achieved by always
running the bcrypt comparison against either the found user's hash or a fixed dummy hash when
no user is found, so the response timing and shape never differ by branch.
- Account creation (User Story 4): `email` valid and not already in use, `name` non-empty,
`role` one of `ADMIN`/`AGENT` (never `CUSTOMER`, research.md), `password` non-empty (hashed
before storage, never persisted or logged in plaintext).
## State / lifecycle
- `User.active` (new column, above) is the deactivation flag. The self-identity endpoint (User
Story 3) re-fetches the `User` row by `sub` on every call and rejects if it no longer exists
or `active: false` — this is the feature's only server-side re-validation path; `fastify
.authenticate` itself does not re-fetch on every request (that would defeat the point of a
stateless JWT check), so a deactivated account's *other* already-issued-token requests remain
valid until that token's natural expiry or an explicit logout, exactly as spec.md's Edge Cases
already scopes it ("a short token lifetime bounds the rest").
+136
View File
@@ -0,0 +1,136 @@
# Implementation Plan: Identity and Authentication
**Branch**: `010-identity-auth` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/010-identity-auth/spec.md`
## Summary
Finishes the original, never-wired scaffold: `identity/auth`'s email-only login stub becomes a
real bcryptjs-verified, JWT-issuing login; `fastify.authenticate` (currently a complete no-op)
becomes a real signature/expiry/revocation check populating `request.user` and the already-
shared `request.reqContext.actorId`/`actorType` fields every module since 007 already reads; a
new `requireRole(...roles)` preHandler factory adds role-based gating on top. `User` gains
`passwordHash` and `active` columns. Logout revokes a token's `jti` via the same Redis-denylist
shape 002's replay protection already established.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: `jsonwebtoken` (new — JWT sign/verify), `bcryptjs` (new — password
hashing, pure JS to avoid native-build friction on Windows dev environments). Reuses existing
`ioredis` for the revocation denylist.
**Storage**: PostgreSQL via Prisma (`User.passwordHash`, `User.active`). Redis for the
revocation denylist (`auth:revoked:<jti>`, TTL = remaining token lifetime) — same shape as
002's `hasSeenJti`/`markJtiSeen`.
**Testing**: Vitest — unit tests for password verification's identical-response-on-failure
behavior and the `requireRole` preHandler's role-matching logic; integration tests against real
Postgres/Redis for the full login → gated-route → logout flow, and specifically re-verifying at
least one already-shipped admin route per module (002-009) now genuinely rejects an invalid
session.
**Target Platform**: Same Fastify modular monolith. Modifies `src/plugins/auth.plugin.ts`,
populates `src/modules/identity/auth/`, adds `POST /admin/users` (a new small surface, placed
alongside `identity/agents`'s own admin routes since account management is an identity concern,
not `identity/auth`'s own — `identity/auth` owns login/logout/self-identity, not account CRUD).
**Project Type**: Backend service — single project.
**Performance Goals**: Token verification (signature + expiry + Redis denylist check) must stay
a single Redis round trip, not a Postgres query, on every gated request — only the self-identity
endpoint (User Story 3) re-fetches from Postgres, by design (research.md).
**Constraints**: MUST NOT reveal account existence via login's failure response (FR-002); MUST
NOT ever store or return a plaintext password (FR-003); MUST NOT change which existing routes
are gated, only make the gate real (FR-006); MUST re-validate against current account state on
the self-identity endpoint specifically, not on every request (data-model.md).
**Scale/Scope**: One modified plugin (`auth.plugin.ts`), one populated module
(`identity/auth`), one new small admin-account-creation surface, two new dependencies, two new
`User` columns, one seed-script update. Explicitly excludes: password reset, MFA, login-specific
rate-limiting, and retroactively adding `requireRole('ADMIN')` to every existing admin route
beyond a representative sample (research.md — tracked as this feature's own Polish-phase
mechanical task, not a redesign of any other feature's access model).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | This feature authenticates SupportHub's own staff (`User`/`Agent`), explicitly never `CUSTOMER`-role accounts (research.md/spec.md Assumptions) — customer identity remains exclusively SaaS-delegated via 002's own trust boundary, untouched by this feature. Matches the constitution's own carve-out: "SupportHub is the sole authority only for its own domain: ... support org structure." | PASS |
| II. Configuration Over Hardcoding | Token lifetime and any future role list are read from a config value (research.md's 4-hour default), never a magic number duplicated at each call site. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | `identity/auth` keeps its standard shape; `requireRole` is exported from `identity/auth`'s own public `index.ts` for other modules' routes to compose with, the same way `fastify.authenticate` itself is already a cross-cutting plugin-level primitive, not a module import. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involvement in this feature. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | This feature is what finally makes 007-009's own audit fields (`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) accurate for real agent/admin actions instead of always falling back to `'unknown'` (research.md) — directly strengthens, not just satisfies, this principle. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Token verification and revocation are stateless/Redis-TTL-based, not an in-memory timer; two concurrent login attempts for the same account are independently evaluated with no shared mutable state (spec.md Edge Cases). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature doesn't touch tickets or problems. | PASS — N/A |
| Technology & Platform Constraints | Two new, narrowly-scoped dependencies (`jsonwebtoken`, `bcryptjs`), both justified in research.md; reuses existing Redis infrastructure, no new infrastructure category introduced. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Principle VI is worth restating post-design:
this feature has no user-facing "audit" screen of its own, but its real effect is retroactively
correcting the audit trail of every feature since 007 that could only ever record `'unknown'`
as the acting agent/admin — a materially more accurate audit history the moment this ships.
## Project Structure
### Documentation (this feature)
```text
specs/010-identity-auth/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ ├── schema.prisma # MODIFIED — User.passwordHash, User.active
│ └── seed/roles.seed.ts # MODIFIED — seeded accounts get real password hashes
├── src/
│ ├── plugins/
│ │ └── auth.plugin.ts # REPLACED stub — real JWT verify + revocation
│ │ check, populates request.user + reqContext
│ ├── infrastructure/
│ │ └── cache/ # MODIFIED — revocation denylist helpers
│ │ alongside the existing jti-replay helpers
│ └── modules/
│ └── identity/
│ ├── auth/ # REPLACED stub — full real login/logout/
│ │ ├── controller/ routes/ schema/ self-identity, requireRole exported from
│ │ │ repository/ service/ types/ its own public index.ts
│ │ │ mapper/ constants/ index.ts
│ │ └── (no engine/ — no real decision logic beyond password/token checks)
│ └── agents/ # MODIFIED — new POST /admin/users route
│ └── (existing module, account-creation surface added alongside its own
│ existing agent-roster admin routes)
└── tests/
├── unit/identity/ # password-failure-response-parity,
│ requireRole matching logic
└── integration/ # full login/gating/logout flow, spot-checks
across 002-009's own existing admin routes
```
**Structure Decision**: Single project. `POST /admin/users` (account creation) lives under
`identity/agents` rather than `identity/auth`, since `identity/auth` owns authentication
mechanics (login/logout/self-identity) while account/roster management is already that
module's own established concern — mirrors 006's own precedent of `identity/agents` owning
agent-roster CRUD.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+52
View File
@@ -0,0 +1,52 @@
# Quickstart: Validating Identity and Authentication
Prerequisites: migrations applied; `npm run prisma:seed` run so the two demo accounts exist
with their new real passwords (documented in the seed script itself, local/dev use only).
## Scenario 1 — login (User Story 1)
1. `POST /auth/login` with the seeded admin's correct email/password. **Expected**: `200`, a
token, and `{id, email, name, role: 'ADMIN'}`.
2. Repeat with the correct email but a wrong password. **Expected**: `401`.
3. Repeat with an email that doesn't exist at all. **Expected**: the exact same `401` body/
status as step 2 — diff the two responses to confirm they're indistinguishable.
## Scenario 2 — route gating and role enforcement (User Story 2)
1. Call an existing admin route (e.g. `POST /admin/teams`) with no `Authorization` header.
**Expected**: `401`.
2. Repeat with a malformed token (`Bearer not-a-real-token`). **Expected**: `401`.
3. Log in as the seeded agent (role `AGENT`); call an admin-only route gated by
`requireRole('ADMIN')`. **Expected**: `403`.
4. Log in as the seeded admin; repeat step 3's call. **Expected**: `200`/`201` (whatever that
route normally returns on success).
## Scenario 3 — self-identity (User Story 3)
1. Log in; call `GET /auth/me` with the resulting token. **Expected**: `200`, identity matches
the login response exactly.
2. Directly deactivate that account (`active: false`) via a direct DB update (simulating an
admin action no UI exists for yet); repeat the same `GET /auth/me` call with the same,
still-unexpired token. **Expected**: `401` — re-validated against current account state, not
the token's own claims.
## Scenario 4 — admin creates an account (User Story 4)
1. Log in as admin; `POST /admin/users` with a new email/name/role `AGENT`/password.
**Expected**: `201`, response never includes the password or its hash.
2. Log in as a non-admin (the seeded agent); repeat step 1. **Expected**: `403`.
3. Immediately log in as the newly-created account with the password from step 1. **Expected**:
`200` — no manual step needed in between.
4. Repeat step 1 with an email already in use. **Expected**: `409`.
## Scenario 5 — logout (User Story 5)
1. Log in; call `POST /auth/logout` with the resulting token. **Expected**: `200`.
2. Immediately reuse that same token on any gated route. **Expected**: `401` — rejected even
though it hasn't naturally expired.
## What "done" looks like
All five scenarios pass, and Scenario 2 is additionally verified against at least one
already-shipped admin route from each of 002-009 (not just a route this feature itself adds),
proving the real gate actually protects what the no-op stub never did.
+149
View File
@@ -0,0 +1,149 @@
# Phase 0 Research: Identity and Authentication
## Decision: Finish the existing scaffold's own intended design, not a new one
- **Decision**: `User`/`UserRole`, the two seeded-but-passwordless demo accounts, and
`AuthUser`/`JwtPayload` in `src/common/types` are the real target — this feature adds a
`passwordHash` column, replaces `identity/auth`'s email-only stub with real password
verification and JWT issuance, and makes `fastify.authenticate` actually verify that JWT.
- **Rationale**: Every shape needed (the JWT payload's exact fields, the user/role model, even
the bootstrap accounts) was already scaffolded before this session's spec-driven rebuild
began — this is the same "give an existing, unwired scaffold its first real implementation"
pattern every other phase in this codebase has followed, not a new design decision.
- **Alternatives considered**: A separate, purpose-built `Session`/`Credential` model instead of
extending `User` — rejected; `User` already has exactly the fields a staff account needs
(email, name, role), and doc 06 never defined a competing entity for this.
## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one
- **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`,
no-default, required environment variable already defined in `src/config/env.ts` and already
set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven
rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS`
(a non-secret, defaultable number).
- **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/
`JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and
has simply never been read by any code until now.
- **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected
once `JWT_SECRET` was found; would create two secrets doing the identical job.
## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing
- **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept
consistent with `auth.plugin.ts`'s existing manual-decorator style rather than introducing the
`@fastify/jwt` plugin ecosystem) and `bcryptjs` (pure JavaScript, no native compilation step —
`bcrypt`/`argon2`'s native bindings are a real source of friction on this team's Windows dev
environment, confirmed earlier this session when Docker/Prisma tooling already needed
workarounds for the same class of platform friction).
- **Rationale**: Both are the standard, widely-used choice for their job; `bcryptjs`
specifically avoids re-litigating the native-module build problems this session has already
hit more than once on Windows.
- **Alternatives considered**: `@fastify/jwt` — rejected only for consistency with this
codebase's existing hand-rolled decorator style, not a correctness concern. `argon2`
rejected for the same native-build-friction reason as `bcrypt`; `bcryptjs` is a well-
established, secure-enough choice for this scale (JWT `Vitest`-verified via existing
precedent, not a cryptographic novelty).
## Decision: Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism
- **Decision**: `JwtPayload` gains a `jti` (JWT ID, a random UUID per issued token). Logout adds
that `jti` to a Redis key (`auth:revoked:<jti>`) with a TTL equal to the token's own remaining
lifetime. `fastify.authenticate` checks this key (in addition to verifying the signature and
expiry) before accepting a token.
- **Rationale**: This is the exact same shape as 002's `hasSeenJti`/`markJtiSeen` replay-
protection mechanism (`src/infrastructure/cache`) — reused directly rather than inventing a
second Redis-backed token-tracking pattern. A TTL equal to remaining lifetime means the
denylist entry is automatically cleaned up and never grows unbounded.
- **Alternatives considered**: A full server-side session table (every issued token recorded in
Postgres, checked on every request) — rejected as unnecessary weight; spec.md's own
Assumptions explicitly chose "short-lived JWT + revocation-on-logout-only" over full session
tracking, and Redis is already the right tool for this exact shape of check (fast, TTL-native).
## Decision: A 4-hour token lifetime
- **Decision**: Issued JWTs expire 4 hours after issuance (`exp` claim).
- **Rationale**: Long enough that a working agent isn't repeatedly forced to re-authenticate
mid-shift, short enough that a leaked/forgotten token's exposure window is bounded in hours,
not days — a reasonable default for an internal staff tool with no remember-me/refresh-token
flow in this feature's scope (Assumptions: no MFA/hardening pass yet either).
- **Alternatives considered**: A refresh-token pair (short-lived access token + long-lived
refresh token) — rejected as more mechanism than this feature's scope calls for; nothing in
spec.md's user stories requires silent re-authentication, and it can be added later without
breaking the token shape this feature establishes.
## Decision: `fastify.authenticate` also populates the existing, already-shared `reqContext.actorId`/`actorType`
- **Decision**: On a valid token, `fastify.authenticate` sets `request.user` (the full
`AuthUser`) AND `request.reqContext.actorId = user.id`, `request.reqContext.actorType =
ActorType.USER` — the same two `RequestContext` fields `authenticateProductIntegration`
already populates for customer-originated requests (002).
- **Rationale**: Every module from 007 onward already reads `request.reqContext?.actorId ??
'unknown'` in its controllers (`actorFrom(request)` helpers in assignments, tickets,
escalation, resolutions) expecting exactly this to eventually be populated by a real staff
auth mechanism — this was a forward-compatible convention already in place, not something
this feature needs to change call sites for. Every one of those audit trails (
`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) becomes accurate for real
agent/admin actions the moment this feature ships, with no changes to 007-009's own code.
- **Alternatives considered**: A separate `request.user`-only convention, leaving `reqContext
.actorId` customer-only — rejected; would require touching every existing `actorFrom` call
site across four already-shipped features for no benefit, when the field was clearly designed
to be auth-mechanism-agnostic from the start.
## Decision: Role-gating via a `requireRole(...roles)` preHandler factory, not a fixed decorator
- **Decision**: A new exported function, `requireRole(...allowedRoles: string[])`, returns a
Fastify preHandler that checks `request.user?.role` against the given list, throwing
`AuthorizationError` (403) if it doesn't match — used as
`{ preHandler: [fastify.authenticate, requireRole('ADMIN')] }`. Not a fixed
`fastify.requireAdmin` decorator, even though `ADMIN` is the only role checked today.
- **Rationale**: A factory function generalizes to any future role/permission check (e.g. a
hypothetical `SENIOR_AGENT`) without a new decorator per role; `fastify.authenticate` and
`requireRole` compose as two separate preHandlers, matching this codebase's existing
`[fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit]` two-step
preHandler-array convention exactly.
- **Alternatives considered**: A single combined `fastify.authenticateAdmin` decorator —
rejected; would duplicate `fastify.authenticate`'s own token-verification logic for every new
role instead of composing with it.
## Decision: `Agent.userId` is added as a nullable link, but linking is not this feature's own workflow
- **Decision**: `Agent` gains `userId String? @unique`, a nullable FK to `User.id` — the schema
capability to say "this login identity's routing/skills profile is that `Agent` row" — but
this feature does not add an endpoint or admin screen to set it. No seed data links the
demo agent account to an `Agent` row either (none is seeded for it today).
- **Rationale**: While investigating account creation (User Story 4), a real gap surfaced: a
`User` (the thing that logs in) and an `Agent` (the thing 006/007 route tickets to) have never
been connected — an `AGENT`-role `User` today has no way to be identified as a *specific*
`Agent` for "tickets assigned to me"-style queries supporthub-web's own agent dashboard will
need. Adding the column now is cheap and unblocks that later without a schema change at that
point; building the actual linking workflow (which almost certainly belongs in 006's
`identity/agents` admin screens, alongside team/skill assignment, not this identity/auth
feature) is a real, separate piece of scope this feature doesn't need to solve today.
- **Alternatives considered**: Building the full link-an-account-to-an-agent workflow as part of
this feature — rejected as scope creep; this feature's own job is proving a `User` *can*
authenticate and be authorized, not completing every downstream consumer of that identity.
Making `Agent.userId` required — rejected; an `Agent` created via 006's existing screens has
no `User` account requirement today and shouldn't suddenly need one just because this feature
exists.
## Decision: Which existing routes get gated is unchanged — only the gate itself becomes real
- **Decision**: This feature does not add `fastify.authenticate` to any route that doesn't
already have it, and does not add `requireRole('ADMIN')` to every existing admin route as
part of this feature's own implementation — SC-002 is satisfied by demonstrating the
mechanism works on a representative sample (one action per module), with the mechanical work
of adding `requireRole('ADMIN')` to every remaining `/admin/*` route across 002-009 tracked as
this feature's own Polish-phase task, not a scope expansion into re-designing any other
feature's authorization model.
- **Rationale**: FR-006 is explicit: "this feature does not change which routes are gated, only
makes the gate real." Deciding which of the many already-shipped admin routes should be
admin-only vs. any-authenticated-agent is a real per-route judgment call (e.g., should an
agent be able to create a hierarchy node? almost certainly not; should an agent read one?
probably yes) — this feature makes that judgment call possible to enforce, and applies it
everywhere in its own Polish phase, but doesn't silently redesign any other feature's own
intended access model beyond what's obviously admin-only (write/config endpoints) vs.
read/agent-usable.
- **Alternatives considered**: Leaving every existing route exactly as `fastify.authenticate`-
only (no `requireRole`) and treating role-based gating as entirely out of scope — rejected;
spec.md's own User Story 2/FR-005 explicitly requires admin-only enforcement to exist
somewhere concrete, not just as an available-but-unused mechanism.
+237
View File
@@ -0,0 +1,237 @@
# Feature Specification: Identity and Authentication
**Feature Branch**: `010-identity-auth`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "supporthub-web's admin/agent role-gating (its own Phase 1,
001-agent-admin-ui) has no real backend to build on: `fastify.authenticate` is a complete
no-op stub, and the existing `identity/auth` scaffold's login endpoint accepts an email alone
with no password and returns the raw user record, never a session. Build minimal, real
agent/admin authentication in supporthub-api first, as a prerequisite for the frontend feature."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An agent or admin logs in and receives a session (Priority: P1)
A user with a SupportHub-issued account (never a SaaS-delegated identity — this is SupportHub's
own staff, per Constitution Principle I's "support org structure" being SupportHub's own
authority) logs in with their email and password and receives a session token that authorizes
their subsequent requests.
**Why this priority**: Every other story in this feature, and the entire admin/agent-facing
half of supporthub-web, has nothing to build on without this.
**Independent Test**: Log in with a seeded account's correct credentials; confirm a session
token is returned and a subsequent authenticated request using it succeeds.
**Acceptance Scenarios**:
1. **Given** a user account with a set password, **When** they submit the correct email and
password, **Then** they receive a session token and their own `id`/`email`/`name`/`role`.
2. **Given** a user account, **When** they submit an incorrect password, **Then** the request
is rejected with no session token issued — the rejection message MUST NOT reveal whether the
email itself was valid (never "wrong password" vs "no such user" as distinguishable
responses).
3. **Given** no account exists for a submitted email, **When** login is attempted, **Then** it
is rejected with the same indistinguishable-from-wrong-password response as Scenario 2.
---
### User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1)
Every existing `/admin/*` route (and any other route already gated by the `fastify.authenticate`
stub across features 002-009) actually rejects a request with no valid session, and every route
that should be admin-only actually rejects a valid session whose role isn't `ADMIN`.
**Why this priority**: This is the entire point of the feature — without it, User Story 1
issues a token that nothing on the backend actually checks, which is no better than the current
no-op stub.
**Independent Test**: Call an existing admin route (e.g. creating a team) with no
`Authorization` header, with an expired/malformed token, with a valid agent-role token, and
with a valid admin-role token; confirm exactly the last one succeeds.
**Acceptance Scenarios**:
1. **Given** a request with no `Authorization` header, **When** it hits a route gated by
`fastify.authenticate`, **Then** it's rejected as unauthorized.
2. **Given** a request with a malformed, expired, or tampered token, **When** it hits a gated
route, **Then** it's rejected as unauthorized — never silently treated as anonymous/no-op the
way the current stub does.
3. **Given** a valid session for a user whose role is `AGENT`, **When** it hits a route that
requires the `ADMIN` role specifically, **Then** it's rejected as forbidden, distinct from
the unauthorized case above.
4. **Given** a valid session for a user whose role is `ADMIN`, **When** it hits any route gated
by either `fastify.authenticate` or an admin-only requirement, **Then** it succeeds.
---
### User Story 3 - An authenticated user can identify themselves (Priority: P2)
A logged-in user can ask "who am I" and get back their own identity and role, without needing
to decode their own session token client-side.
**Why this priority**: Depends on User Story 1. supporthub-web's role-gating (rendering the
admin portal only for admins) needs a reliable way to know the current session's role after
the token is already held — decoding a JWT's claims client-side is a reasonable fallback, but a
real endpoint is what lets that identity be revalidated against current server-side state (e.g.
a deactivated account) rather than trusting a possibly-stale token's own claims forever.
**Independent Test**: Log in, then call the "who am I" endpoint with the resulting session;
confirm it returns the same identity and role as the login response, and that it's rejected
under the same conditions as User Story 2.
**Acceptance Scenarios**:
1. **Given** a valid session, **When** the identity endpoint is called, **Then** it returns the
current `id`/`email`/`name`/`role` for that session.
2. **Given** a session for an account that has since been deactivated, **When** the identity
endpoint (or any gated route) is called, **Then** it's rejected — a session's validity is
re-checked against current account state, not just the token's own unexpired signature.
---
### User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
An admin creates a new user account (agent or admin role) with an initial password, since there
is no public self-signup for SupportHub's own staff accounts.
**Why this priority**: Depends on User Story 2 (admin-only gating). Without this, the only way
to add a second real account is a direct database write — fine for the one seeded bootstrap
admin, not for onboarding a real team.
**Independent Test**: As an admin, create a new agent account with a password; confirm the new
account can immediately log in (User Story 1) with those credentials.
**Acceptance Scenarios**:
1. **Given** an authenticated admin, **When** they create a new account with an email,
name, role, and initial password, **Then** it's created and can log in immediately.
2. **Given** a non-admin session, **When** they attempt to create an account, **Then** it's
rejected as forbidden (User Story 2's own guarantee, exercised here specifically).
3. **Given** an email already in use by an existing account, **When** account creation is
attempted, **Then** it's rejected — never a second account silently sharing one email.
---
### User Story 5 - A user logs out (Priority: P3)
A logged-in user can end their own session explicitly, rather than only ever waiting for it to
expire.
**Why this priority**: Lowest priority — a short-lived token that simply expires already
bounds the exposure of a lost/leftover session; an explicit logout is a UX nicety layered on
top, not a security-critical gap the way User Stories 1-2 are.
**Independent Test**: Log in, log out, then attempt to use the same token again; confirm it's
now rejected.
**Acceptance Scenarios**:
1. **Given** a valid session, **When** the user logs out, **Then** that specific token is
rejected on any subsequent use, even though it hasn't yet expired.
---
### Edge Cases
- What happens to a session already issued to a user whose password is changed or whose account
is deactivated? Out of scope for this feature to build a full revocation-on-every-write
mechanism (Assumptions) — User Story 3's re-check-on-identity-call is the only server-side
re-validation this feature guarantees; a short token lifetime (Assumptions) bounds the rest.
- What happens if two login attempts for the same account happen concurrently with different
passwords (e.g. a credential-stuffing attempt racing a real login)? Each is evaluated
independently against the stored password hash — no shared mutable state between them, so no
new concurrency concern is introduced.
- What happens to the two demo accounts the seed script already creates
(`admin@supporthub.internal`, `agent@supporthub.internal`) which currently have no password?
This feature MUST give them real, seeded passwords (documented for local/dev use only) so the
existing seed script keeps producing an immediately-usable bootstrap admin — never account
IDs that exist but can never actually log in.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let a user log in with email and password, returning a session
token and their own identity (`id`/`email`/`name`/`role`) on success.
- **FR-002**: A login attempt with an incorrect password or an unrecognized email MUST be
rejected with an indistinguishable response — the system MUST NOT reveal whether a submitted
email corresponds to an existing account.
- **FR-003**: Passwords MUST be stored only as a salted hash, never in plaintext or in any
reversible form.
- **FR-004**: `fastify.authenticate` MUST reject a request with a missing, malformed, expired,
or otherwise invalid session token — it MUST NOT pass a request through as anonymous/no-op
the way the current stub does.
- **FR-005**: The system MUST provide a way to require a specific role (at minimum, `ADMIN`)
on a route, distinct from and layered on top of `fastify.authenticate`'s own valid-session
check, returning a distinguishable forbidden (not unauthorized) response when the role
requirement fails.
- **FR-006**: Every existing route currently gated by `fastify.authenticate` (across
002-009's own admin/read surfaces) MUST continue to work for a valid session and MUST now
actually reject an invalid one — this feature does not change which routes are gated, only
makes the gate real.
- **FR-007**: The system MUST provide an endpoint that returns the current session's own
identity and role, re-validated against current account state (not solely the token's own
claims).
- **FR-008**: The system MUST let an authenticated admin create a new account (email, name,
role, initial password), rejecting a duplicate email.
- **FR-009**: The system MUST let a user invalidate their own current session token before its
natural expiry.
- **FR-010**: The two existing seeded demo accounts MUST be given real, working passwords as
part of this feature, documented as local/development credentials.
### Key Entities
- **User**: A SupportHub staff identity — email, name, role (`ADMIN`/`AGENT`), and (new in this
feature) a securely hashed password. Distinct from `Agent` (the routing/skills/team-membership
profile an `AGENT`-role `User` has) and from a SaaS-delegated customer identity, which this
feature does not touch.
- **Session**: The short-lived, server-issued proof that a `User` authenticated successfully,
carrying their `id`, `email`, and `role`; revocable before its natural expiry (User Story 5).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of requests to a `fastify.authenticate`-gated route with no valid session are
rejected, verified across every module's existing admin routes (002-009), not just this
feature's own new endpoints.
- **SC-002**: 100% of admin-only actions are rejected for a valid non-admin session, verified for
at least one action from each module that has one.
- **SC-003**: 0% of login rejections reveal whether the submitted email corresponds to an
existing account, verified by comparing the exact response for both cases.
- **SC-004**: 100% of passwords are stored only as a hash — verified by inspecting the stored
representation directly, never as a value that could be reversed to the original password.
- **SC-005**: An admin can create a working new account and have it log in successfully within
the same test run, with no manual database step.
## Assumptions
- **No password-reset/forgot-password flow is built in this feature** — an admin can create a
new account (User Story 4), but resetting an existing one's forgotten password is out of
scope; the smallest viable fix today is an admin recreating the account or a direct
operational fix, not a self-service flow.
- **Session tokens are short-lived JWTs with a fixed expiry, not a server-side session store per
token** — logout (User Story 5) is implemented via a revocation check (a denylist of
logged-out-early tokens), not full server-side session tracking; this keeps token validation
fast (no DB round trip on every request) while still making explicit logout meaningfully
different from "wait for expiry." The exact expiry duration and revocation mechanism are
research.md decisions, not fixed here.
- **No account self-registration** — every account is created either by the seed script (the
two bootstrap demo accounts) or by an existing admin (User Story 4); there is no public
sign-up endpoint, consistent with these being SupportHub's own staff accounts, never a
SaaS-delegated customer identity.
- **This feature does not add a password-strength policy, MFA, or rate-limiting specifically
for login attempts beyond what 002's existing generic rate-limit infrastructure might already
cover incidentally** — those are real hardening concerns explicitly named in
`docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), not this
feature's job to anticipate.
- **The `CUSTOMER` value already defined on `UserRole` is never assigned by this feature** — no
code path in this feature creates a `User` with `role: CUSTOMER`; per Constitution Principle
I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never
a local `User` row. The enum value's continued existence is a pre-existing scaffold detail
this feature doesn't need to remove to stay correct.
+285
View File
@@ -0,0 +1,285 @@
---
description: "Task list for 010-identity-auth"
---
# Tasks: Identity and Authentication
**Input**: Design documents from `specs/010-identity-auth/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/identity-auth-contract.md](./contracts/identity-auth-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks. Pure logic worth a unit test: the identical-failure-
response behavior (FR-002/SC-003) and the `requireRole` matching logic. Everything else is
best proven end-to-end against a real Postgres/Redis, including a specific pass re-verifying
existing 002-009 admin routes now actually reject an invalid session.
**Organization**: Tasks are grouped by user story (US1 = P1 login, US2 = P1 route/role gating,
US3 = P2 self-identity, US4 = P2 admin-created accounts, US5 = P3 logout).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`,
`@types/bcryptjs`) to `package.json`
- [x] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and
`AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`,
exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`,
`authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape
- [x] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its
existing files, replacing the email-only `AuthService.validateCredentials`/
`AuthRepository.findByEmail`-only stub content
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema for the entities every user story needs.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [x] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean
@default(true)`) to `prisma/schema.prisma`, plus `Agent.userId` (`String? @unique`, FK to
`User.id` — research.md's additive, not-yet-consumed link) (depends on T001-T003)
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`)
for T004 (depends on T004)
- [x] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both
seeded accounts (`admin@supporthub.internal`, `agent@supporthub.internal`), documenting
the plaintext dev password in a comment directly above the hash call (local/dev use only,
per spec.md Edge Cases) (depends on T005)
**Checkpoint**: Schema migrated, demo accounts have real passwords. User stories can now be
built.
---
## Phase 3: User Story 1 - An agent or admin logs in and receives a session (Priority: P1) 🎯 MVP (part 1)
**Goal**: Real password verification and JWT issuance, with an identical failure response
regardless of which reason login failed.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and
given no user found at all, the login-failure path produces byte-identical response
shape/status in the non-matching and no-user cases — in
`tests/unit/identity/login-failure-parity.test.ts`
- [x] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a
token + identity; wrong password and nonexistent email produce the same `401`) against a
real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006)
### Implementation for User Story 1
- [x] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken`
(jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per
data-model.md) in `identity/auth/mapper/` (depends on T002)
- [x] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in
`identity/auth/repository/` (depends on T005)
- [x] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against
either the found hash or a fixed dummy hash when not found (FR-002's timing/shape
parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for
every failure branch — in `identity/auth/service/` (depends on T009, T010)
- [x] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the
email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered
from `src/api/routes.ts` (depends on T011)
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
**Checkpoint**: Login works and never leaks account existence through its failure response.
---
## Phase 4: User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1) 🎯 MVP (part 2)
**Goal**: `fastify.authenticate` actually verifies; `requireRole` enforces role on top of it;
every existing gated route is re-verified.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong
role throws `AuthorizationError`, no `request.user` at all throws) in
`tests/unit/identity/require-role.test.ts`
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token,
wrong-role token, correct-role token) against a real Postgres/Redis in
`tests/integration/identity-auth-flow.test.ts` (depends on T008)
- [x] T016 [US2] Integration test spot-checking at least one existing admin route per module
(002's product-integration admin route, 004's knowledge admin route, 006's team-creation
route, 007's manual-assignment route, 008's SLA-policy route, 009's investigation route)
now rejects a missing/invalid session — in `tests/integration/identity-auth-flow.test.ts`
(depends on T015)
### Implementation for User Story 2
- [x] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in
`src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same
Redis-key-with-TTL shape, research.md) (depends on T002)
- [x] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and
expiry, check T017's revocation denylist, and on success set `request.user` (the full
`AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on
any failure, never pass through as anonymous (depends on T009, T017)
- [x] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks
`request.user?.role`, throws `AuthorizationError` if it doesn't match) in
`identity/auth/service/` (or a dedicated `identity/auth/guards/` file), exported from
`identity/auth`'s public `index.ts` (depends on T018)
- [x] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across
002-009 that doesn't already distinguish agent-vs-admin access (product-integration
admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only
routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`-
only (research.md's own scoping: this is a mechanical pass applying an existing judgment,
not a new design) (depends on T019)
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
**Checkpoint**: Every P1 user story is complete — a session is real, and it's actually checked
everywhere it's supposed to be. This is the feature's MVP.
---
## Phase 5: User Story 3 - An authenticated user can identify themselves (Priority: P2)
**Goal**: A self-identity endpoint that re-validates against current account state.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login;
deactivating the account rejects a still-unexpired token's use of this endpoint
specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 3
- [x] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws
`AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/
service/` (depends on T010)
- [x] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/
controller/` + `routes/` (depends on T023)
- [x] T025 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
**Checkpoint**: A session can be introspected and is re-validated against live account state.
---
## Phase 6: User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
**Goal**: Admin-only account creation, immediately usable to log in.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and
it logs in immediately; non-admin rejected; duplicate email rejected) — in
`tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 4
- [x] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on
duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md
— account creation lives alongside `identity/agents`'s own roster CRUD, not
`identity/auth`) (depends on T009)
- [x] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` +
`requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from
`src/api/routes.ts` — response never includes the password or hash (depends on T019, T027)
- [x] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass
**Checkpoint**: New staff accounts can be provisioned without a manual database write.
---
## Phase 7: User Story 5 - A user logs out (Priority: P3)
**Goal**: Explicit, immediate session revocation.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 5
- [x] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same
token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts`
(depends on T021)
### Implementation for User Story 5
- [x] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken`
— in `identity/auth/service/` (depends on T017)
- [x] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in
`identity/auth/controller/` + `routes/` (depends on T031)
- [x] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
**Checkpoint**: All five user stories work independently and together — real login, real
gating, self-identity, admin-provisioned accounts, and logout form one coherent auth system.
---
## Phase 8: Polish & Cross-Cutting Concerns
- [x] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing
broke elsewhere, then the full integration suite (including 002-009's own suites, since
T020 adds `requireRole` to their existing routes) against real Docker-provisioned
Postgres/Redis
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US5
- **User Story 2 (Phase 4)**: Depends on US1 (a real token to verify)
- **User Story 3 (Phase 5)**: Depends on US2 (the gate US3's own route sits behind)
- **User Story 4 (Phase 6)**: Depends on US2 (`requireRole('ADMIN')`)
- **User Story 5 (Phase 7)**: Depends on US2 (the gate logout's own route sits behind) and
US1's token shape (`jti`)
- **Polish (Phase 8)**: Depends on all five user stories
### Parallel Opportunities
- T001-T003 (independent scaffolding)
- T007 (unit test) alongside T009-T011 (the implementation it tests)
- T014 (unit test) alongside T019 (the implementation it tests)
- T034 in Polish
### Sequencing Note
T020 (adding `requireRole('ADMIN')` across 002-009's existing routes) is the one task in this
feature that touches code outside `identity/*` — run each touched module's own existing test
suite immediately after, not only in T036's final regression pass, so a role-gating regression
in, say, 007's own suite is caught close to its cause rather than at the very end.
---
## Implementation Strategy
### MVP First (User Stories 1-2 Only)
1. Setup + Foundational (T001-T006)
2. User Story 1 (T007-T013) → login works, no account-existence leak
3. User Story 2 (T014-T021) → the gate is real everywhere it already existed
4. **STOP and VALIDATE**: Quickstart Scenarios 1-2 pass, including the cross-module spot-check
(T016). This is the feature's MVP — every other user story is a smaller addition on top of a
now-real auth system.
### Incremental Delivery
1. Setup + Foundational → schema migrated, demo accounts have real passwords
2. Add User Story 1 → login is real
3. Add User Story 2 → the gate is real everywhere (P1-complete, MVP)
4. Add User Story 3 → self-identity, re-validated against live account state
5. Add User Story 4 → admins can provision new accounts
6. Add User Story 5 → explicit logout
7. Polish → full regression across every feature this touches
@@ -0,0 +1,58 @@
# Specification Quality Checklist: Agent Ticket Queue
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This feature was not on the original 11-phase roadmap, and wasn't anticipated by 010's own
scope either — it surfaced while beginning supporthub-web's 001-agent-admin-ui planning: its
User Story 1 (agent dashboard) needs to list "tickets currently assigned to me," and no route,
repository method, or even a documented gap anywhere in the ticketing or orchestration modules
answers that question. Numbered 011 in supporthub-api's own sequence for the same reason 010
was — a genuine, immediately-needed backend prerequisite discovered while building the
consuming feature, not deferred hardening.
- User Story 1 (linking `Agent.userId`) is itself a "finish the scaffold's own intended design"
case, same pattern as 010: the field was added in 010-identity-auth specifically for this
purpose ("schema capability only, no workflow sets it yet") and simply never got its own
endpoint until now.
- Deliberately narrow: this is not a general ticket search/list endpoint (Assumptions) — only
the one query supporthub-web's agent dashboard actually needs, to avoid speculative scope
beyond what 001-agent-admin-ui's own spec calls for.
- All items pass; no revision iterations were needed.
- **Implementation-time finding**: research.md's plan to add a dedicated
`AgentsService.requireAgentForUser` guard (rather than inlining the lookup in the ticketing
controller) turned out to matter for testability, not just style — it let T007's unit test
exercise the "no linked agent" rejection with a fake repository, with no real database
involved, exactly the kind of isolated unit coverage tasks.md asked for. Worth defaulting to
this shape (a small service method over inline controller logic) whenever a cross-module
guard needs its own unit test.
- No other deviations from plan.md — the two-routes-sharing-one-service-method design, the
proactive existence/role/duplicate-link checks, and the new composite index all worked exactly
as researched, and the full regression suite (unit + integration) stayed clean throughout.
@@ -0,0 +1,68 @@
# Contract: Agent Ticket Queue
## `PATCH /admin/agents/:agentId` (existing route, extended)
**Auth**: `fastify.authenticate` (unchanged — this route was already agent-usable, not
admin-only, since agents may already update their own roster fields per existing precedent).
**Request body** (existing shape plus one new optional field):
```json
{
"name": "string, optional",
"teamId": "string, optional",
"active": "boolean, optional",
"userId": "string | null, optional"
}
```
**Responses**:
- `200` — updated `Agent`, including `userId`.
- `404``agentId` doesn't exist, or (new) the target `userId` doesn't exist as a `User`.
- `400` — (new) the target `User`'s role is not `AGENT`.
- `409` — (new) the target `userId` is already linked to a different `Agent`.
## `GET /agents/me/tickets`
**Auth**: `fastify.authenticate` only — no `requireRole`, since any authenticated `AGENT` (or
`ADMIN`, who may also hold an agent profile) may call this for their own session.
**Response `200`**:
```json
{
"success": true,
"data": [
{
"id": "string",
"code": "string",
"status": "string",
"priority": "string",
"severity": "string",
"product": { "id": "string", "externalProductId": "string", "name": "string" },
"customer": { "externalUserId": "string", "externalTenantId": "string" },
"assignedAt": "ISO 8601 datetime",
"sla": {
"status": "string",
"firstResponseDueAt": "ISO 8601 datetime | null",
"resolutionDueAt": "ISO 8601 datetime | null",
"breachedAt": "ISO 8601 datetime | null"
}
}
],
"meta": null
}
```
`sla` is `null` when no `SLARun` exists yet for that ticket.
**Response `404`**: the session's `User` has no linked `Agent` row
(`{ "success": false, "error": { "code": "NOT_FOUND", "message": "No agent profile is linked to this account." } }`).
## `GET /admin/agents/:agentId/tickets`
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
**Response**: identical shape to `GET /agents/me/tickets`'s `200`, for the `agentId` named in
the URL. `404` if `agentId` doesn't exist as an `Agent` row (a plain "agent not found," distinct
from the self-route's "no agent linked to this account").
@@ -0,0 +1,48 @@
# Data Model: Agent Ticket Queue
## Modified: `Agent`
No new column — `userId`/`user` already exist (010-identity-auth). This feature is the first to
actually write `userId` through an endpoint, and adds the supporting index below.
```prisma
model Assignment {
// ...existing fields unchanged...
@@index([ticketId, isCurrent])
@@index([agentId, isCurrent]) // NEW — supports "current assignments for agent X"
@@map("assignments")
}
```
## New (response-shape only, no new table): `AssignedTicketSummary`
A read projection, not a persisted entity — assembled per-request from `Ticket` joined to its
current `Assignment`, `Product`, `CustomerReference`, and (if present) `SLARun`.
| Field | Source | Notes |
|---|---|---|
| `id` | `Ticket.id` | |
| `code` | `Ticket.code` | e.g. `ACME-2026-0042` |
| `status` | `Ticket.status` | One of the 12 lifecycle states (003's own state machine) |
| `priority` | `Ticket.priority` | Opaque string, as already modeled |
| `severity` | `Ticket.severity` | Opaque string, as already modeled |
| `product` | `Ticket.product` | `{ id, externalProductId, name }` |
| `customer` | `Ticket.customer` | `{ externalUserId, externalTenantId }` — no PII beyond what 002's own `CustomerReference` already stores |
| `assignedAt` | `Assignment.assignedAt` | The current assignment's start time |
| `sla` | `SLARun` (nullable) | `{ status, firstResponseDueAt, resolutionDueAt, breachedAt }` or `null` if no `SLARun` exists yet for this ticket |
## Validation / Business Rules
- **Linking** (`PATCH /admin/agents/:agentId`'s new `userId` field):
- The target `User` must exist and have role `AGENT` (FR-001).
- No other `Agent` row may already have that `userId` (FR-001) — checked proactively before
the write (research.md), not left to the database's own `@unique` constraint to reject.
- `userId: null` explicitly unlinks (distinct from omitting the field, which leaves it
unchanged — the existing `updateAgentSchema` pattern for optional fields).
- **Listing** (`GET /agents/me/tickets`, `GET /admin/agents/:agentId/tickets`):
- Only `Assignment.isCurrent: true` rows are considered (FR-003).
- The agent-self route resolves `agentId` exclusively from `request.user.id``Agent.userId`
lookup — never from any request input (FR-004).
- A session with no linked `Agent` row throws a specific `NotFoundError`
("No agent profile is linked to this account."), never an empty array (FR-006).
+110
View File
@@ -0,0 +1,110 @@
# Implementation Plan: Agent Ticket Queue
**Branch**: `011-agent-ticket-queue` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/011-agent-ticket-queue/spec.md`
## Summary
Finishes wiring `Agent.userId` (added in 010-identity-auth as schema-only) by extending the
existing `PATCH /admin/agents/:agentId` with an optional `userId`, then adds the ticket-query
this unblocks: `GET /agents/me/tickets` (agent's own session) and
`GET /admin/agents/:agentId/tickets` (admin, explicit agent) — both returning the same
summarized, dashboard-ready projection of every ticket currently assigned to that agent.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
**Primary Dependencies**: None new — reuses Prisma, the existing `identity/agents` and
`ticketing/tickets` modules, and 010's `requireRole`.
**Storage**: PostgreSQL via Prisma. Adds one index (`Assignment @@index([agentId, isCurrent])`)
— the query this feature introduces (all current assignments for one agent) has no supporting
index today; the existing `[ticketId, isCurrent]` index doesn't serve an agent-first lookup.
**Testing**: Vitest — unit test for the "no linked Agent" rejection path; integration tests
against real Postgres/Redis for linking, the agent's-own-session query, the admin explicit-
agent query, and cross-agent isolation (one agent never sees another's tickets).
**Target Platform**: Same Fastify modular monolith. Modifies `identity/agents` (linking
endpoint, `userId` already returned by existing reads) and `ticketing/tickets` (new summary
query + routes) — no new module, since "list my tickets" is a ticketing concern reading
orchestration's `Assignment` state, matching 003's existing module boundary (ticketing already
depends on orchestration's public surface for status-transition side effects).
**Project Type**: Backend service — single project.
**Performance Goals**: The ticket-summary query is one indexed query for current assignments
plus a single batched fetch of their tickets (with product/customer/SLA-run relations) — no
N+1 per-ticket round trip, matching FR-003/SC-001's "single request" requirement.
**Constraints**: MUST NOT let an agent's own-session call accept a client-supplied `agentId`
(FR-004 — always resolved from the session's own linked `Agent` row). MUST reject a session
with no linked `Agent` row distinguishably from an empty list (FR-006).
**Scale/Scope**: One new admin endpoint (link), two new read endpoints (agent-self, admin-
explicit) sharing one service method, one new Prisma index. Explicitly excludes: a general
ticket search/filter endpoint, pagination, and self-service linking (spec.md Assumptions).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Purely internal to SupportHub's own domain (agent roster, ticket assignment) — no SaaS/customer identity involved. | PASS — N/A |
| II. Configuration Over Hardcoding | No new configurable values introduced. | PASS — N/A |
| III. Layered Architecture With Enforced Module Boundaries | The new query lives in `ticketing/tickets` (the module that owns `Ticket`), reading `Assignment` via orchestration's own public `index.ts` export — no reach-through to orchestration's internals. The link endpoint lives in `identity/agents`, alongside its existing agent CRUD. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
| VI. Durable Audit & History | No new mutable state beyond the `Agent.userId` link itself, which `Agent`'s own `updatedAt` already timestamps. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries plus one simple linking write guarded by the existing `@unique` constraint on `Agent.userId` (a concurrent double-link race is rejected by the database itself, not application logic). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — no problem-management involvement. | PASS — N/A |
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/011-agent-ticket-queue/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — Assignment @@index([agentId, isCurrent])
└── src/
└── modules/
├── identity/
│ └── agents/ # MODIFIED — link-user endpoint alongside existing agent CRUD
│ ├── controller/ routes/ schema/
│ └── service/
└── ticketing/
└── tickets/ # MODIFIED — new agent-assigned-tickets summary query
├── controller/ routes/ schema/
└── service/ mapper/
└── tests/
├── unit/identity/ # "no linked Agent" rejection unit test
└── integration/ # linking flow + both list endpoints + cross-agent isolation
```
**Structure Decision**: Single project, no new module. The link endpoint extends
`identity/agents` (already owns agent CRUD); the ticket-summary query extends
`ticketing/tickets` (already owns `Ticket`) rather than a new module, since this is one small
read query, not a new bounded concern.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,35 @@
# Quickstart: Validating Agent Ticket Queue
Prerequisites: 010-identity-auth's login working; an existing `Team`/`Agent`/`User` (role
`AGENT`) to link.
## Scenario 1 — linking (User Story 1)
1. `PATCH /admin/agents/:agentId` with `{ "userId": "<agent's User.id>" }` as an admin.
**Expected**: `200`, response's `userId` matches.
2. Repeat with a `userId` belonging to a `User` whose role is `ADMIN`. **Expected**: `400`.
3. Repeat step 1's `userId` against a *different* `agentId`. **Expected**: `409`.
## Scenario 2 — an agent lists their own tickets (User Story 2)
1. With two tickets currently assigned to the linked agent (via the existing orchestration
assignment flow) and one assigned to a different agent, log in as that agent and call
`GET /agents/me/tickets`. **Expected**: `200`, exactly the two tickets, each with `product`/
`customer`/`priority`/`severity`/`status`/`assignedAt`/`sla` populated.
2. Reassign one of those two tickets away (to a different agent or node). **Expected**: calling
`GET /agents/me/tickets` again returns only the one remaining ticket.
3. Log in as a `User` (role `AGENT`) with no linked `Agent` row and call the same endpoint.
**Expected**: `404` with the specific "no agent profile linked" message, not `[]`.
## Scenario 3 — an admin lists a specific agent's tickets
1. Log in as admin; call `GET /admin/agents/:agentId/tickets` for the agent from Scenario 2.
**Expected**: `200`, same ticket set and shape as that agent's own `GET /agents/me/tickets`
call.
2. Log in as a non-admin agent; call the same admin route for another agent's `agentId`.
**Expected**: `403`.
## What "done" looks like
All three scenarios pass, and Scenario 2 step 2 specifically confirms the list reflects live
assignment state rather than a snapshot from when the agent first logged in.
+72
View File
@@ -0,0 +1,72 @@
# Research: Agent Ticket Queue
## Decision: extend the existing `PATCH /admin/agents/:agentId`, don't add a new link endpoint
- **Decision**: Add an optional `userId: z.string().min(1).nullable().optional()` to
`updateAgentSchema` and handle it in `AgentsService.update` (proactively check the target
`User`'s role and any existing link before writing, same pre-check style as
`UsersService.create`'s duplicate-email check — see 010-identity-auth), rather than a
dedicated `PATCH /admin/agents/:agentId/link-user` route.
- **Rationale**: `PATCH /admin/agents/:agentId` already exists as the one place an agent's
mutable fields are updated (`name`, `teamId`, `active`) — `userId` is exactly that kind of
field, not a distinct workflow. A second endpoint would duplicate routing/auth wiring for no
behavioral gain.
- **Alternatives considered**: A dedicated `/link-user` endpoint — rejected as an unnecessary
extra surface once the existing update endpoint's shape was checked and found to already fit.
## Decision: proactive existence/role checks, not a caught unique-constraint error
- **Decision**: Before writing `userId`, look up the target `User` (404 if it doesn't exist,
a clear rejection if its role isn't `AGENT`) and look up any existing `Agent` already linked
to that `userId` (a clear `ConflictError` if one exists and isn't this same agent) — the same
pattern `UsersService.create` (010-identity-auth) already established for its own duplicate-
email check, rather than letting Postgres's `@unique` constraint on `Agent.userId` throw and
translating that error after the fact.
- **Rationale**: Consistency with the one precedent this codebase already has for "reject a
would-be duplicate before writing," and a clearer error message than parsing a raw
`PrismaClientKnownRequestError` code.
- **Alternatives considered**: Catch `P2002` (unique constraint violation) and translate it —
workable, but the proactive-check style already used by `UsersService.create` was preferred
for consistency within the same codebase.
## Decision: the ticket-summary query lives in `ticketing/tickets`, not `orchestration/assignments`
- **Decision**: `TicketsService` (or a new `TicketsRepository` method) owns the new
"tickets currently assigned to agent X" query, reading `Assignment` rows via
`orchestration/assignments`'s own already-public repository/service surface (its `index.ts`),
not by reaching into `orchestration`'s internals.
- **Rationale**: The result is fundamentally a list of `Ticket`s (with a projection of
product/customer/SLA data) — `ticketing/tickets` already owns `Ticket` and its existing
`findById`/`findByCode` methods; `orchestration/assignments` owns the assignment *decision*
and *history*, not ticket listing. This mirrors 009's own precedent of `problem-management`
reading `ticketing`'s public surface rather than duplicating ticket state there.
- **Alternatives considered**: A new cross-cutting `reporting`/`dashboard` module — rejected as
premature; this is one query, not a new bounded concern (spec.md Assumptions explicitly rule
out a general-purpose list/search endpoint).
## Decision: one new Prisma index, `Assignment @@index([agentId, isCurrent])`
- **Decision**: Add this composite index. The existing `@@index([ticketId, isCurrent])` supports
"is this ticket currently assigned, and to whom" (007's own original query shape); this
feature's query is the mirror image — "which tickets is this agent currently assigned to" —
and has no supporting index today.
- **Rationale**: Without it, "all current assignments for agent X" is a sequential scan over the
whole `assignments` table. Cheap, purely additive schema change; no data migration needed
beyond the index build itself.
- **Alternatives considered**: Rely on the existing `[ticketId, isCurrent]` index (Postgres can't
use a composite index efficiently for a query that doesn't lead with its first column) —
rejected; a plain sequential scan is the actual alternative, not this index.
## Decision: two routes sharing one service method, not one route with an optional param
- **Decision**: `GET /agents/me/tickets` (`fastify.authenticate` only — resolves the agent from
`request.user.id` via the new `Agent.userId` link) and `GET /admin/agents/:agentId/tickets`
(`fastify.authenticate` + `requireRole('ADMIN')` — resolves the agent directly from the URL
param) both call the same `TicketsService.listAssignedTo(agentId)`.
- **Rationale**: FR-004 requires an agent's own call can never accept a client-supplied
`agentId` — collapsing both into one route with an optional query param would make that
invariant a runtime `if` instead of a routing-level guarantee. Two routes make "whose tickets"
structurally unambiguous per caller type, matching 010's own precedent of `GET /auth/me` vs.
an admin-only equivalent being distinct routes rather than one parameterized one.
- **Alternatives considered**: `GET /tickets?assignedAgentId=<id or 'me'>` — rejected; makes
FR-004's guarantee a body of validation logic rather than routing structure.
+141
View File
@@ -0,0 +1,141 @@
# Feature Specification: Agent Ticket Queue
**Feature Branch**: `011-agent-ticket-queue`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "Give agents and the frontend a way to list tickets currently
assigned to a given agent, with enough summary detail (customer, product, priority, status, SLA
state) to power an agent dashboard, since no such query exists anywhere in the ticketing or
orchestration modules today."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
An admin connects an existing `User` account (role `AGENT`, from 010-identity-auth) to its
corresponding `Agent` roster row (from 006-support-organization), so the platform knows which
login belongs to which routing/skills profile.
**Why this priority**: Every other story here depends on resolving "this logged-in session" to
"this agent's roster row." `Agent.userId` was added in 010-identity-auth specifically for this
purpose but has never been set by any workflow — this is that missing workflow.
**Independent Test**: Create a `User` (role `AGENT`) and a separate `Agent` roster row; link
them via the admin endpoint; confirm the link is retrievable and that linking a `User` already
linked to a different `Agent` is rejected.
**Acceptance Scenarios**:
1. **Given** an unlinked `Agent` and a `User` with role `AGENT` not yet linked to any agent,
**When** an admin links them, **Then** the `Agent` row's `userId` is set and retrievable.
2. **Given** a `User` already linked to `Agent` A, **When** an admin attempts to link that same
`User` to `Agent` B, **Then** the request is rejected (the existing unique constraint on
`Agent.userId` is surfaced as a clear conflict, not a raw database error).
3. **Given** a `User` whose role is `ADMIN` rather than `AGENT`, **When** an admin attempts to
link it to an `Agent` row, **Then** the request is rejected — an `Agent` roster row
represents a working agent, not an admin-only account.
---
### User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
An authenticated agent (or an admin looking at a specific agent, for support purposes) can
retrieve a list of every ticket currently assigned to that agent, each with enough summary data
— customer reference, product, priority, severity, status, and SLA state if a run exists — to
power an agent dashboard without a further per-ticket fetch.
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
agent-dashboard user story (its 001-agent-admin-ui, User Story 1) has no data source without it,
and no other endpoint in the ticketing or orchestration modules answers this question today.
**Independent Test**: With two tickets currently assigned to an agent (via the existing
orchestration assignment engine) and a third assigned to a different agent, call the new
endpoint as the first agent; confirm exactly the first two are returned, each with the summary
fields populated, and the third is absent.
**Acceptance Scenarios**:
1. **Given** an agent with two tickets currently assigned to them, **When** they call this
endpoint, **Then** both are returned, each including customer reference, product, priority,
severity, status, and SLA state (or an explicit absence of one, if no `SLARun` exists yet).
2. **Given** an agent with zero currently-assigned tickets, **When** they call this endpoint,
**Then** an empty list is returned — not an error.
3. **Given** a ticket reassigned away from an agent (its `Assignment.isCurrent` flips to another
agent's row), **When** the original agent calls this endpoint again, **Then** that ticket no
longer appears.
4. **Given** a `User` session with no linked `Agent` row at all (User Story 1 never completed
for this account), **When** that session calls this endpoint, **Then** the response is a
clear, specific rejection — never a silent empty list that could be mistaken for "no tickets
assigned," and never a raw null-reference error.
5. **Given** an admin session, **When** they call this endpoint for a specific `agentId`,
**Then** the same summary list is returned for that agent — an admin's own use of the
endpoint is explicit about which agent it's asking about, unlike an agent's own call, which
is always implicitly about themselves.
---
### Edge Cases
- What happens if an agent has a ticket assigned whose `Problem`/`Product`/`CustomerReference`
was deleted (should not happen under normal FK constraints, but the endpoint's own contract
should be explicit): every relation this endpoint reads is a required, non-nullable foreign
key already enforced by the schema, so this case cannot occur without a prior data-integrity
violation elsewhere: not specifically handled here.
- What happens if two `Agent` rows somehow both have `isCurrent: true` assignments for the same
ticket (should be impossible under 007's own assignment invariant)? This endpoint trusts that
invariant rather than re-deriving it — it is 007's own concern, not this feature's.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let an admin set an `Agent` row's linked `User` (`userId`), MUST
reject linking a `User` already linked to a different `Agent`, and MUST reject linking a
`User` whose role is not `AGENT`.
- **FR-002**: The system MUST let an admin read which `User`, if any, an `Agent` row is linked
to (already covered by the existing `GET /admin/agents/:agentId`, which returns the full
`Agent` row — this FR only requires `userId` not be excluded from that response).
- **FR-003**: The system MUST provide an endpoint that returns every ticket currently assigned
(`Assignment.isCurrent: true`) to a given agent, each with customer reference, product,
priority, severity, status, and SLA state summarized without a further per-ticket request.
- **FR-004**: When called by an agent's own session, the endpoint MUST resolve "which agent" from
that session's linked `Agent` row (User Story 1), never from a client-supplied agent ID — an
agent can only ever list their own tickets this way.
- **FR-005**: When called by an admin session with an explicit `agentId`, the endpoint MUST
return that agent's tickets — an admin-only capability for support/oversight purposes.
- **FR-006**: The system MUST reject a call from a session with no linked `Agent` row with a
specific, distinguishable error — never an empty list.
### Key Entities
- **Agent-User Link**: The (now finally wired) association between a `User` account and the
`Agent` roster row it authenticates as, via `Agent.userId`.
- **Assigned Ticket Summary**: A read-only projection of a `Ticket` plus its current
`Assignment` and (if present) `SLARun`, shaped for list display rather than full detail.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An agent's currently-assigned tickets are retrievable in a single request, with
zero additional per-ticket requests needed to populate a dashboard-style summary list.
- **SC-002**: 100% of sessions with no linked `Agent` row receive a specific rejection from the
new endpoint, never an empty list indistinguishable from "genuinely zero tickets assigned."
- **SC-003**: 0% of one agent's currently-assigned tickets are visible to another agent calling
the endpoint as themselves.
## Assumptions
- **This feature does not add a general-purpose ticket search/filter/list endpoint** — only the
narrow "tickets currently assigned to a specific agent" query supporthub-web's agent dashboard
needs. A broader admin-facing ticket search is explicitly out of scope, deferred until a
concrete need names its own filters.
- **Linking (User Story 1) is a one-time admin action per agent, not a self-service flow** — an
agent does not link their own account; matches 006/010's own existing pattern of admin-managed
roster and account provisioning.
- **No pagination is included** — an individual agent's currently-assigned ticket count is
small enough (bounded by realistic per-agent workload) that a single unpaginated list is
sufficient for this feature's scope; revisit if a future feature's data suggests otherwise.
+119
View File
@@ -0,0 +1,119 @@
---
description: "Task list for 011-agent-ticket-queue"
---
# Tasks: Agent Ticket Queue
**Input**: Design documents from `specs/011-agent-ticket-queue/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/agent-ticket-queue-contract.md](./contracts/agent-ticket-queue-contract.md),
[quickstart.md](./quickstart.md)
**Organization**: Tasks are grouped by user story (US1 = P1 linking, US2 = P1 ticket listing).
US2 depends on a helper US1 also needs, so despite being nominally independent, build US1 first.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 Add `Assignment @@index([agentId, isCurrent])` to `prisma/schema.prisma`; generate
the migration (`prisma migrate diff` → hand-write `migration.sql``prisma migrate
deploy`, this session's established non-interactive workaround) and run
`npm run prisma:generate`
**Checkpoint**: Index in place. Both user stories can now be built.
---
## Phase 2: User Story 1 - An admin links a staff account to its agent roster entry (Priority: P1)
**Goal**: `Agent.userId` becomes settable through the existing update endpoint, with the
rejection rules FR-001 requires.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T002 [P] [US1] Integration test covering Quickstart Scenario 1 (link succeeds; non-AGENT
role rejected 400; already-linked-elsewhere rejected 409) in
`tests/integration/agent-ticket-queue.test.ts` (depends on T001)
### Implementation for User Story 1
- [x] T003 [US1] Add `AgentsRepository.findByUserId(userId)` in
`src/modules/identity/agents/repository/agents.repository.ts` — shared by this story's
own duplicate-link check and by User Story 2's agent-self route (T010)
- [x] T004 [US1] Add `userId: z.string().min(1).nullable().optional()` to `updateAgentSchema` in
`src/modules/identity/agents/schema/agents.schema.ts`
- [x] T005 [US1] In `AgentsService.update` (`src/modules/identity/agents/service/agents.service.ts`),
when `data.userId !== undefined`: if non-null, look up the target `User` (via a small
`UsersRepository.findById`) — 404 if missing, reject with a `ValidationError` if its role
isn't `AGENT`; look up any `Agent` already linked to that `userId` (T003's
`findByUserId`) — `ConflictError` if it's a different agent than `agentId` (depends on
T003, T004)
- [x] T006 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
**Checkpoint**: An agent's login can now be resolved to its roster row.
---
## Phase 3: User Story 2 - An agent retrieves their own currently-assigned tickets (Priority: P1)
**Goal**: Both list endpoints return the same summarized projection, correctly scoped per
caller.
**Independent Test**: Quickstart Scenarios 2-3.
### Tests for User Story 2
- [x] T007 [P] [US2] Unit test: given a `User` id with no linked `Agent`, the service throws the
specific `NotFoundError` — in `tests/unit/identity/agent-ticket-queue-guard.test.ts`
- [x] T008 [US2] Integration test covering Quickstart Scenarios 2-3 (agent sees exactly their
own current assignments; list updates after a reassignment; no-linked-agent session gets
404 not `[]`; admin route returns the same shape for an explicit `agentId`; non-admin
calling the admin route for another agent gets 403) in
`tests/integration/agent-ticket-queue.test.ts` (depends on T006)
### Implementation for User Story 2
- [x] T009 [US2] Add `TicketsRepository.findAssignedToAgent(agentId)` in
`src/modules/ticketing/tickets/repository/tickets.repository.ts` — one query joining
current `Assignment` (via orchestration's public repository/service surface) to `Ticket`
with `product`/`customer`/`sLARun` relations (depends on T001)
- [x] T010 [US2] Add `TicketsService.listAssignedTo(agentId)` mapping each row to the
`AssignedTicketSummary` shape (data-model.md) in
`src/modules/ticketing/tickets/service/tickets.service.ts` (depends on T009)
- [x] T011 [US2] Add `GET /agents/me/tickets` (`fastify.authenticate` only; resolves `agentId`
via `agentsService`'s `findByUserId` (T003) against `request.user.id`, throwing the
FR-006 `NotFoundError` if none) and `GET /admin/agents/:agentId/tickets`
(`fastify.authenticate` + `requireRole('ADMIN')`) in `src/modules/ticketing/tickets/
controller/` + `routes/`, registered from `src/api/routes.ts` (depends on T003, T010)
- [x] T012 [US2] Run Quickstart Scenarios 2-3 locally and confirm all steps pass
**Checkpoint**: supporthub-web's agent dashboard now has a real data source.
---
## Phase 4: Polish & Cross-Cutting Concerns
- [x] T013 [P] Update `specs/011-agent-ticket-queue/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T014 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T015 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
---
## Dependencies & Execution Order
- **Foundational (Phase 1)**: No dependencies — BLOCKS both user stories
- **User Story 1 (Phase 2)**: Depends on Foundational
- **User Story 2 (Phase 3)**: Depends on Foundational and on T003 (built in Phase 2) — build
Phase 2 before Phase 3 despite the two stories being otherwise independent
- **Polish (Phase 4)**: Depends on both user stories
@@ -0,0 +1,60 @@
# Specification Quality Checklist: Admin List Views
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Discovered the same way 011-agent-ticket-queue was: while building supporthub-web's
001-agent-admin-ui (User Stories 6 and 7 this time), a research pass over supporthub-api's
actual endpoints found no cross-ticket SLA-run or escalation-event listing at all, and no
products-with-integration-status endpoint — three separate but same-shaped gaps (an existing
domain's data, never exposed as a list/join query), bundled into one feature rather than three
separate ones since none is large enough to justify its own spec.
- Deliberately narrow: read-only, no new persisted entity, no general search/filter API beyond
the one filter (`status`) and one cap (`limit`) each list actually needs, per Assumptions.
- All items pass; no revision iterations were needed.
- **Implementation-time finding**: research.md's plan.md draft had described the existing
single-ticket `GET /tickets/:ticketId/sla-run` as "agent-facing (fastify.authenticate)" — it's
actually fully ungated (no preHandler at all). Didn't change this feature's own design
(`GET /admin/sla-runs`/`GET /admin/escalation-events` still use `fastify.authenticate`, a
deliberately more conservative choice than the existing route, matching spec.md's own
"agent-usable" wording), but worth correcting for anyone reading research.md later.
- No `SLA_RUN_STATUSES` constant existed anywhere before this feature — `SLARun.status` had
only ever been written as free strings across the pause/resume/breach-detection code paths.
Centralized it in `orchestration/sla/mapper/sla-run-status.ts` since this feature is the first
caller that needs to validate against it, not just write it.
- **Follow-up (post-implementation)**: while building supporthub-web's own knowledge-governance
screen against this feature's own spirit, found a fourth same-shaped gap this spec's own scope
didn't originally name: `GET /knowledge/retrieve` (004-product-knowledge) only ever returns
`status: 'published'` entries — a governance screen that needs to see and publish a *draft*
entry had no endpoint to list it at all. Added `GET /admin/products/:externalProductId/
knowledge` directly to the knowledge module (not this feature's own routes, since it lives
where `KnowledgeEntry` itself does) in a small follow-up commit, same spirit as this spec's
three original endpoints.
@@ -0,0 +1,83 @@
# Contract: Admin List Views
## `GET /admin/sla-runs`
**Auth**: `fastify.authenticate` only (agent-usable, per spec.md Assumptions).
**Query**: `status?: 'running' | 'paused' | 'warning' | 'breached' | 'completed'`
**Response `200`**:
```json
{
"success": true,
"data": [
{
"ticketId": "string",
"ticketCode": "string",
"status": "string",
"firstResponseDueAt": "ISO 8601 datetime | null",
"resolutionDueAt": "ISO 8601 datetime | null",
"breachedAt": "ISO 8601 datetime | null",
"firstResponseBreachedAt": "ISO 8601 datetime | null"
}
],
"meta": null
}
```
**Response `400`**: an invalid `status` value.
## `GET /admin/escalation-events`
**Auth**: `fastify.authenticate` only.
**Query**: `limit?: number` (1-200, default 50)
**Response `200`**:
```json
{
"success": true,
"data": [
{
"ticketId": "string",
"ticketCode": "string",
"reason": "string",
"ruleId": "string | null",
"triggeredBy": "string",
"toNodeId": "string | null",
"createdAt": "ISO 8601 datetime"
}
],
"meta": null
}
```
Ordered most-recent-first (`createdAt desc`).
## `GET /admin/products`
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
**Response `200`**:
```json
{
"success": true,
"data": [
{
"id": "string",
"externalProductId": "string",
"name": "string",
"status": "string",
"supportEnabled": true,
"integrationStatus": "active | suspended | null"
}
],
"meta": null
}
```
`integrationStatus` is `null` when the product has no `ProductIntegration` at all — never
defaulted to `"active"` or any other value that could be mistaken for a real integration state.
+47
View File
@@ -0,0 +1,47 @@
# Data Model: Admin List Views
No schema changes. Three response-shape projections over existing models.
## `SlaRunListItem` (response shape only)
| Field | Source |
|---|---|
| `ticketId` | `SLARun.ticketId` |
| `ticketCode` | `SLARun.ticket.code` (via `include`) |
| `status` | `SLARun.status` |
| `firstResponseDueAt` | `SLARun.firstResponseDueAt` |
| `resolutionDueAt` | `SLARun.resolutionDueAt` |
| `breachedAt` | `SLARun.breachedAt` |
| `firstResponseBreachedAt` | `SLARun.firstResponseBreachedAt` |
## `EscalationEventListItem` (response shape only)
| Field | Source |
|---|---|
| `ticketId` | `EscalationEvent.ticketId` |
| `ticketCode` | `EscalationEvent.ticket.code` (via `include`) |
| `reason` | `EscalationEvent.reason` |
| `ruleId` | `EscalationEvent.ruleId` (null for manual/no-match) |
| `triggeredBy` | `EscalationEvent.triggeredBy` |
| `toNodeId` | `EscalationEvent.toNodeId` |
| `createdAt` | `EscalationEvent.createdAt` |
## `ProductCatalogListItem` (response shape only)
| Field | Source |
|---|---|
| `id` | `Product.id` |
| `externalProductId` | `Product.externalProductId` |
| `name` | `Product.name` |
| `status` | `Product.status` |
| `supportEnabled` | `Product.supportEnabled` |
| `integrationStatus` | Derived: `product.integration?.status ?? null` — never the full `ProductIntegration` row (research.md) |
## Validation / Business Rules
- `GET /admin/sla-runs?status=``status` validated against `SLA_RUN_STATUSES` (`running`,
`paused`, `warning`, `breached`, `completed`); omitted means unfiltered.
- `GET /admin/escalation-events?limit=``limit` coerced, `1..200`, default `50`; ordered by
`createdAt desc`.
- `GET /admin/products` — no filter; ordered by `name asc` (matches existing catalog list
conventions elsewhere in this codebase, e.g. `TeamsRepository.findAll`).
+104
View File
@@ -0,0 +1,104 @@
# Implementation Plan: Admin List Views
**Branch**: `012-admin-list-views` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/012-admin-list-views/spec.md`
## Summary
Adds three read-only endpoints, each a straightforward `findMany` on an already-existing model
plus a small ticket-id/code projection: `GET /admin/sla-runs` (optional `?status=`),
`GET /admin/escalation-events` (optional `?limit=`), and `GET /admin/products` (products joined
to their integration's status). No new persisted entity, no write capability.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
**Primary Dependencies**: None new — Prisma only.
**Storage**: PostgreSQL via Prisma. No schema change — every field already exists; these are
projections over `SLARun`, `EscalationEvent`, and `Product`/`ProductIntegration`.
**Testing**: Vitest — integration tests against real Postgres/Redis for each endpoint's filter/
ordering/projection behavior, plus one admin-role-gating check for `GET /admin/products`.
**Target Platform**: Same Fastify modular monolith. Modifies `orchestration/sla` (new route +
repository method), `orchestration/escalation` (new route + repository method), and
`catalog/products` (new admin route + repository method) — no new module, each list lives in
the module that already owns its underlying model.
**Project Type**: Backend service — single project.
**Performance Goals**: Each list is one indexed/simple query — `SLARun` has no per-status
index today (status is a small string column, not indexed), acceptable at this stage per
spec.md's own "no general search API" scoping; revisit if a future feature's data volume
demands one.
**Constraints**: FR-004 — read-only, no new write path. The product-catalog list must not leak
`ProductIntegration.credentialRef` (encrypted secret) or any other sensitive integration field
— only `status` is projected.
**Scale/Scope**: Three new GET routes across three existing modules, three new repository
methods, no new module, no schema migration. Explicitly excludes: pagination (spec.md
Assumptions — `limit` only on the escalation-event list), and any filter beyond `status`/`limit`.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Purely internal SupportHub domain (SLA/escalation/product-catalog monitoring) — no SaaS/customer identity involved. | PASS — N/A |
| II. Configuration Over Hardcoding | No new configurable values. | PASS — N/A |
| III. Layered Architecture With Enforced Module Boundaries | Each list lives in the module that already owns its model (`orchestration/sla`, `orchestration/escalation`, `catalog/products`) — no cross-module reach-through; the ticket id/code projection reads `ticketsRepository`'s own public surface via `ticketing/tickets`'s existing `index.ts`. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
| VI. Durable Audit & History | Not applicable — no new mutable state. | PASS — N/A |
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries; no concurrency concern. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/012-admin-list-views/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-api/
└── src/
└── modules/
├── orchestration/
│ ├── sla/ # MODIFIED — GET /admin/sla-runs
│ │ ├── controller/ routes/
│ │ └── repository/ (new findAll(status?) method)
│ └── escalation/ # MODIFIED — GET /admin/escalation-events
│ ├── controller/ routes/
│ └── repository/ (new findRecent(limit?) method)
└── catalog/
└── products/ # MODIFIED — GET /admin/products
├── controller/ routes/
└── repository/ (new findAllWithIntegrationStatus() method)
└── tests/
└── integration/ # one new test file per endpoint's own scenarios
```
**Structure Decision**: Single project, no new module — each endpoint extends the module that
already owns its underlying data, matching 011-agent-ticket-queue's own precedent.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+27
View File
@@ -0,0 +1,27 @@
# Quickstart: Validating Admin List Views
## Scenario 1 — SLA runs across tickets
1. With SLA runs in `running`, `paused`, and `breached` states across three tickets, call
`GET /admin/sla-runs` as any authenticated agent. **Expected**: `200`, all three, each with
`ticketId`/`ticketCode` populated.
2. Repeat with `?status=breached`. **Expected**: only the breached run.
3. Repeat with `?status=not-a-real-status`. **Expected**: `400`.
## Scenario 2 — recent escalation events across tickets
1. With one automatic and one manual escalation event recorded on two different tickets, call
`GET /admin/escalation-events`. **Expected**: `200`, both, most-recent-first, the automatic
one showing its `ruleId` and the manual one showing `ruleId: null` and its `triggeredBy`.
## Scenario 3 — product catalog with integration status
1. With one product that has an active integration and one with no integration at all, call
`GET /admin/products` as an admin. **Expected**: `200`, the first shows
`integrationStatus: "active"`, the second shows `integrationStatus: null`.
2. Repeat as a non-admin agent. **Expected**: `403`.
## What "done" looks like
All three scenarios pass against a real Postgres/Redis, and none of the three endpoints leaks
`ProductIntegration.credentialRef` or any other integration-internal field.
+62
View File
@@ -0,0 +1,62 @@
# Research: Admin List Views
## Decision: project ticket id/code via a second query, not a raw join
- **Decision**: Each repository method fetches its own rows (`SLARun[]`/`EscalationEvent[]`)
with Prisma's own `include: { ticket: { select: { id: true, code: true } } }` — a single
Prisma query using the existing `ticket` relation already on both models, not a hand-written
SQL join or a second round-trip.
- **Rationale**: Both `SLARun` and `EscalationEvent` already have a `ticket` relation
(`@relation(fields: [ticketId], references: [id])`) — Prisma's `include` turns this into one
query, not N+1, and needs no new repository dependency on `ticketsRepository`.
- **Alternatives considered**: A second batched `ticketsRepository.findByIds(...)` call — works,
but `include` is simpler and already idiomatic in this codebase's own repositories (e.g.
011-agent-ticket-queue's `findAssignedToAgent`).
## Decision: `status` filter on `GET /admin/sla-runs` is validated against `SLA_RUN_STATUSES`
- **Decision**: `status` is an optional query param validated with
`z.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional()` — the same
status vocabulary `SLARun.status` already uses (008-sla-escalation).
- **Rationale**: A typo'd status silently returning zero rows (if left as a free string) would
be a confusing, silent failure mode for a monitoring view; validating it up front makes an
invalid filter a clear `400`, matching this codebase's existing "resolve/validate first, then
act" convention (e.g. 011's proactive existence checks).
- **Alternatives considered**: A free-text `z.string().optional()` — rejected for the silent-
wrong-filter risk above.
## Decision: `GET /admin/escalation-events` defaults to `limit=50`, capped at `200`
- **Decision**: `limit` is `z.coerce.number().int().positive().max(200).default(50)`.
- **Rationale**: Unlike `SLARun` (bounded by currently-open tickets) or `Product` (bounded by
catalog size), `EscalationEvent` rows only ever accumulate — an unbounded list would grow
without limit. A sane default plus a hard ceiling avoids both an accidentally-enormous
response and a caller needing to know to always pass one.
- **Alternatives considered**: True cursor-based pagination — rejected as more than this
feature's own scope calls for (spec.md Assumptions); a capped `limit` is enough for a
"recent escalations" monitoring view.
## Decision: product-catalog integration status is a derived string, not the raw `ProductIntegration` row
- **Decision**: `GET /admin/products` returns `integrationStatus: 'active' | 'suspended' | null`
(`null` when `product.integration` is absent) — never the full `ProductIntegration` object.
- **Rationale**: `ProductIntegration.credentialRef` is an encrypted secret at rest
(002-saas-integration); even encrypted, there's no reason for a list-view response to include
it, or any other integration-internal field (`rateLimitPerMinute`, `allowedScope`, etc.) this
screen doesn't render (FR-003's own "constraints" — plan.md).
- **Alternatives considered**: Nesting the full `include: { integration: true }` result under
the product — rejected; a derived, minimal field is both simpler for the frontend and doesn't
require re-auditing every future `ProductIntegration` field addition for accidental exposure
through a public-adjacent list view (this route is admin-only, but the same discipline this
codebase already applies to `AssignedTicketSummary`'s own minimal projection applies here too).
## Decision: `GET /admin/products` is a new admin route, not an extension of the existing public `GET /products`
- **Decision**: A separate route rather than adding an optional `includeIntegrationStatus` query
param to the existing public, ungated `GET /products`.
- **Rationale**: `GET /products` is intentionally public (spec.md Assumptions of
002-saas-integration's own catalog read); layering an admin-only field onto a public route
via a query flag would make that route's own auth requirement conditional on which fields
were requested — a confusing, easy-to-get-wrong pattern. A separate `requireRole('ADMIN')`
route keeps the gate unconditional and obvious.
- **Alternatives considered**: The query-flag approach above — rejected for the reason stated.
+144
View File
@@ -0,0 +1,144 @@
# Feature Specification: Admin List Views
**Feature Branch**: `012-admin-list-views`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "Add missing read-only list endpoints supporthub-web's admin
monitoring and catalog screens need: SLA runs across tickets, recent escalation events across
tickets, and products with their integration status, none of which exist as a single query
today."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An agent or admin sees SLA status across every ticket at a glance (Priority: P1)
Rather than checking one ticket's SLA state at a time, an agent or admin retrieves a list of
every ticket's current SLA run, filterable by status (running/paused/warning/breached), each
entry carrying enough to identify and link to its ticket.
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
001-agent-admin-ui, User Story 6, has no data source for its SLA monitor view without it, and
no endpoint in the SLA module answers "every ticket's SLA state," only one ticket's own.
**Independent Test**: With SLA runs in different states across several tickets, call this
endpoint unfiltered and confirm every run appears; call it filtered by `status=breached` and
confirm only breached runs appear.
**Acceptance Scenarios**:
1. **Given** tickets with SLA runs in running, paused, and breached states, **When** the
endpoint is called with no filter, **Then** every run is returned, each including its
ticket's id and code, status, and due/breached timestamps.
2. **Given** the same tickets, **When** the endpoint is called with `status=breached`, **Then**
only the breached runs are returned.
---
### User Story 2 - An agent or admin sees recent escalation events across every ticket (Priority: P1)
An agent or admin retrieves a list of recent escalation events across all tickets — each
showing the triggering reason, the rule that fired it (if automatic) or the actor who triggered
it (if manual), and the resulting target hierarchy node.
**Why this priority**: The same 001-agent-admin-ui User Story 6 has no data source for its
escalation matrix view without it — today the only way to see an escalation event at all is
`EscalationEventRepository.findAllForTicket`, which requires already knowing which ticket to
ask about.
**Independent Test**: With escalation events (both automatic and manual) recorded across
several tickets, call this endpoint and confirm every event appears, most recent first, each
identifying its ticket, reason, rule-or-actor, and target node.
**Acceptance Scenarios**:
1. **Given** three tickets each with one escalation event, **When** the endpoint is called,
**Then** all three appear, ordered most-recent-first, each including its ticket id/code,
reason, `ruleId` (or null for manual), `triggeredBy`, and `toNodeId`.
---
### User Story 3 - An admin views the product catalog with integration status (Priority: P2)
An admin retrieves the product catalog with each product's integration status
(active/suspended) visible directly in the list, rather than needing a second lookup per
product.
**Why this priority**: Lower than User Stories 1-2 (matches 001-agent-admin-ui's own User Story
7 being P3) — the product catalog changes far less often than SLA/escalation state, but its own
consuming frontend story still has no single query to build a list screen against: the existing
public `GET /products` doesn't include `ProductIntegration`, and integration status is only
otherwise reachable per-integration-id, not per-product.
**Independent Test**: With two products, one with an active integration and one with a
suspended integration, call this endpoint and confirm each product's own integration status is
present without a further request.
**Acceptance Scenarios**:
1. **Given** a product with an active integration and one with a suspended integration, **When**
an admin calls this endpoint, **Then** both appear with their correct integration status;
a product with no integration at all shows a clearly-absent (not misleadingly "active")
status.
---
### Edge Cases
- What happens to a ticket whose SLA run was already marked `completed` (ticket resolved)? It
still appears in the unfiltered SLA-run list (this is a monitoring view of everything that
exists, not just "currently at risk") but is excluded by a `status=breached`/`running`/etc.
filter unless it matches.
- What happens for a ticket with no SLA run at all (no matching policy, or the run hasn't been
created yet)? It simply doesn't appear in this list — this endpoint lists existing `SLARun`
rows, it does not synthesize one for every ticket.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST provide an endpoint listing every `SLARun`, each including its
owning ticket's id and code, optionally filtered by `status`.
- **FR-002**: The system MUST provide an endpoint listing recent `EscalationEvent` rows across
all tickets, most-recent-first, each including its owning ticket's id and code.
- **FR-003**: The system MUST provide an endpoint listing the product catalog with each
product's integration status included, distinguishing "has an active integration," "has a
suspended integration," and "has no integration at all."
- **FR-004**: All three endpoints are read-only (no new write capability) and reuse existing
`SLARun`/`EscalationEvent`/`Product`/`ProductIntegration` data — no new persisted entity.
### Key Entities
- **SLA Run List Item**: An `SLARun` projected with its ticket's `id`/`code` alongside its own
existing fields.
- **Escalation Event List Item**: An `EscalationEvent` projected with its ticket's `id`/`code`
alongside its own existing fields.
- **Product Catalog List Item**: A `Product` projected with its integration's `status`, or an
explicit absence marker if it has none.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Every ticket's SLA state is retrievable in a single request, filterable by status,
with zero additional per-ticket requests needed.
- **SC-002**: Recent escalation events across every ticket are retrievable in a single request.
- **SC-003**: The product catalog with integration status is retrievable in a single request,
with 0% of products showing a misleading status when they have no integration at all.
## Assumptions
- **No pagination on the SLA-run or product-catalog lists** — matches 011-agent-ticket-queue's
own precedent (bounded, realistic data volumes for this stage); the escalation-event list
DOES cap at a default/maximum `limit` (most-recent-first), since that list only ever grows
and has no other natural bound.
- **These are read-only monitoring/catalog views, not a general search/filter API** — the SLA
list's only filter is `status`; no additional filters (date range, product, priority) are
added speculatively beyond what 001-agent-admin-ui's own User Story 6 spec asks for.
- **Auth**: SLA-run and escalation-event lists are agent-usable (`fastify.authenticate` only,
matching the existing single-ticket `GET /tickets/:id/sla-run`'s own agent-facing nature and
001-agent-admin-ui's "agents and admins" wording for User Story 6); the product-catalog list
is admin-only (`requireRole('ADMIN')`), matching every other admin-configuration read in this
codebase.
+95
View File
@@ -0,0 +1,95 @@
---
description: "Task list for 012-admin-list-views"
---
# Tasks: Admin List Views
**Input**: Design documents from `specs/012-admin-list-views/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/admin-list-views-contract.md](./contracts/admin-list-views-contract.md),
[quickstart.md](./quickstart.md)
**Organization**: Tasks are grouped by user story (US1 = P1 SLA runs, US2 = P1 escalation
events, US3 = P2 product catalog). All three are independent of each other.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: User Story 1 - SLA runs across every ticket (Priority: P1)
**Independent Test**: Quickstart Scenario 1.
- [x] T001 [P] [US1] Add `SLARunRepository.findAll(status?)` in
`src/modules/orchestration/sla/repository/sla-run.repository.ts``include: { ticket:
{ select: { id: true, code: true } } }`, optional `where: { status }`
- [x] T002 [US1] Add `SLAService.listAll(status?)` (or directly on the controller if no service
method is warranted — check existing pattern) validating `status` against
`SLA_RUN_STATUSES` (400 on an invalid value) in
`src/modules/orchestration/sla/service/sla.service.ts` (depends on T001)
- [x] T003 [US1] Add `GET /admin/sla-runs` (`fastify.authenticate` only) in
`src/modules/orchestration/sla/controller/` + `routes/`, projecting each row to
`SlaRunListItem` (data-model.md) (depends on T002)
- [x] T004 [US1] Integration test covering Quickstart Scenario 1 (unfiltered returns all;
`status=breached` filters correctly; an invalid status is 400) in
`tests/integration/admin-list-views.test.ts`
- [x] T005 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
---
## Phase 2: User Story 2 - Recent escalation events across every ticket (Priority: P1)
**Independent Test**: Quickstart Scenario 2.
- [x] T006 [P] [US2] Add `EscalationEventRepository.findRecent(limit)` in
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts`
`include: { ticket: { select: { id: true, code: true } } }`, `orderBy: { createdAt:
'desc' }`, `take: limit`
- [x] T007 [US2] Add `GET /admin/escalation-events` (`fastify.authenticate` only, `limit` query
param `z.coerce.number().int().positive().max(200).default(50)`) in
`src/modules/orchestration/escalation/controller/` + `routes/`, projecting to
`EscalationEventListItem` (depends on T006)
- [x] T008 [US2] Integration test covering Quickstart Scenario 2 (both events appear, most-
recent-first, automatic vs manual distinguished by `ruleId`) in
`tests/integration/admin-list-views.test.ts` (same file as T004)
- [x] T009 [US2] Run Quickstart Scenario 2 locally and confirm it passes
---
## Phase 3: User Story 3 - Product catalog with integration status (Priority: P2)
**Independent Test**: Quickstart Scenario 3.
- [x] T010 [P] [US3] Add `ProductsRepository.findAllWithIntegrationStatus()` in
`src/modules/catalog/products/repository/products.repository.ts``include: {
integration: { select: { status: true } } }`, `orderBy: { name: 'asc' }`
- [x] T011 [US3] Add `GET /admin/products` (`fastify.authenticate` + `requireRole('ADMIN')`) in
`src/modules/catalog/products/controller/` + `routes/`, projecting each row to
`ProductCatalogListItem` (`integrationStatus: product.integration?.status ?? null`
never the full `ProductIntegration` row, research.md) (depends on T010)
- [x] T012 [US3] Integration test covering Quickstart Scenario 3 (active + no-integration
products both correct; non-admin gets 403) in `tests/integration/admin-list-views.test.ts`
(same file as T004/T008)
- [x] T013 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
---
## Phase 4: Polish & Cross-Cutting Concerns
- [x] T014 [P] Update `specs/012-admin-list-views/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T015 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T016 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
---
## Dependencies & Execution Order
- **User Stories 1-3**: Fully independent of each other and of any Foundational phase (no shared
prerequisite beyond the existing schema) — parallelizable in any order
- **Polish (Phase 4)**: Depends on all three user stories
+18
View File
@@ -15,9 +15,19 @@ import { teamsRoutes } from '@/modules/identity/teams';
import { agentsRoutes } from '@/modules/identity/agents';
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
import { assignmentsRoutes } from '@/modules/orchestration/assignments';
import { businessCalendarsRoutes } from '@/modules/platform/business-calendars';
import { slaRoutes } from '@/modules/orchestration/sla';
import { escalationRoutes } from '@/modules/orchestration/escalation';
import { investigationRoutes } from '@/modules/problem-management/investigation';
import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
import { authRoutes } from '@/modules/identity/auth';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(metricsRoutes);
await app.register(productsRoutes);
await app.register(inboundRequestRoutes);
@@ -31,5 +41,13 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(agentsRoutes);
await app.register(hierarchyRoutes);
await app.register(assignmentsRoutes);
await app.register(businessCalendarsRoutes);
await app.register(slaRoutes);
await app.register(escalationRoutes);
await app.register(investigationRoutes);
await app.register(rootCausesRoutes);
await app.register(solutionsRoutes);
await app.register(verificationRoutes);
await app.register(resolutionsRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+4
View File
@@ -1,9 +1,13 @@
import { logger } from '@/infrastructure/observability';
import { registerAttachmentWorker } from '@/jobs/attachments';
import { registerAiSessionWorker } from '@/jobs/ai-session';
import { registerSlaWorker } from '@/jobs/sla';
import { registerCleanupWorker } from '@/jobs/cleanup';
export async function bootstrapQueue(): Promise<void> {
registerAttachmentWorker();
registerAiSessionWorker();
registerSlaWorker();
registerCleanupWorker();
logger.info('Queue Manager initialized.');
}
+1
View File
@@ -12,6 +12,7 @@ export interface JwtPayload {
email: string;
role: string;
actorType: ActorType;
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
iat?: number;
exp?: number;
}
+6
View File
@@ -0,0 +1,6 @@
import { env } from './env';
export const authConfig = {
jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
};
+10
View File
@@ -55,6 +55,16 @@ const envSchema = z.object({
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
// II), consistent with every other policy default in this codebase.
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
// Problem Resolution (009) — how long a ticket waits in RESOLUTION_PENDING_CUSTOMER with no
// explicit customer confirmation before the auto-close sweep resolves it — see
// specs/009-problem-resolution/research.md "auto-close waiting period".
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
// Identity and Authentication (010) — token lifetime; signing itself reuses the existing,
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
// until now) — see specs/010-identity-auth/research.md.
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
});
export type EnvConfig = z.infer<typeof envSchema>;
+2
View File
@@ -5,3 +5,5 @@ export * from './queue';
export * from './storage';
export * from './ai';
export * from './orchestration';
export * from './problem-resolution';
export * from './auth';
+5
View File
@@ -0,0 +1,5 @@
import { env } from './env';
export const problemResolutionConfig = {
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
};
+42
View File
@@ -3,6 +3,7 @@ import { DomainEventName } from '../domain-events';
import { BaseDomainEvent } from '../event-types';
import { sessionsService } from '@/modules/ai-support/sessions';
import { orchestrationService } from '@/modules/orchestration/orchestration';
import { slaService } from '@/modules/orchestration/sla';
interface TicketUpdatedPayload {
ticketId: string;
@@ -11,6 +12,13 @@ interface TicketUpdatedPayload {
newStatus: string;
}
interface TicketAssignedPayload {
ticketId: string;
agentId: string;
strategy: string;
actor: string;
}
let registered = false;
/**
@@ -45,4 +53,38 @@ export function registerDomainEventHandlers(): void {
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
},
);
// 008-sla-escalation research.md "SLA-run lifecycle is wired entirely through the existing
// domain-event bus": TICKET_ASSIGNED was defined since 007-orchestration-assignment but never
// published until now (assignment.engine.ts's persistAndTransition). Idempotent — no-ops if
// the ticket already has a run (SLARun.ticketId @unique).
eventBus.subscribe(
DomainEventName.TICKET_ASSIGNED,
async (event: BaseDomainEvent<TicketAssignedPayload>) => {
await slaService.handleTicketAssigned(event.payload.ticketId);
},
);
// 008-sla-escalation FR-007/FR-008: pause on entering WAITING_FOR_CUSTOMER, resume on leaving
// it — durable (a DB timestamp shift), never an in-memory timer (Constitution Principle VII).
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus === 'WAITING_FOR_CUSTOMER') {
await slaService.pause(event.payload.ticketId);
} else if (event.payload.previousStatus === 'WAITING_FOR_CUSTOMER') {
await slaService.resume(event.payload.ticketId);
}
},
);
// 008-sla-escalation FR-010: a ticket reaching RESOLVED (003-ticketing's terminal status before
// CLOSED/REOPENED) completes its SLA run — never later marked breached.
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus !== 'RESOLVED') return;
await slaService.complete(event.payload.ticketId);
},
);
}
+17
View File
@@ -0,0 +1,17 @@
import { cacheService } from './cache.service';
const REVOKED_KEY_PREFIX = 'auth:revoked:';
/**
* Explicit-logout revocation for staff session tokens (specs/010-identity-auth/research.md
* "Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism"). A jti is
* denylisted only until its own token would have expired anyway, so the set never grows
* unbounded — the same shape as replay-guard.ts's hasSeenJti/markJtiSeen.
*/
export async function isTokenRevoked(jti: string): Promise<boolean> {
return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`);
}
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds);
}
+1
View File
@@ -2,3 +2,4 @@ export * from './redis.client';
export * from './cache.service';
export * from './replay-guard';
export * from './rate-limiter';
export * from './auth-revocation';
+22 -1
View File
@@ -1,8 +1,29 @@
import { queueManager, QueueName } from '@/infrastructure/queue';
import { logger } from '@/infrastructure/observability';
import { resolutionsService } from '@/modules/problem-management/resolutions';
const AUTO_CLOSE_SWEEP_INTERVAL_MS = 5 * 60_000;
/**
* 009-problem-resolution research.md "Auto-close is a repeatable BullMQ job on the existing,
* unclaimed CLEANUP queue" — mirrors 008's SLA breach-detection job registration exactly: a
* repeatable job whose processor calls a directly-callable sweep method containing all the real
* logic, durable via BullMQ's own persisted repeatable-job state (Constitution Principle VII).
*/
export function registerCleanupWorker(): void {
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
logger.info({ jobId: job.id, data: job.data }, 'Processing Cleanup Job');
logger.info({ jobId: job.id }, 'Running resolution auto-close sweep');
await resolutionsService.runAutoCloseSweep();
});
void queueManager.getQueue(QueueName.CLEANUP).add(
'auto-close-resolutions',
{
jobId: 'auto-close-resolutions',
type: 'auto-close-resolutions',
payload: {},
createdAt: new Date().toISOString(),
},
{ repeat: { every: AUTO_CLOSE_SWEEP_INTERVAL_MS } },
);
}
+23 -1
View File
@@ -1,8 +1,30 @@
import { queueManager, QueueName } from '@/infrastructure/queue';
import { logger } from '@/infrastructure/observability';
import { slaService } from '@/modules/orchestration/sla';
const BREACH_DETECTION_INTERVAL_MS = 60_000;
/**
* research.md "Breach detection — one repeatable BullMQ job, not one delayed job per run": a
* single job scheduled to repeat every minute, whose processor calls SlaService's directly-
* callable sweep — the sweep itself contains 100% of the actual logic, so this registration is
* pure scheduling (Constitution Principle VII — durable, survives a restart via BullMQ's own
* persisted repeatable-job state, never an in-memory setInterval).
*/
export function registerSlaWorker(): void {
queueManager.registerWorker(QueueName.SLA, async (job) => {
logger.info({ jobId: job.id, data: job.data }, 'Processing SLA Job');
logger.info({ jobId: job.id }, 'Running SLA breach-detection sweep');
await slaService.runBreachDetectionSweep();
});
void queueManager.getQueue(QueueName.SLA).add(
'detect-breaches',
{
jobId: 'detect-breaches',
type: 'detect-breaches',
payload: {},
createdAt: new Date().toISOString(),
},
{ repeat: { every: BREACH_DETECTION_INTERVAL_MS } },
);
}
@@ -20,6 +20,15 @@ export class KnowledgeController {
return reply.status(201).send({ success: true, data: entry, meta: null });
}
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
* unlike GET /knowledge/retrieve which is published-only). */
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
const { externalProductId } = request.params as { externalProductId: string };
const productId = await resolveProductId(externalProductId);
const entries = await this.service.listForGovernance(productId);
return reply.status(200).send({ success: true, data: entries, meta: null });
}
async publish(request: FastifyRequest, reply: FastifyReply) {
const { code } = request.params as { code: string };
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
});
}
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
* governance screen (which must see drafts to publish them) needs its own query. */
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
return this.prisma.knowledgeEntry.findMany({
where: { productId, isCurrentVersion: true },
orderBy: { createdAt: 'desc' },
});
}
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
* is validated-first, then most-recently-effective. */
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
@@ -1,35 +1,44 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { knowledgeController, errorCodesController, runbooksController } from '../controller';
/**
* Admin routes gated by fastify.authenticate (research.md — known limitation inherited from
* 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future
* AI-support feature will call, not an admin surface (research.md "Admin endpoint
* authentication").
* Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real
* (010-identity-auth). Reads stay agent-usable (fastify.authenticate only).
* /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support
* feature will call, not an admin surface (research.md "Admin endpoint authentication").
*/
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/knowledge',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.create(req, reply),
);
// 012-admin-list-views follow-up: the governance screen's own data source (every status).
fastify.get(
'/admin/products/:externalProductId/knowledge',
{ preHandler: fastify.authenticate },
(req, reply) => knowledgeController.listForGovernance(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/publish',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.publish(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/unpublish',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.unpublish(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/validate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.validate(req, reply),
);
fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) =>
knowledgeController.edit(req, reply),
fastify.put(
'/admin/knowledge/:code',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.edit(req, reply),
);
fastify.get(
'/admin/knowledge/:code/versions',
@@ -39,12 +48,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/error-codes',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createErrorCode(req, reply),
);
fastify.post(
'/admin/products/:externalProductId/known-issues',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createKnownIssue(req, reply),
);
fastify.get(
@@ -55,7 +64,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/runbooks',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.create(req, reply),
);
fastify.get(
@@ -65,12 +74,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
);
fastify.put(
'/admin/products/:externalProductId/runbooks/:key',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.edit(req, reply),
);
fastify.patch(
'/admin/products/:externalProductId/runbooks/:key/deactivate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.deactivate(req, reply),
);
@@ -57,6 +57,12 @@ export class KnowledgeService {
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
return this.repo.retrieve(filters);
}
/** 012-admin-list-views follow-up: every entry for a product, any status — the governance
* screen's own data source (unlike `retrieve`, which is published-only). */
async listForGovernance(productId: string): Promise<KnowledgeEntry[]> {
return this.repo.findAllForProduct(productId);
}
}
export const knowledgeService = new KnowledgeService();
@@ -1,16 +1,17 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { confidencePolicyController, sessionController } from '../controller';
/**
* contracts/ai-support-contract.md: admin confidence-policy routes gated by
* fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are
* not admin routes — called by the ticket-owning caller, same as 003-ticketing's
* contracts/ai-support-contract.md: the admin confidence-policy write route is gated by
* fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn
* routes are not admin routes — called by the ticket-owning caller, same as 003-ticketing's
* POST/GET .../messages, and carry no additional gate of their own in this feature.
*/
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.put(
'/admin/products/:externalProductId/ai-policy',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => confidencePolicyController.upsert(req, reply),
);
fastify.get(
+1
View File
@@ -1,3 +1,4 @@
export { categoriesRoutes } from './routes';
export { CategoriesService, categoriesService } from './service';
export type { CategoryDTO } from './types';
export { categoriesRepository, CategoriesRepository } from './repository';
@@ -1,3 +1,4 @@
import { Category } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class CategoriesRepository {
@@ -6,6 +7,13 @@ export class CategoriesRepository {
async findAllCategories(): Promise<unknown[]> {
return this.prisma.category.findMany();
}
/** 008-sla-escalation: existence check for SLAPolicy.categoryId — this stub had no findById
* at all before, a leftover gap from the original scaffold (same class of gap this codebase
* has closed in every prior feature that needed one). */
async findById(id: string): Promise<Category | null> {
return this.prisma.category.findUnique({ where: { id } });
}
}
export const categoriesRepository = new CategoriesRepository();
@@ -12,6 +12,21 @@ export class ProductsController {
meta: null,
});
}
/** 012-admin-list-views: admin catalog screen — never returns the full ProductIntegration
* row, only its derived status (research.md). */
async getProductsWithIntegrationStatus(_request: FastifyRequest, reply: FastifyReply) {
const products = await this.service.listWithIntegrationStatus();
const data = products.map((product) => ({
id: product.id,
externalProductId: product.externalProductId,
name: product.name,
status: product.status,
supportEnabled: product.supportEnabled,
integrationStatus: product.integration?.status ?? null,
}));
return reply.status(200).send({ success: true, data, meta: null });
}
}
export const productsController = new ProductsController();
+2 -2
View File
@@ -21,5 +21,5 @@ export type { ProductIntegrationWithProduct } from './repository';
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
export { inboundRequestSchema } from './schema';
export type { InboundRequest } from './schema';
export { inboundRequestSchema, identityOnlyRequestSchema } from './schema';
export type { InboundRequest, IdentityOnlyRequest } from './schema';
@@ -8,6 +8,17 @@ export class ProductsRepository {
return this.prisma.product.findMany();
}
/** 012-admin-list-views: the product catalog with each product's integration status joined
* in — never the full ProductIntegration row (its credentialRef is a secret at rest). */
async findAllWithIntegrationStatus(): Promise<
(Product & { integration: { status: string } | null })[]
> {
return this.prisma.product.findMany({
orderBy: { name: 'asc' },
include: { integration: { select: { status: true } } },
});
}
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
return this.prisma.product.findUnique({ where: { externalProductId } });
}
@@ -1,34 +1,35 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { productIntegrationsController } from '../controller';
/**
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet.
* Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see
* specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known
* limitation this decorator previously didn't perform real verification).
*/
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/integration',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.register(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/rotate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.rotate(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/revoke',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.revoke(req, reply),
);
fastify.patch(
'/admin/integrations/:integrationId/status',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.updateStatus(req, reply),
);
@@ -1,6 +1,15 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { productsController } from '../controller';
export async function productsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.get('/products', (req, reply) => productsController.getProducts(req, reply));
// 012-admin-list-views: admin-only — a separate route rather than a query flag on the public
// /products above, so the auth gate stays unconditional (research.md).
fastify.get(
'/admin/products',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productsController.getProductsWithIntegrationStatus(req, reply),
);
}
@@ -20,3 +20,20 @@ export const inboundRequestSchema = z
.strict();
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
/**
* 009-problem-resolution: the identity-only subset of the inbound contract — for a caller
* already acting on an existing ticket (confirm-resolution, reopen) rather than creating one, so
* `source`/`problem` (ticket-creation-specific) aren't required. Every other verification step
* (token validity, replay, scope, revocation) is identical — see
* product-integration-auth.plugin.ts's shared verifyIntegrationIdentity.
*/
export const identityOnlyRequestSchema = z
.object({
productId: z.string().min(1),
tenantId: z.string().min(1),
userId: z.string().min(1),
})
.strict();
export type IdentityOnlyRequest = z.infer<typeof identityOnlyRequestSchema>;
@@ -6,6 +6,12 @@ export class ProductsService {
async listProducts(): Promise<unknown[]> {
return this.repo.findAllProducts();
}
/** 012-admin-list-views: the product catalog with integration status, for the admin catalog
* screen. */
async listWithIntegrationStatus() {
return this.repo.findAllWithIntegrationStatus();
}
}
export const productsService = new ProductsService();
@@ -6,6 +6,8 @@ import {
AgentSkillsService,
agentAvailabilityService,
AgentAvailabilityService,
usersService,
UsersService,
} from '../service';
import {
createAgentSchema,
@@ -13,6 +15,7 @@ import {
listAgentsQuerySchema,
upsertAgentSkillSchema,
upsertAgentAvailabilitySchema,
createUserSchema,
} from '../schema';
export class AgentsController {
@@ -20,6 +23,7 @@ export class AgentsController {
private readonly service: AgentsService = agentsService,
private readonly skills: AgentSkillsService = agentSkillsService,
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
private readonly users: UsersService = usersService,
) {}
async create(request: FastifyRequest, reply: FastifyReply) {
@@ -73,6 +77,13 @@ export class AgentsController {
const record = await this.availability.getForAgent(agentId);
return reply.status(200).send({ success: true, data: record, meta: null });
}
/** 010-identity-auth User Story 4: admin-only account creation. */
async createUser(request: FastifyRequest, reply: FastifyReply) {
const body = createUserSchema.parse(request.body);
const user = await this.users.create(body);
return reply.status(201).send({ success: true, data: user, meta: null });
}
}
export const agentsController = new AgentsController();
@@ -10,6 +10,7 @@ export interface UpdateAgentData {
name?: string | undefined;
teamId?: string | undefined;
active?: boolean | undefined;
userId?: string | null | undefined;
}
export interface FindAgentsFilter {
@@ -44,6 +45,11 @@ export class AgentsRepository {
});
}
/** 011-agent-ticket-queue: resolves a logged-in session to its agent roster row. */
async findByUserId(userId: string): Promise<Agent | null> {
return this.prisma.agent.findUnique({ where: { userId } });
}
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.prisma.agent.findMany({
where: {
@@ -1,3 +1,4 @@
export * from './agents.repository';
export * from './agent-skills.repository';
export * from './agent-availability.repository';
export * from './users.repository';
@@ -0,0 +1,27 @@
import { User } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateUserData {
email: string;
name: string;
role: 'ADMIN' | 'AGENT';
passwordHash: string;
}
export class UsersRepository {
constructor(private readonly prisma = prismaClient) {}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async create(data: CreateUserData): Promise<User> {
return this.prisma.user.create({ data });
}
}
export const usersRepository = new UsersRepository();
@@ -1,9 +1,17 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { agentsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
* (research.md "Admin endpoint authentication"). */
/** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a
* no-op stub, per that feature's own research.md). `POST /admin/users` additionally requires
* the ADMIN role (010's own User Story 4) since account creation is more sensitive than
* agent-roster management. */
export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/users',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => agentsController.createUser(req, reply),
);
fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.create(req, reply),
);
@@ -11,6 +11,9 @@ export const updateAgentSchema = z
name: z.string().min(1).optional(),
teamId: z.string().min(1).optional(),
active: z.boolean().optional(),
// 011-agent-ticket-queue: links this agent to the User account it authenticates as.
// null explicitly unlinks; omitting the field leaves the existing link unchanged.
userId: z.string().min(1).nullable().optional(),
})
.strict();
@@ -1,3 +1,4 @@
export * from './agents.schema';
export * from './agent-skills.schema';
export * from './agent-availability.schema';
export * from './users.schema';
@@ -0,0 +1,12 @@
import { z } from 'zod';
export const createUserSchema = z
.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['ADMIN', 'AGENT']),
password: z.string().min(1),
})
.strict();
export type CreateUserBody = z.infer<typeof createUserSchema>;
@@ -1,5 +1,5 @@
import { Agent } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { ConflictError, NotFoundError, ValidationError } from '@/common/errors';
import { teamsRepository } from '@/modules/identity/teams';
import {
agentsRepository,
@@ -7,6 +7,7 @@ import {
CreateAgentData,
UpdateAgentData,
FindAgentsFilter,
usersRepository,
} from '../repository';
export class AgentsService {
@@ -26,6 +27,20 @@ export class AgentsService {
const team = await teamsRepository.findById(data.teamId);
if (!team) throw new NotFoundError('Team not found.');
}
// 011-agent-ticket-queue FR-001: proactive existence/role/duplicate-link checks, mirroring
// UsersService.create's own pre-check style, rather than translating a raw unique-
// constraint error after the fact.
if (data.userId !== undefined && data.userId !== null) {
const user = await usersRepository.findById(data.userId);
if (!user) throw new NotFoundError('User not found.');
if (user.role !== 'AGENT') {
throw new ValidationError('Only a User with role AGENT can be linked to an agent.');
}
const existingLink = await this.repo.findByUserId(data.userId);
if (existingLink && existingLink.id !== agentId) {
throw new ConflictError('This account is already linked to a different agent.');
}
}
const updated = await this.repo.update(agentId, data);
if (!updated) throw new NotFoundError('Agent not found.');
return updated;
@@ -37,6 +52,15 @@ export class AgentsService {
return agent;
}
/** 011-agent-ticket-queue FR-006: resolves a logged-in session to its own agent roster row,
* throwing a specific, distinguishable error rather than letting a caller mistake "no linked
* agent" for "an agent with zero results." */
async requireAgentForUser(userId: string): Promise<Agent> {
const agent = await this.repo.findByUserId(userId);
if (!agent) throw new NotFoundError('No agent profile is linked to this account.');
return agent;
}
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.repo.findAll(filter);
}
@@ -1,3 +1,4 @@
export * from './agents.service';
export * from './agent-skills.service';
export * from './agent-availability.service';
export * from './users.service';
@@ -0,0 +1,35 @@
import { User } from '@prisma/client';
import { ConflictError } from '@/common/errors';
import { hashPassword } from '@/modules/identity/auth';
import { usersRepository, UsersRepository } from '../repository';
import { CreateUserBody } from '../schema';
export class UsersService {
constructor(private readonly repo: UsersRepository = usersRepository) {}
/** FR-008: rejects a duplicate email — never a second account silently sharing one. */
async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> {
const existing = await this.repo.findByEmail(body.email);
if (existing) throw new ConflictError('An account with this email already exists.');
const passwordHash = await hashPassword(body.password);
const user = await this.repo.create({
email: body.email,
name: body.name,
role: body.role,
passwordHash,
});
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
active: user.active,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
}
export const usersService = new UsersService();
@@ -1,17 +1,33 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service';
import { AuthCredentialsInput } from '../types';
import { loginSchema } from '../schema';
function bearerToken(request: FastifyRequest): string {
const header = request.headers.authorization;
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
}
export class AuthController {
constructor(private readonly service: AuthService = authService) {}
async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) {
const user = await this.service.validateCredentials(request.body);
return reply.status(200).send({
success: true,
data: user,
meta: null,
});
async handleLogin(request: FastifyRequest, reply: FastifyReply) {
const body = loginSchema.parse(request.body);
const result = await this.service.login(body);
return reply.status(200).send({ success: true, data: result, meta: null });
}
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
// Unreachable in practice: this route is only ever registered behind fastify.authenticate,
// which always sets request.user on success.
if (!request.user) throw new AuthenticationError('Session is no longer valid.');
const user = await this.service.getCurrentUser(request.user.id);
return reply.status(200).send({ success: true, data: user, meta: null });
}
async handleLogout(request: FastifyRequest, reply: FastifyReply) {
await this.service.logout(bearerToken(request));
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
}
}
+5 -1
View File
@@ -1,3 +1,7 @@
export { authRoutes } from './routes';
export { AuthService, authService } from './service';
export type { AuthCredentialsInput } from './types';
export { requireRole } from './service';
export type { LoginBody } from './schema';
export type { LoginResult } from './service';
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
export { AUTH_CONSTANTS } from './constants';
@@ -1,5 +1,52 @@
export class AuthMapper {
static toResponse(user: Record<string, unknown>): Record<string, unknown> {
return { ...user };
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AuthUser, JwtPayload } from '@/common/types';
import { ActorType } from '@/common/enums';
import { authConfig } from '@/config';
const SALT_ROUNDS = 10;
// research.md "byte-identical failure response": compared against when no user is found at
// all, so a login's timing/shape never reveals whether the email itself was valid.
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', SALT_ROUNDS);
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
return bcrypt.compare(password, hash ?? DUMMY_HASH);
}
export function signToken(user: { id: string; email: string; role: string }): {
token: string;
jti: string;
expiresAt: Date;
} {
const jti = randomUUID();
const expiresInSeconds = authConfig.tokenLifetimeHours * 3600;
const payload: Omit<JwtPayload, 'iat' | 'exp'> = {
sub: user.id,
email: user.email,
role: user.role,
actorType: ActorType.USER,
jti,
};
const token = jwt.sign(payload, authConfig.jwtSecret, { expiresIn: expiresInSeconds });
return { token, jti, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, authConfig.jwtSecret) as JwtPayload;
}
export function toAuthUser(payload: JwtPayload): AuthUser {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
actorType: payload.actorType,
};
}
@@ -1,12 +1,18 @@
import { User } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class AuthRepository {
constructor(private readonly prisma = prismaClient) {}
async findByEmail(email: string): Promise<unknown> {
return this.prisma.user.findUnique({
where: { email },
});
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
/** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */
async findActiveById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user || !user.active) return null;
return user;
}
}
@@ -1,9 +1,14 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { FastifyInstance } from 'fastify';
import { authController } from '../controller';
import { AuthCredentialsInput } from '../types';
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) =>
authController.handleLogin(req, reply),
fastify.post('/auth/login', (req, reply) => authController.handleLogin(req, reply));
fastify.get('/auth/me', { preHandler: fastify.authenticate }, (req, reply) =>
authController.getCurrentUser(req, reply),
);
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
authController.handleLogout(req, reply),
);
}
@@ -1,5 +1,10 @@
import { z } from 'zod';
export const authCredentialsSchema = z.object({
export const loginSchema = z
.object({
email: z.string().email(),
});
password: z.string().min(1),
})
.strict();
export type LoginBody = z.infer<typeof loginSchema>;
@@ -1,11 +1,50 @@
import { User } from '@prisma/client';
import { AuthenticationError } from '@/common/errors';
import { revokeToken } from '@/infrastructure/cache';
import { authRepository, AuthRepository } from '../repository';
import { AuthCredentialsInput } from '../types';
import { verifyPassword, signToken, verifyToken } from '../mapper';
import { LoginBody } from '../schema';
export interface LoginResult {
token: string;
user: { id: string; email: string; name: string; role: string };
}
function toPublicUser(user: User): LoginResult['user'] {
return { id: user.id, email: user.email, name: user.name, role: user.role };
}
export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {}
async validateCredentials(input: AuthCredentialsInput): Promise<unknown> {
return this.repo.findByEmail(input.email);
/**
* FR-002/SC-003: every failure branch (no such email, inactive account, wrong password)
* throws the identical AuthenticationError — bcrypt.compare always runs exactly once,
* against a fixed dummy hash when no user is found, so timing never leaks which branch fired.
*/
async login(body: LoginBody): Promise<LoginResult> {
const user = await this.repo.findByEmail(body.email);
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
if (!user || !user.active || !passwordMatches) {
throw new AuthenticationError('Invalid email or password.');
}
const { token } = signToken(user);
return { token, user: toPublicUser(user) };
}
/** User Story 3: re-validated against current account state, not just the token's claims. */
async getCurrentUser(userId: string): Promise<LoginResult['user']> {
const user = await this.repo.findActiveById(userId);
if (!user) throw new AuthenticationError('Session is no longer valid.');
return toPublicUser(user);
}
async logout(token: string): Promise<void> {
const payload = verifyToken(token);
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
await revokeToken(payload.jti, remainingSeconds);
}
}
@@ -1 +1,2 @@
export * from './auth.service';
export * from './require-role';
@@ -0,0 +1,15 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
/**
* research.md "Role-gating via a requireRole(...roles) preHandler factory": composes with
* fastify.authenticate as a second preHandler — `{ preHandler: [fastify.authenticate,
* requireRole('ADMIN')] }` — rather than a fixed decorator per role.
*/
export function requireRole(...allowedRoles: string[]) {
return async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
if (!request.user || !allowedRoles.includes(request.user.role)) {
throw new AuthorizationError('You do not have permission to perform this action.');
}
};
}
@@ -1,3 +1 @@
export interface AuthCredentialsInput {
email: string;
}
export {};

Some files were not shown because too many files have changed in this diff Show More