58 Commits
Author SHA1 Message Date
saqibmir da11dbc961 Merge pull request 'development' (#18) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/18
2026-09-10 09:19:18 +00:00
saqibmir d78bf52182 Merge pull request '016-load-concurrency-testing' (#17) from 016-load-concurrency-testing into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/17
2026-09-10 07:00:21 +00:00
saqib mirandClaude Sonnet 5 20e5493798 docs(016-load-concurrency-testing): polish — findings, task completion
Documents implementation-time findings in the requirements checklist: all
three suspected races were confirmed real then fixed, the ticket-status
mechanism needed no fix, a real pre-existing test-infrastructure issue
(throwaway DB ticket-code collisions at high accumulated volume) was found
and resolved by resetting the throwaway database and replaying its full
migration history, two full-suite-only integration failures were confirmed
as pre-existing cross-file contamination (not a regression), and the
load-test tooling surfaced a real Anthropic API cost consideration for
ticket creation itself. All 23 tasks marked complete.

Full quality gate green: typecheck, lint, architecture check, full unit
suite (119/119), full integration suite against a freshly reset throwaway
database (122/124 — the 2 failures are the project's own already-accepted
MinIO baseline), and all 6 concurrency test files (11/11).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:14:16 +05:30
saqib mirandClaude Sonnet 5 8400a8db84 feat(016-load-concurrency-testing): US5 — autocannon load-test tooling
tests/load/autocannon.config.ts is a thin shared wrapper over autocannon's
programmatic API producing this feature's own report shape (throughput,
latency p50/p90/p99, non-2xx, rate-limited count) — printed and written to
tests/load/reports/ (gitignored) for every run. No pass/fail threshold is
applied (FR-009): throughput/latency targets are an OPEN BUSINESS DECISION
per the roadmap's own convention, never invented.

Three scripts cover the named critical endpoint groups: ticket-creation
(pure DB path), admin-reporting (015-reporting-dashboards, pure DB path),
and ai-support-flow (005-ai-support's real Anthropic API calls — clearly
flagged as real, billed cost, run only at a small bounded amount rather than
an open-ended duration). All three were run once at a small scale against the
real dev server to confirm the tooling works end-to-end and cleans up fully
after itself (verified via direct DB checks, not just script exit codes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:50:12 +05:30
saqib mirandClaude Sonnet 5 cd8e408d7e test(016-load-concurrency-testing): US4 — prove ticket status optimistic concurrency
tests/concurrency/ticket-status-race.test.ts fires 20 genuinely concurrent
TicketsRepository.updateStatus calls from the same starting version against
real Postgres. Passes on the first run, confirming (rather than assuming)
003-ticketing's existing atomic version-checked updateMany already holds
under real concurrency — no implementation change needed (research.md §4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:19:24 +05:30
saqib mirandClaude Sonnet 5 c7aac460b7 test(016-load-concurrency-testing): US3 — prove escalation idempotency holds
tests/concurrency/escalation-idempotency.test.ts fires the same escalation
trigger (escalationService.handleBreach) concurrently more than once for the
same ticket against real Postgres, asserting exactly one EscalationEvent and
one current Assignment result every time — verified across 10 repeated runs
(SC-003). The database-level unique-violation is visibly caught and absorbed
in the logs, confirming the fix (previous commit) actually engages under a
genuine race rather than being untested code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:17:31 +05:30
saqib mirandClaude Sonnet 5 93d6fe8b94 fix(016-load-concurrency-testing): US3 — finish escalation idempotency plumbing
Completes the escalation-event repository/service changes from the prior
commit: normalizes the exactOptionalPropertyTypes mismatch in the
findFirst fallback lookup, and updates every existing unit test
(sla-pause-resume, sla-breach-detection, sla-compliance-metric,
escalation-rule-match) to the new SlaRunRepository.updateWithVersion and
EscalationEventRepository.create({event, wasNewlyCreated}) signatures.
Full typecheck/lint/architecture-check clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:13:46 +05:30
saqib mir 794e16f349 create the specf document 2026-09-09 17:39:12 +05:30
saqib mirandClaude Sonnet 5 0e3543bb01 fix(016-load-concurrency-testing): US2 — version-guard SLA pause/resume/sweep
SlaRunRepository.updateWithVersion replaces the old unguarded update(),
mirroring TicketsRepository.updateStatus's exact atomic-updateMany pattern.
pause/resume/complete now retry (bounded) against fresh state on a version
conflict; the breach sweep skips a run that lost the race to a concurrent
pause/resume/complete rather than clobbering it, deferring to the next
scheduled pass. Verified against real Postgres: concurrent pause/resume/sweep
activity against the same run now always leaves it in one
internally-consistent state, across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:17:09 +05:30
saqib mirandClaude Sonnet 5 58d0a98134 fix(016-load-concurrency-testing): US1 — retry assignment creation on race conflict
AssignmentRepository.createAssignment now catches the
assignments_one_current_per_ticket unique-violation and retries the whole
supersede-then-create transaction (bounded, with jitter) instead of
propagating a raw P2002 to the caller. Verified against real Postgres: before
this fix, 20 genuinely concurrent assignment attempts on the same ticket
reliably threw an unhandled unique-constraint error; after it, exactly one
current assignment results every time across 10 repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:10:10 +05:30
saqib mirandClaude Sonnet 5 1e332143bd build(016-load-concurrency-testing): T001-T002 setup + concurrency-guard migration
Adds autocannon as a devDependency for the load-test tooling (T001), and the
shared schema migration T002 blocks: SLARun.version for optimistic
concurrency, plus two Postgres partial unique indexes
(assignments_one_current_per_ticket, escalation_events_ticket_rule_unique)
guarding against the assignment and escalation races research.md documents.
Applied directly to both the real dev DB and the throwaway test DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:03:37 +05:30
saqib mirandClaude Sonnet 5 2fb5b7ac73 tasks(016-load-concurrency-testing): break down into 23 tasks across 5 stories
Foundational phase (T002) covers the one shared schema migration US1/US2/US3
depend on; US4 (proof-only, no schema change) and US5 (load-test tooling)
have no dependency on it and can proceed independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:57:09 +05:30
saqib mirandClaude Sonnet 5 015ef62b71 plan(016-load-concurrency-testing): design assignment/SLA/escalation race fixes
research.md nails down the exact mechanism for each real race the audit
found: a partial unique index for assignment double-assignment, a
Ticket-style version counter for SLA pause/resume/sweep, and a partial
unique index for escalation-rule idempotency — each traced to the specific
repository/service code that has the gap today. data-model.md and plan.md
carry the resulting schema and repository-contract changes; quickstart.md
defines the real-infra verification steps for each user story.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:55:32 +05:30
saqib mirandClaude Sonnet 5 9f52d51003 spec(016-load-concurrency-testing): specify concurrency-safety and load testing scope
Five user stories: assignment double-assignment race, SLA pause/resume race,
escalation idempotency, ticket optimistic-concurrency proof, and HTTP
load/throughput testing tooling. Scoped from a targeted audit of existing
concurrency guarantees rather than guesswork — see spec.md's Assumptions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:51:00 +05:30
saqib mirandClaude Sonnet 5 7106753ed3 fix(015-reporting-dashboards): count humanEscalated by Assignment existence, not status
ManagementRepository/ProductReportRepository.countEverEscalatedToHuman checked
a list of terminal statuses that ticket-state-machine.ts's own transition
table shows are reachable from BOTH the AI-resolved path and the
human-escalation path once they converge (RESOLUTION_PENDING_CUSTOMER,
RESOLVED, CLOSED, REOPENED). Every AI-resolved ticket was being double-counted
as human-escalated too — confirmed live against real seeded dev data
(humanEscalated: 34 out of totalCases: 34, an impossible 100%).

Fixed by keying off assignments: { some: {} } instead, since
orchestrationService.handleHumanEscalation is the only code path that ever
creates an Assignment row. Updated management-dashboard.test.ts's own
human-resolved fixture to create a real Assignment row, since it previously
relied on the now-fixed buggy status-based signal without one.

Found via manual verification against a real running dev server while
building supporthub-web's 002-reporting-dashboards-ui, not by any existing
automated test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:31:13 +05:30
saqib mirandClaude Sonnet 5 d65683641a feat(015-reporting-dashboards): four real reporting/analytics endpoints
Wires the pre-scaffolded, unused platform/reports module (ReportsService
.generateSummaryReport previously returned {}) into four real, admin-
gated dashboards matching docs/09-testing-observability-cicd.md's own
table:

- GET /admin/reports/management: total cases, AI-resolved, human-
  escalated, resolved/open, SLA compliance/breaches, escalation count,
  average response/resolution time.
- GET /admin/reports/product/:externalProductId: support volume,
  problem-category breakdown, recurring problems, AI-resolution/human-
  escalation rate, top error codes.
- GET /admin/reports/support: current per-agent workload, SLA at-risk/
  breached counts, escalation count, response/resolution performance.
- GET /admin/reports/ai: AI resolution/human-handoff rate, failed-
  troubleshooting-then-escalated rate, knowledge-match rate, confidence
  distribution (reusing 005-ai-support's own decideConfidenceBand),
  tool invocation success/failure.

Every rate/average is number|null -- null means no qualifying data in
range, never a computed NaN or a misleading 0. Adds one new durable
table, ErrorCodeLookup, since 014-full-observability's own equivalent
metric is a process-lifetime Prometheus counter unusable for a
historical "top errors" report.

Verified end-to-end against real Postgres/Redis: every figure checked
against hand-computed expected values, including a no-activity range
(all-zero counts, all-null rates) and cross-product isolation.

Also fixes a real regression the new ErrorCodeLookup FK caused in the
pre-existing known-issues.test.ts (its afterAll deleted ErrorCode rows
before the now-referencing lookup rows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:59:38 +05:30
saqib mirandClaude Sonnet 5 814d9d7b17 docs(015-reporting-dashboards): task breakdown
28 tasks across a shared Foundational phase (schema, config, shared
rate/date-range helpers, module scaffolding) and 4 independently-testable
dashboard user stories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:15:22 +05:30
saqib mirandClaude Sonnet 5 4a159725c2 docs(015-reporting-dashboards): plan, research, data model, contract, quickstart
Documents the exact Prisma query per dashboard figure, the one new
durable table this feature needs (ErrorCodeLookup — 014's own equivalent
metric is process-lifetime, unusable for a historical report), the
"no data -> null, never NaN" convention, and why the AI dashboard's
confidence distribution deliberately uses the system-default threshold
rather than resolving a per-diagnosis policy (AIDiagnosis has no
reliable FK back to which policy applied).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:14:29 +05:30
saqib mirandClaude Sonnet 5 c4a2faa6e3 docs(015-reporting-dashboards): feature spec and quality checklist
Phase 11's third sub-area (reporting/analytics dashboards), per explicit
user direction. Backend-first scope (four read-only aggregation
endpoints wiring up the pre-scaffolded platform/reports module),
following the same backend-before-frontend pattern already established
for 010/011/014 this session — a supporthub-web dashboard UI is a
separate, not-yet-started follow-on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:10:00 +05:30
saqib mirandClaude Sonnet 5 ea50e3596a test(014-full-observability): fix cross-file contamination + polish
business-metrics.test.ts's "human resolution" case drove a ticket
through a real HUMAN_ESCALATION transition via ticketsService.updateStatus,
which triggers the real orchestration subscriber's default ROUND_ROBIN
auto-assignment against every agent in the shared throwaway database —
reproduced deterministically landing on agent-ticket-queue.test.ts's own
dedicated agent. Fixed by driving the intermediate transitions directly
through ticketsRepository.updateStatus (no domain-event publish),
reserving the real, event-publishing call for only the final RESOLVED
transition the metric subscriber needs to observe.

Also documents (checklist Notes), without fixing, a separate pre-existing
issue confirmed unrelated to this feature via git checkout to the clean
013-auth-hardening tip: nearly every integration test file's product ID
collapses to the same 4-letter ticket-code prefix ("TEST"), so enough
concurrent TEST_*-prefixed files can exceed the fixed retry ceiling on
ticket-code generation and surface as a real 500 — a 003-ticketing
concern, out of scope here.

Marks all 30 tasks.md items complete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:56 +05:30
saqib mirandClaude Sonnet 5 de5915a8c1 feat(014-full-observability): remaining business metrics (first response, resolution, SLA, errors, tools)
Completes User Story 4's eleven named metrics: first-response-time
(messages.service.ts's post(), guarded against double-counting a
ticket's second agent message), SLA compliance (sla.service.ts's
complete()/runBreachDetectionSweep(), with a guard so a run already
breached by the sweep is never also counted "met" when it later
resolves), most-common-errors (error-codes.service.ts, counted only
once a code is confirmed real), and tool-failure-rate/knowledge-
effectiveness (tools.service.ts's single executeTool call site).

Verified end-to-end against real Postgres/Redis by scraping the real
/metrics endpoint before and after driving each metric's actual
underlying event through the real service layer — including a genuine
tool-execution failure (a nonexistent ticket ID) rather than a
simulated one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:57:47 +05:30
saqib mirandClaude Sonnet 5 f75589d9ce feat(014-full-observability): real distributed tracing + first 5 business metrics
User Story 3: initializes a real OpenTelemetry TracerProvider (previously
inert — getTracer() returned a no-op tracer with nothing ever exported).
Adds ticket.create, ai.escalation, and orchestration.assignment spans
covering both FR-006 cross-module paths, verified via a real, in-memory
test exporter that confirms actual trace/parent-span nesting, not mocked.

Also registers an AsyncLocalStorageContextManager
(@opentelemetry/context-async-hooks) — without one, OTel's context API is
a no-op that doesn't propagate across the await boundaries this feature's
own event-bus subscribers rely on for span nesting; caught by the first
version of the tracing integration test actually failing on real
parent/child assertions, not assumed.

Graceful degradation (FR-007) verified against a real, deliberately
unreachable OTLP endpoint: the SDK's own background export path (what
production actually exercises) never produces an unhandled rejection.

Starts on the 11 named business-health metrics: AI session
resolved/escalated outcomes (session.repository.ts, the single choke
point every branch in session.service.ts funnels through), human-vs-AI
resolution + resolution-time (a new TICKET_UPDATED/RESOLVED subscriber),
escalation rate (a new subscriber on ESCALATION_TRIGGERED, published
unconditionally since 008 but never previously consumed), and recurring
problems (tickets.service.ts's existing problem-creation call site).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:32:14 +05:30
saqib mirandClaude Sonnet 5 acd3843aaf feat(014-full-observability): per-request access log + live request-duration metric
User Stories 1-2: every completed request (including 404s and early
replies from other hooks) now emits exactly one structured access-log
line, and every log line produced during that request's handling shares
its requestId/correlationId via a new AsyncLocalStorage-backed Pino mixin
— with zero changes to any existing log call site. The previously-dead
supporthub_http_request_duration_seconds histogram now actually receives
observations, so error rate and latency per route are computable from
/metrics alone.

Also bumps @opentelemetry/sdk-trace-base 1.x -> 2.x to align with the two
new tracing dependencies added in this same branch (exporter-trace-otlp-http,
resources) onto one consistent major version — npm had otherwise installed
two incompatible OTel core/resources majors side by side, which also
happened to resolve a moderate DoS advisory in @opentelemetry/core <2.8.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:12:14 +05:30
saqib mirandClaude Sonnet 5 0135e4ca05 docs(014-full-observability): task breakdown
30 tasks across 4 independently-testable user stories plus a shared
foundational phase (ALS request-context store + new OTel dependencies).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:52:26 +05:30
saqib mirandClaude Sonnet 5 5a0fe9f847 docs(014-full-observability): plan, research, data model, contract, quickstart
Documents the exact hook point chosen for each of the 3 dead observability
primitives (access log, request-duration histogram, tracer provider) and
the 11 named business-health metrics, verified against the real current
code rather than assumed — including a pre-existing SLA-run status data
quality gap surfaced along the way (documented, not fixed here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:51:22 +05:30
saqib mirandClaude Sonnet 5 09ba56d3af docs(014-full-observability): feature spec and quality checklist
Phase 11's second sub-area (full observability), per explicit user
direction. Scopes wiring the already-scaffolded logging/metrics/tracing
into something actually functional, explicitly bounded away from the
separate, not-yet-started reporting/analytics dashboards sub-area.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:31:38 +05:30
saqib mirandClaude Sonnet 5 79bc2ef25b feat(013-auth-hardening): password reset, password strength policy, login rate-limiting
Closes the two gaps 010-identity-auth explicitly deferred (password reset,
login rate-limiting), plus a shared password-strength validator both the
reset-consume endpoint and admin account creation now depend on.

- Password reset: single-use, paired-Redis-key tokens (never in Postgres),
  identical response regardless of account existence, stubbed delivery via
  a structured log line (no email infrastructure exists yet).
- Password strength: one validatePasswordStrength() call site, wired into
  both POST /admin/users and the reset-consume flow.
- Login rate-limiting: checkRateLimit keyed by submitted email, checked
  before any credential verification.

Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused
two fixed accounts across the whole integration suite via upsert — now
rate-limited per email, that collided across ~30 files sharing one budget.
Each call now gets a unique email; no call sites needed to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:22:49 +05:30
saqib mirandClaude Sonnet 5 3bdccc901f test(011-agent-ticket-queue): harden afterAll against wildcard SLA policy contamination
Same class of cross-file test-isolation gap already fixed in
orchestration-flow.test.ts and sla-escalation-flow.test.ts (010's own
regression work): a wildcard (non-product-scoped) SLA policy from
another suite can match this file's own tickets too, leaving a real
sla_run row that RESTRICTs the ticket delete. Also cleaned up several
orphaned wildcard SLA policies that had accumulated in the shared
throwaway test database from earlier runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:33:52 +05:30
saqib mirandClaude Sonnet 5 65175a85b5 docs(013-auth-hardening): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:27:27 +05:30
saqib mirandClaude Sonnet 5 52f1fa3db0 docs(013-auth-hardening): plan, research, data model, contract, quickstart
Reset tokens live only in Redis as a paired key shape (mirrors 010's own
revocation-denylist pattern) - never in Postgres, never storing the raw
token. Password-strength policy is one shared validator called from both
the new reset-consume endpoint and 010's existing POST /admin/users.
Login rate-limiting reuses the existing checkRateLimit helper from
002's own inbound trust boundary, keyed by submitted email, checked
before any credential verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:26:49 +05:30
saqib mirandClaude Sonnet 5 b016e77b70 docs(013-auth-hardening): spec for password reset, password policy, login rate-limiting
Phase 11's "security hardening pass" (docs/10-implementation-roadmap.md),
first slice, per explicit user direction. Closes the two concrete gaps
010-identity-auth's own Assumptions named as out of its scope. MFA is
intentionally excluded as its own larger follow-up feature. Email
delivery for password-reset is stubbed (server-side log) per explicit
user decision, since this codebase has no email infrastructure at all
today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:23:49 +05:30
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
saqibmir 051ee88974 Merge pull request 'development' (#15) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/15
2026-09-03 11:21:04 +00:00
saqibmir 177488caf8 Merge pull request 'fix' (#14) from 009-problem-resolution into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/14
2026-09-03 11:20:27 +00:00
MdIrshad1234 5607fdfcd5 Merge branch 'main' of https://gitea.maskantech.in/gitea_admin/support_backend 2026-09-03 16:46:02 +05:30
MdIrshad1234 ca14d45ff1 marge the solve 2026-09-03 16:45:45 +05:30
saqibmir a5650b1089 Merge pull request 'development' (#13) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/13
2026-09-03 11:12:34 +00:00
saqibmir 7e92a1679b Merge pull request '009-problem-resolution' (#12) from 009-problem-resolution into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/12
2026-09-03 11:11:24 +00:00
MdIrshad1234 d902f6b26d slove the merge 2026-09-03 14:27:16 +05:30
saqibmir 6a7c6a493e Merge pull request 'development' (#11) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/11
2026-09-03 08:52:59 +00:00
saqibmir 6beef9584b Merge pull request '008-sla-escalation' (#10) from 008-sla-escalation into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/10
2026-09-03 08:50:56 +00:00
saqibmir 71520e2423 Merge pull request '007-orchestration-assignment' (#9) from 007-orchestration-assignment into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/9
2026-09-03 06:41:48 +00:00
maskantech f468dcb6f8 Merge pull request 'main' (#5) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/5
2026-08-20 07:52:48 +00:00
maskantech fc438d552b Merge pull request 'Update README.md' (#4) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/4
2026-08-20 07:30:57 +00:00
maskantech 97c954c6fa Merge pull request 'Update README.md' (#3) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/3
2026-08-20 07:26:08 +00:00
maskantech b44f3d1dd2 Merge pull request 'update compose' (#2) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/2
2026-08-20 07:17:37 +00:00
maskantech 8ba8cbe4e1 Merge pull request 'main' (#1) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/1
2026-08-20 06:08:31 +00:00
179 changed files with 11359 additions and 212 deletions
+31
View File
@@ -0,0 +1,31 @@
NODE_ENV=development
PORT=4501
# Nest build
BUILD_COMMAND=npm run build:development
# Database
POSTGRES_HOST=postgres
POSTGRES_DB=support_dev
POSTGRES_USER=support_user
POSTGRES_PASSWORD=z1F3tKF1JNDBQmMq95Up
# DATABASE_URL=postgresql://support_user:z1F3tKF1JNDBQmMq95Up@postgres:5432/support_dev
DATABASE_URL=postgresql://support_user:SupportDev123@localhost:5432/support_dev
# Redis
# REDIS_HOST=redis
# REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=D7FJ7QDKo5gF9KQAO1GL
# Security & CORS
# JWT_SECRET=super-secret-development-jwt-key-32-chars-long
# CORS_ORIGINS=https://support-dev.maskantech.in
# Security & CORS
JWT_SECRET=super-secret-development-jwt-key-32-chars-long
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=c2e444fe8cc19eb7465e2f8a05f7384628de7a879fcc812766e093c9182fcd58
CORS_ORIGINS=https://support-dev.maskantech.in
+3
View File
@@ -43,3 +43,6 @@ docker/minio/data/
.env
.env.*
!.env.example
# Load-test run reports — measurement artifacts, not fixtures (016-load-concurrency-testing)
tests/load/reports/
+23 -58
View File
@@ -1,66 +1,31 @@
# SupportHub API
### Development
- docker compose --env-file .env.development -f docker-compose.development.yml up -d --build
### Development (Docker)
- Start all services: `docker compose --env-file .env.development -f docker-compose.development.yml up -d --build`
- Start only database & cache (for local app development): `docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis`
### Test
- docker compose --env-file .env.test -f docker-compose.test.yml up --build
### Test (Docker)
- `docker compose --env-file .env.test -f docker-compose.test.yml up --build`
### Production
- docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
### Production (Docker)
- `docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d`
### Stop
- docker compose -f docker-compose.prod.yml down
### Stop / Down
- Stop production: `docker compose -f docker-compose.prod.yml down`
- Stop development: `docker compose -f docker-compose.development.yml down`
- Stop development & wipe volumes: `docker compose --env-file .env.development -f docker-compose.development.yml down -v`
### List Containers
- docker compose --env-file .env.development -f docker-compose.development.yml ps
### List Containers & Logs
- List containers: `docker compose --env-file .env.development -f docker-compose.development.yml ps`
- Follow logs: `docker compose --env-file .env.development -f docker-compose.development.yml logs -f`
### Logs
- docker compose --env-file .env.development -f docker-compose.development.yml logs -f
---
### Local Development (Host)
1. Start database & cache in Docker:
```bash
docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis
```
2. Start API server in watch mode:
```bash
npm run dev
```
---
### Database Migrations & Prisma
- **Generate Prisma Client**:
```bash
npm run prisma:generate
```
- **Run / Apply Dev Migrations**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:migrate
```
- **Deploy Migrations (Production/CI)**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:deploy
```
- **Push Schema directly (Sync schema without migration files)**:
```bash
npx dotenv-cli -e .env.development -- npx prisma db push
```
---
### Database Migrations
- **Local (using .env.development):**
- Create/apply new migration: `npx prisma migrate dev --name <name>`
- Push schema directly (prototype/sync): `npx prisma db push`
- Deploy pending migrations: `npm run prisma:deploy`
- **Inside Docker Container:**
- `docker exec -it support-api-development npx prisma migrate deploy`
### Database Seeding
- **Seed Database (Roles, Products, Categories, Hierarchy & Demo data)**:
```bash
npx dotenv-cli -e .env.development -- npm run prisma:seed
```
- **Local:**
- `npm run prisma:seed` (or `npx tsx --env-file=.env.development prisma/seed/index.ts`)
- **Inside Docker Container:**
- `docker exec -it support-api-development npm run prisma:seed`
+759 -35
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -54,7 +54,10 @@
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^3.0.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/context-async-hooks": "^2.11.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/resources": "^2.11.0",
"@opentelemetry/sdk-trace-base": "^2.11.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
@@ -70,12 +73,14 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/autocannon": "^7.12.7",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"autocannon": "^8.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.0.11",
@@ -83,7 +88,7 @@
"prettier": "^3.2.5",
"prisma": "^5.12.1",
"tsc-alias": "^1.9.2",
"tsx": "^4.7.2",
"tsx": "^4.23.13",
"typescript": "^5.4.5",
"vitest": "^1.5.0"
},
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "error_code_lookups" (
"id" TEXT NOT NULL,
"errorCodeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "error_code_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "error_code_lookups_productId_createdAt_idx" ON "error_code_lookups"("productId", "createdAt");
-- AddForeignKey
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "sla_runs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;
-- 016-load-concurrency-testing research.md §1: at most one current assignment per ticket,
-- enforced at the database level (a partial unique index, since Prisma's schema DSL cannot
-- express a WHERE-predicated unique constraint directly).
CREATE UNIQUE INDEX "assignments_one_current_per_ticket" ON "assignments"("ticketId") WHERE "isCurrent" = true;
-- 016-load-concurrency-testing research.md §3: a given escalation rule may fire at most once
-- per ticket over that ticket's lifetime (SLARun.ticketId is already @unique — no reopen-cycle
-- support, so a rule-triggered breach genuinely cannot recur for the same ticket). Manual
-- escalations (rule_id IS NULL) are excluded and remain repeatable.
CREATE UNIQUE INDEX "escalation_events_ticket_rule_unique" ON "escalation_events"("ticketId", "ruleId") WHERE "ruleId" IS NOT NULL;
+26 -1
View File
@@ -45,6 +45,7 @@ model Product {
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
errorCodeLookups ErrorCodeLookup[]
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
@@ -239,13 +240,31 @@ model ErrorCode {
productId String
description String
product Product @relation(fields: [productId], references: [id])
product Product @relation(fields: [productId], references: [id])
knownIssues KnownIssue[]
lookups ErrorCodeLookup[]
@@unique([productId, code])
@@map("error_codes")
}
// 015-reporting-dashboards research.md §6: a durable, append-only audit row recording that a
// known-error-code lookup happened — 014-full-observability's own equivalent
// (supporthub_known_error_lookups_total) is a process-lifetime Prometheus counter, unusable for
// a historical "top errors" report. productId is denormalized from errorCode.productId so the
// Product dashboard's range query never needs to join back through ErrorCode just to filter.
model ErrorCodeLookup {
id String @id @default(cuid())
errorCodeId String
errorCode ErrorCode @relation(fields: [errorCodeId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@index([productId, createdAt])
@@map("error_code_lookups")
}
model KnownIssue {
id String @id @default(cuid())
productId String
@@ -507,6 +526,7 @@ model Assignment {
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@index([agentId, isCurrent])
@@map("assignments")
}
@@ -575,6 +595,11 @@ model SLARun {
completedAt DateTime?
// 016-load-concurrency-testing: optimistic-concurrency counter, identical convention to
// Ticket.version (003-ticketing) — guards pause/resume/complete/the breach sweep against
// racing each other and silently clobbering this run's state (research.md §2).
version Int @default(0)
@@index([status, resolutionDueAt])
@@index([status, firstResponseDueAt])
@@map("sla_runs")
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
export async function seedCategories(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding baseline product categories...');
console.log(' Seeding baseline product categories...');
const product = await prisma.product.findUnique({
where: { externalProductId: 'CORE_PLATFORM' },
@@ -0,0 +1,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
@@ -0,0 +1,67 @@
# Specification Quality Checklist: Authentication Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This is `docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), first
slice, per explicit user direction — the two concrete gaps 010-identity-auth's own Assumptions
named as deliberately out of its scope: password-reset and login rate-limiting. MFA, the third
item 010 named, is intentionally excluded here as its own larger follow-up.
- Password-reset's email-delivery step is explicitly stubbed (server-side log, not a real send)
per explicit user decision — this codebase has no email-sending infrastructure at all today
(no library, no configured provider), discovered while scoping this feature, and introducing
one is a separate decision the user chose to defer rather than bundle into this pass.
- Password-strength policy (User Story 2) was added beyond the two named gaps because it's a
direct, unavoidable dependency of User Story 1 — a password-reset flow that accepts any
password would be hardening one gap while leaving the other wide open at the same door.
- All items pass; no revision iterations were needed.
## Implementation Notes (post-build)
- `tests/helpers/auth.ts`'s shared `loginAs()` helper previously reused two fixed accounts
(`test-admin@supporthub.test` / `test-agent@supporthub.test`) across every integration test
file via `upsert`. Once login became rate-limited per email (User Story 3), the ~30 files that
each call it once in their own `beforeAll` collectively exceeded the attempt budget for those
two shared addresses well before most files' own tests ran, turning their legitimate logins
into `429`s. Fixed by giving each `loginAs()` call its own unique, randomly-suffixed email —
nothing in the suite depended on the literal fixed addresses, so no call sites needed to
change, only the helper itself.
- While re-running the full suite for regression, `tests/integration/orchestration-strategies.test.ts`'s
"SKILL_BASED prefers the eligible agent with the higher proficiency level" test was found
failing (picks the lower-proficiency agent). Verified via `git stash` that this reproduces
identically on the clean pre-013 `HEAD` with none of this feature's changes present — it is a
pre-existing bug in 007-orchestration-assignment's `SKILL_BASED` strategy, unrelated to and out
of scope for this feature. Left unfixed here; worth its own follow-up.
- `tests/integration/ticket-attachments.test.ts`'s 2 known MinIO-dependent failures (accepted
baseline, this project doesn't run MinIO) remain unchanged by this feature.
- All other integration and unit tests pass, including 010-identity-auth's own login/admin-account
tests, confirming no regression from `AuthService.login`'s new rate-limit check or the shared
`validatePasswordStrength` call added to `UsersService.create`.
@@ -0,0 +1,48 @@
# Contract: Authentication Hardening
## `POST /auth/password-reset/request`
**Auth**: None (like login itself — the caller has no session yet).
**Request body**: `{ "email": "string" }`
**Response `200`** (always, regardless of whether the account exists):
```json
{ "success": true, "data": { "message": "If that account exists, a reset link has been sent." }, "meta": null }
```
No token, ever, appears in this response — it's only visible via the stub's own server-side log
line (`{ "event": "password_reset_requested", "userId": "...", "resetUrl": "..." }`).
## `POST /auth/password-reset/consume`
**Auth**: None (the token itself is the credential).
**Request body**: `{ "token": "string", "newPassword": "string" }`
**Responses**:
- `200``{ "success": true, "data": { "message": "Password updated." }, "meta": null }`
- `400 VALIDATION_ERROR``newPassword` doesn't meet `validatePasswordStrength`.
- `400 INVALID_RESET_TOKEN` (or equivalent) — token missing, expired, or already used. The
response never distinguishes which of the three — matching data-model.md's own note that a
consumer can't otherwise tell "expired" from "already used" from "never existed."
## `PATCH /admin/users` — unchanged route, tightened validation
`POST /admin/users` (010-identity-auth) now also rejects a `password` shorter than
`PASSWORD_MIN_LENGTH` with the same `validatePasswordStrength` message the reset-consume
endpoint uses — no new route, no schema field change, just a stricter check on the existing
`password` field.
## `POST /auth/login` — unchanged route, new pre-check
Before this feature: any number of attempts, any speed. After: attempts for the same submitted
`email` beyond `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` within `LOGIN_RATE_LIMIT_WINDOW_SECONDS` receive:
```json
{ "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many login attempts. Try again later." } }
```
with HTTP `429`, distinct from the existing `401` identical-failure-response 010 already
returns for wrong credentials.
+50
View File
@@ -0,0 +1,50 @@
# Data Model: Authentication Hardening
No Postgres schema changes. `User.passwordHash` (010-identity-auth) is updated in place by a
successful reset; no other model changes.
## Redis-only: Password Reset Token
Not a Prisma model — exists only as two paired Redis keys, both expiring together.
| Key | Value | TTL |
|---|---|---|
| `password-reset:token:<sha256(token)>` | `userId` | `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` |
| `password-reset:user:<userId>` | `sha256(token)` | same |
**Issuing** (`requestPasswordReset`): if `password-reset:user:<userId>` already has a value,
delete `password-reset:token:<that value>` first (invalidating the prior token — FR-002), then
set both new keys.
**Consuming** (`resetPassword`): `GET password-reset:token:<sha256(presented token)>` → if
absent, reject (FR-004: invalid/expired/already-used, indistinguishably — the key not existing
covers all three cases identically, which is itself desirable: a consumer can't tell "expired"
from "already used" from "never existed," matching the same non-leaking spirit as 010's own
login-failure parity). If present, resolve `userId`, delete both keys (single-use), update the
password.
## Configuration (new)
| Env var | Purpose | Default |
|---|---|---|
| `PASSWORD_MIN_LENGTH` | Minimum password length, enforced everywhere a password is set | `10` |
| `PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` | How long a reset token stays valid | `30` |
| `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` | Max login attempts per email per window | `5` |
| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | The window `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` applies over | `300` |
## Validation / Business Rules
- `requestPasswordReset(email)`: always returns the same shape regardless of whether `email`
resolves to a real, active account (FR-001) — internally, only issues a real token when it
does; the caller-visible response is identical either way.
- `resetPassword(token, newPassword)`: `validatePasswordStrength` runs first (fail fast on the
cheap, stateless check), then the token is looked up. Unlike login/reset-request,
account-existence secrecy doesn't apply here — FR-004 and User Story 2 both call for their
*own*, specific rejection reasons ("password too short" vs. "invalid or expired token"); only
FR-001's account-existence question needs the identical-response treatment, not this
endpoint's two legitimately-different failure modes.
- `login(email, password)`: the rate-limit check (`login:<email>`) runs first, before
`repo.findByEmail`/`verifyPassword` (FR-007) — a rate-limited request never reaches the
identical-failure-response logic 010 already built; it gets its own distinct rate-limit
rejection instead (Acceptance Scenario 1's own point: a rate limit is an honestly-different
condition from a credentials failure, not disguised as one).
+126
View File
@@ -0,0 +1,126 @@
# Implementation Plan: Authentication Hardening
**Branch**: `013-auth-hardening` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/013-auth-hardening/spec.md`
## Summary
Adds `POST /auth/password-reset/request` and `POST /auth/password-reset/consume` to
`identity/auth` (the module that already owns login/logout/self-identity mechanics), backed by
a Redis-stored, single-use reset token — the "delivery" step logs the token server-side rather
than emailing it. Adds a shared password-strength validator used by both the reset-consume
endpoint and 010's own `POST /admin/users`. Adds a pre-credential-check rate limit to
`POST /auth/login`, reusing the existing `checkRateLimit` helper 002's own inbound trust
boundary already established.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
**Primary Dependencies**: None new — reuses `crypto` (Node built-in, for token generation and
hashing), the existing `ioredis` client, and `zod`.
**Storage**: No schema change. Reset tokens live entirely in Redis (never in Postgres) — two
keys per active token, mirroring the existing revocation-denylist's own Redis-key-with-TTL shape:
`password-reset:token:<sha256(token)>``userId`, and `password-reset:user:<userId>`
`sha256(token)`, both with the same TTL (the reset token's own lifetime). The second key is what
lets issuing a new token invalidate the previous one (FR-002) without a database table.
**Testing**: Vitest — unit tests for the password-strength validator and the rate-limit's own
pre-credential-check ordering; integration tests against real Postgres/Redis for the full
request → (read the token from the stub's log output) → consume → login-with-new-password flow,
the identical-response-regardless-of-existing-account behavior, and the login rate limit
actually rejecting the N+1th attempt while a different account's login proceeds normally.
**Target Platform**: Same Fastify modular monolith. Modifies `identity/auth` (new routes,
service methods, the shared password-strength validator) and `identity/agents` (existing
`POST /admin/users` now calls the shared validator instead of accepting any password
unchecked).
**Project Type**: Backend service — single project.
**Performance Goals**: The login rate-limit check is one Redis `INCR` (already how
`checkRateLimit` works) — no added database round trip on the login hot path, consistent with
010's own performance goal for `fastify.authenticate`.
**Constraints**: FR-001/SC-001 — reset-request must respond identically regardless of account
existence, including timing-shape (the same pattern 010's login already established: do the
same amount of work either way). FR-007 — the rate-limit check MUST run before
`bcrypt.compare`, not after i.e. before any password-verification cost is paid, both for
FR-007's own ordering requirement and so a rate-limited attacker gains no timing signal from a
skipped bcrypt call.
**Scale/Scope**: Two new routes, one new shared validator, one new env-configured rate-limit
policy, one modified existing endpoint (`POST /admin/users`). No new module, no schema
migration, no new module dependencies. Explicitly excludes: MFA, real email delivery, IP-based
rate limiting, password complexity rules beyond minimum length (spec.md Assumptions).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Same carve-out as 010 — this hardens SupportHub's own staff authentication, never touching SaaS-delegated customer identity. | PASS |
| II. Configuration Over Hardcoding | Password minimum length and the login rate-limit's max-attempts/window are both new env-configured values (`PASSWORD_MIN_LENGTH`, `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS`), never hardcoded magic numbers — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Reset endpoints live in `identity/auth` (owns auth mechanics); the shared password-strength validator is exported from `identity/auth`'s own public `index.ts` for `identity/agents` to consume, the same precedent `hashPassword`/`verifyPassword` themselves already set. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
| VI. Durable Audit & History | Not applicable — no new audit-relevant mutable domain state (a password hash change isn't itself an audited business event in this codebase's existing model). | PASS — N/A |
| VII. Concurrency-Safe, Durable Job Handling | Reset-token issuance/consumption is a single Redis operation per step, no shared in-memory state; two concurrent consume attempts for the same token race safely (Redis `GET`+`DEL` — the loser sees the key already gone and is rejected, not a partial/double-apply). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
| Technology & Platform Constraints | No new dependencies or infrastructure — email delivery is explicitly stubbed (spec.md Assumptions, user decision), not a real provider integration. | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/013-auth-hardening/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-api/
├── src/
│ ├── config/
│ │ └── auth.ts # MODIFIED — passwordMinLength, loginRateLimit config
│ └── modules/
│ └── identity/
│ ├── auth/ # MODIFIED
│ │ ├── mapper/
│ │ │ └── password-policy.ts # NEW — shared validatePasswordStrength
│ │ ├── mapper/
│ │ │ └── reset-token.ts # NEW — generate/hash reset tokens
│ │ ├── repository/
│ │ │ └── reset-token.repository.ts # NEW — the two-Redis-key shape
│ │ ├── service/ # MODIFIED — requestPasswordReset, resetPassword,
│ │ │ login's new pre-check rate-limit call
│ │ ├── controller/ routes/ # MODIFIED — the two new routes
│ │ └── schema/ # MODIFIED — request/consume body schemas
│ └── agents/
│ └── service/
│ └── users.service.ts # MODIFIED — calls the shared validator
└── tests/
├── unit/identity/ # password-policy validator, rate-limit ordering
└── integration/ # full reset flow, identical-response check,
login rate-limit behavior
```
**Structure Decision**: Single project, no new module. Everything lives in `identity/auth`
(already owns login/logout/self-identity) except the one-line call site change in
`identity/agents/service/users.service.ts`.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+37
View File
@@ -0,0 +1,37 @@
# Quickstart: Validating Authentication Hardening
## Scenario 1 — password reset, end to end
1. `POST /auth/password-reset/request` with a real seeded account's email. **Expected**: `200`,
generic message; the server log shows a `password_reset_requested` line with a `resetUrl`
containing the real token.
2. Repeat with an email that doesn't exist. **Expected**: identical `200` response body to
step 1 — diff them to confirm.
3. `POST /auth/password-reset/consume` with the token from step 1's log and a policy-meeting new
password. **Expected**: `200`.
4. Repeat step 3 with the same token. **Expected**: rejected — the token is single-use.
5. `POST /auth/login` with the account's email and the new password from step 3. **Expected**:
`200`. Repeat with the account's old password. **Expected**: `401`.
## Scenario 2 — password strength enforced everywhere
1. `POST /admin/users` (as admin) with a password shorter than `PASSWORD_MIN_LENGTH`.
**Expected**: `400`, naming the actual minimum length.
2. `POST /auth/password-reset/consume` with a valid token and a too-short new password.
**Expected**: the same `400` rejection reason as step 1.
## Scenario 3 — login rate limiting
1. Submit `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` failed login attempts for the same email within
`LOGIN_RATE_LIMIT_WINDOW_SECONDS`. **Expected**: each returns `401` (the existing
identical-failure-response).
2. Submit one more attempt for that same email, still within the window — this time with the
*correct* password. **Expected**: `429`, not `200` — the rate limit is checked before
credentials (FR-007).
3. Submit an attempt for a *different* email within the same window. **Expected**: proceeds
normally (evaluated on its own credentials, not rate-limited).
## What "done" looks like
All three scenarios pass against a real Postgres/Redis, and `POST /admin/users`'s own existing
tests (010-identity-auth) still pass with the added password-strength check in place.
+83
View File
@@ -0,0 +1,83 @@
# Research: Authentication Hardening
## Decision: reset tokens live only in Redis, as a paired key shape, never in Postgres
- **Decision**: A random 32-byte token (`crypto.randomBytes(32).toString('hex')`) is generated
per request; only its SHA-256 hash is ever stored (the raw token is returned to the caller of
`requestPasswordReset` for the stub-delivery step to log, then discarded). Two Redis keys per
active token, both with the same TTL (the reset lifetime):
- `password-reset:token:<hash>``userId` (resolves a presented token at consume time)
- `password-reset:user:<userId>``hash` (lets issuing a new token find and delete the prior
one's `token:` key, invalidating it — FR-002)
- **Rationale**: Storing only the hash (never the raw token) mirrors this codebase's own
password-hashing discipline (010's `hashPassword`) and 002's encrypted-credential-at-rest
precedent — a Redis compromise alone shouldn't hand over usable reset tokens. The paired-key
shape gets "only one active token per account" (FR-002) without a database table or a list
scan; it's the same Redis-key-with-TTL pattern 010's own revocation denylist and 002's jti
replay-guard already established, not a new pattern for this codebase.
- **Alternatives considered**: A signed JWT with a `purpose: 'password-reset'` claim — rejected;
a JWT can't be "invalidated by issuing a new one" without also tracking issued tokens
somewhere (defeating the point of using a stateless token), so it would need the same Redis
bookkeeping anyway while adding JWT-parsing overhead for no benefit. A Postgres table — works,
but adds a migration and a cleanup/expiry job for data Redis's own TTL already expires for
free; rejected as unnecessary durability for a short-lived, non-audit-relevant credential.
## Decision: the "delivery" stub is a structured log line, not a fake email object
- **Decision**: `requestPasswordReset` logs `{ event: 'password_reset_requested', userId,
resetUrl }` at `info` level via the existing Pino logger — no new "mock email" abstraction,
no `EmailService` interface to later swap out.
- **Rationale**: Per the user's own explicit choice (stub delivery, not real email), the
simplest honest stub is exactly what a developer needs during this phase: the token, visible
in the same place every other structured log already goes. Building a fake `EmailService`
interface now, before any real provider is chosen, would be speculative abstraction for a
contract nobody has decided yet (which provider, which template).
- **Alternatives considered**: A dedicated `EmailService`/`NotificationService` interface with a
console/log implementation, swapped for a real one later — rejected as premature
infrastructure for a single call site; revisit when a real provider is actually chosen (a
separate, later decision per spec.md Assumptions).
## Decision: one shared `validatePasswordStrength`, minimum length only, `PASSWORD_MIN_LENGTH`-configured
- **Decision**: `identity/auth/mapper/password-policy.ts` exports
`validatePasswordStrength(password: string): void`, throwing `ValidationError` naming the
actual requirement (e.g. "Password must be at least N characters.") if `password.length <
env.PASSWORD_MIN_LENGTH`. Called from both `AuthService`'s new `resetPassword` and
`identity/agents`'s existing `UsersService.create`.
- **Rationale**: FR-005 requires one policy enforced identically everywhere a password is set —
a shared function is the only way to guarantee that rather than trusting two call sites to
stay in sync by convention. Minimum length only (no character-class rules) matches current
NIST guidance (length matters far more than forced complexity) and spec.md's own explicit
scope boundary.
- **Alternatives considered**: A zod `.refine()` embedded separately in each schema — rejected;
duplicates the rule text and the minimum-length constant at two call sites, exactly the drift
FR-005 exists to prevent.
## Decision: login rate-limit reuses the existing `checkRateLimit` helper, keyed by email
- **Decision**: `AuthService.login` calls
`checkRateLimit(`login:${email}`, env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
env.LOGIN_RATE_LIMIT_WINDOW_SECONDS)` as its very first step, before `repo.findByEmail` or
`verifyPassword` — throwing `RateLimitError` (already a distinct error/status from
`AuthenticationError`, per the existing `common/errors`) if exceeded.
- **Rationale**: `checkRateLimit` (`src/infrastructure/cache/rate-limiter.ts`) already exists,
already used by 002's own inbound-request rate limiting, and is exactly the fixed-window
Redis-`INCR` shape this feature needs — reusing it is the literal instruction 010's own
Assumptions gave ("beyond what 002's existing generic rate-limit infrastructure might already
cover"). Keying by the *submitted* email (not a resolved user id) means the limiter runs
identically whether or not the account exists, so it can't itself become a second
account-existence oracle.
- **Alternatives considered**: `@fastify/rate-limit`'s own global plugin (already registered,
1000 req/min) — insufficient on its own; that's a blunt per-IP-or-global HTTP-level limit, not
a per-account brute-force defense, and 010's own Assumptions already anticipated needing
something more targeted for login specifically.
## Decision: `POST /admin/users` gets the shared validator via a one-line call-site change
- **Decision**: `UsersService.create` calls `validatePasswordStrength(body.password)` before
hashing, right alongside its existing duplicate-email check — no schema change, no new route.
- **Rationale**: FR-005's "identically everywhere" requirement includes this pre-existing
010 endpoint, which today accepts any non-empty string as a password. Minimal, surgical fix
at the one call site that needed it.
- **Alternatives considered**: None — this is the only other password-setting call site in the
codebase (confirmed by searching for every `hashPassword(` call).
+194
View File
@@ -0,0 +1,194 @@
# Feature Specification: Authentication Hardening
**Feature Branch**: `013-auth-hardening`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "Phase 11 security hardening pass, first slice: password-reset
(self-service, with a stubbed email-delivery step logging the reset link instead of actually
emailing it), a password-strength policy applied wherever a password is set, and login
rate-limiting to slow down credential-stuffing/brute-force attempts against POST /auth/login.
MFA is a separate, larger follow-up feature, not this one's scope."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - A user resets a forgotten password (Priority: P1)
A user who has forgotten their password requests a reset; the system issues a single-use,
short-lived reset token and "delivers" it (this feature stubs delivery — see Assumptions — a
later feature wires up real email). The user submits the token with a new password and can log
in with it immediately afterward.
**Why this priority**: 010-identity-auth explicitly deferred this ("the smallest viable fix
today is an admin recreating the account") — this is the first real self-service fix for a
locked-out user, and the whole reason this feature exists.
**Independent Test**: Request a reset for a known account; retrieve the issued token (via the
stub's own log output, since there's no real inbox to check); consume it with a new password;
confirm login succeeds with the new password and fails with the old one.
**Acceptance Scenarios**:
1. **Given** an existing account, **When** its email requests a password reset, **Then** a
single-use reset token is issued and "delivered" via the stub — the response itself never
includes the token (it's not a client-visible value, matching a real email-delivery
contract).
2. **Given** an email that doesn't correspond to any account, **When** it requests a password
reset, **Then** the response is identical to Scenario 1's own success response — never
revealing whether the account exists (mirrors 010's own FR-002 philosophy).
3. **Given** a valid, unexpired reset token, **When** it's submitted with a new password meeting
the password-strength policy (User Story 2), **Then** the account's password is updated and
the token becomes unusable — a second consume attempt with the same token is rejected.
4. **Given** an expired or already-used reset token, **When** it's submitted, **Then** the
request is rejected with a clear, specific reason — never silently accepted.
5. **Given** a freshly-reset password, **When** the user logs in with it, **Then** login
succeeds; the old password no longer works.
---
### User Story 2 - Password strength is enforced wherever a password is set (Priority: P1)
Whenever a password is set — an admin creating a new staff account, or a user resetting their
own — the system enforces a minimum strength policy and rejects a weak password with a specific,
actionable reason.
**Why this priority**: 010-identity-auth's own admin-account-creation (`POST /admin/users`) and
this feature's own password-reset both accept a plaintext password with no strength check today
— the most basic hardening gap a "security hardening pass" exists to close first.
**Independent Test**: Attempt to create an account (or reset a password) with a password that
fails the policy (too short); confirm a clear rejection naming what's wrong. Repeat with a
policy-meeting password; confirm it succeeds.
**Acceptance Scenarios**:
1. **Given** the admin account-creation endpoint, **When** a password shorter than the
configured minimum length is submitted, **Then** the request is rejected with a message
naming the actual requirement, not a generic validation error.
2. **Given** the password-reset consume endpoint, **When** a policy-violating password is
submitted, **Then** it's rejected the same way — one policy, enforced identically everywhere
a password is ever set.
3. **Given** a password meeting the policy, **When** it's submitted to either endpoint,
**Then** it's accepted.
---
### User Story 3 - Login attempts are rate-limited (Priority: P1)
Repeated login attempts against the same account within a short window are throttled, slowing
down credential-stuffing and brute-force attacks without permanently locking out a legitimate
user who mistypes their password a few times.
**Why this priority**: `POST /auth/login` has no attempt limit today — an attacker can try
passwords against a known email address as fast as the network allows. This is the other
baseline hardening gap named explicitly in 010-identity-auth's own Assumptions.
**Independent Test**: Submit repeated failed login attempts for the same email within the
configured window; confirm attempts beyond the configured maximum are rejected with a
rate-limit response, distinct from an authentication failure; confirm a successful login for a
*different* account is unaffected.
**Acceptance Scenarios**:
1. **Given** the configured maximum login attempts per window has been reached for one email,
**When** another attempt is made for that same email within the window, **Then** it's
rejected with a clear rate-limit response (not the identical-failure-response body User
Story 1/010 uses for wrong credentials — a rate limit is a different, honestly-reported
condition).
2. **Given** the same exhausted window, **When** a login attempt is made for a *different*
email, **Then** it proceeds normally — the limit is per-account, not global.
3. **Given** the rate-limit window has elapsed, **When** a new attempt is made for the
previously-limited email, **Then** it's evaluated normally again.
---
### Edge Cases
- What happens if a user requests a password reset for the same account multiple times before
consuming the first token? Each request issues its own new token; consuming any valid,
unexpired one succeeds, and consuming one invalidates all of that account's other outstanding
reset tokens (never allowing two guesses to both later succeed independently).
- What happens if a reset token is consumed for an account that was deactivated after the token
was issued but before it was used? The reset is rejected — reactivating a deactivated account
is an admin action (010's own domain), not something a password-reset flow performs
incidentally.
- What happens to a rate-limited login attempt that would have actually succeeded (correct
password, but the account is rate-limited from prior failed attempts)? It's still rejected —
the rate limit is evaluated before credentials, exactly like a real brute-force defense must
be, not skipped for a lucky correct guess.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let a user request a password reset by email, always returning an
identical response regardless of whether the email corresponds to an existing account
(mirrors 010's FR-002).
- **FR-002**: The system MUST issue a single-use, time-limited reset token per request, and MUST
invalidate a token immediately upon use or upon a newer token being issued for the same
account.
- **FR-003**: The system MUST "deliver" the reset token via a clearly-labeled stub (server-side
log output) rather than a real email — this feature does not add email-sending infrastructure
(Assumptions).
- **FR-004**: The system MUST let a user consume a valid reset token with a new password,
updating the account's password hash and rejecting an invalid, expired, or already-used token
with a specific, distinguishable reason.
- **FR-005**: The system MUST enforce one configured password-strength policy (at minimum, a
minimum length) identically at every point a password is ever set — admin account creation
and password-reset consumption alike — never two different or duplicated policies.
- **FR-006**: The system MUST rate-limit `POST /auth/login` attempts per submitted email within
a configured window, rejecting attempts beyond the configured maximum with a response distinct
from a credentials failure.
- **FR-007**: The login rate limit MUST be evaluated before password verification, so a
rate-limited attempt is rejected regardless of whether the submitted password is actually
correct.
- **FR-008**: The system MUST NOT lock an account indefinitely — the rate limit is a rolling/
fixed window that clears on its own, not a manual-unlock-required lockout.
### Key Entities
- **Password Reset Token**: A single-use, time-limited credential tying one request to one
account, consumed exactly once to authorize a password change.
- **Password Policy**: The configured minimum-strength rule(s) applied identically at every
password-setting point in the system.
- **Login Attempt Counter**: A rolling/fixed-window count of failed login attempts per
submitted email, backing the rate limit.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of password-reset requests (existing or nonexistent account) receive an
identical response — 0% reveal account existence.
- **SC-002**: 100% of password-reset tokens are usable exactly once; a second consume attempt
with the same token fails 100% of the time.
- **SC-003**: 100% of passwords accepted by any password-setting endpoint meet the configured
policy; 0% of policy-violating passwords are ever stored.
- **SC-004**: An account subjected to more login attempts than the configured maximum within
the configured window is rejected on 100% of the excess attempts, regardless of whether the
submitted password was correct.
## Assumptions
- **Email delivery is stubbed, not real** — the reset token is logged server-side rather than
emailed, per explicit user decision; wiring up a real email provider is a separate, later
concern once that infrastructure choice is made.
- **MFA is out of scope** — a separate, larger follow-up feature; this pass only closes the two
gaps 010-identity-auth's own Assumptions named as "not this feature's job."
- **No account self-registration** — unchanged from 010; password reset only ever applies to an
existing account, never creates one.
- **The password-strength policy is a minimum-length rule, configurable, not a fixed hardcoded
value** (`docs/10-implementation-roadmap.md`'s own "never hardcode a placeholder value and
ship it as final" instruction) — the exact minimum is a `CONFIGURABLE` value with a reasonable
default, not a business-confirmed final number; additional complexity rules (character
classes, breached-password checks) are a possible future enhancement, not required here.
- **Rate limiting is per submitted email, not per IP** — the most direct defense against
credential-stuffing a specific known account; IP-based limiting is a possible future
enhancement layered on top, not required here.
- **Existing sessions are not force-revoked on password reset** — a reset invalidates the
password (and all other outstanding reset tokens for that account), but any already-issued,
unexpired login session remains valid until its own natural expiry (010's own 4-hour token
lifetime bounds this) rather than requiring a database check on every authenticated request
(010's own performance goal of a single Redis round trip per request, no DB read).
+151
View File
@@ -0,0 +1,151 @@
---
description: "Task list for 013-auth-hardening"
---
# Tasks: Authentication Hardening
**Input**: Design documents from `specs/013-auth-hardening/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/auth-hardening-contract.md](./contracts/auth-hardening-contract.md),
[quickstart.md](./quickstart.md)
**Organization**: Tasks are grouped by user story (US1 = P1 password reset, US2 = P1 password
policy, US3 = P1 login rate-limiting). US2 is a dependency US1's own consume endpoint needs, so
build it first despite the nominal priority tie; US3 is independent of both.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 Add `PASSWORD_MIN_LENGTH` (default `10`),
`PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` (default `30`),
`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` (default `5`), and `LOGIN_RATE_LIMIT_WINDOW_SECONDS`
(default `300`) to `src/config/env.ts`, exposed via `src/config/auth.ts`'s existing
`authConfig` object
**Checkpoint**: Config in place. Both user stories can now be built.
---
## Phase 2: User Story 2 - Password strength is enforced wherever a password is set (Priority: P1)
**Goal**: One shared validator, called from both the (not-yet-built) reset-consume endpoint and
the existing admin account-creation endpoint.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the
actual minimum named; policy-meeting password passes) in
`tests/unit/identity/password-policy.test.ts`
### Implementation for User Story 2
- [x] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s
`validatePasswordStrength(password): void`, throwing `ValidationError` (depends on T001)
- [x] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003)
- [x] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`,
before hashing (depends on T004)
- [x] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run
010-identity-auth's own existing `POST /admin/users` tests to confirm no regression
**Checkpoint**: No password shorter than the policy can ever be set via the admin endpoint.
---
## Phase 3: User Story 1 - A user resets a forgotten password (Priority: P1)
**Goal**: The full request → stub-delivery → consume → login-with-new-password flow.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via
the log stub; a nonexistent email gets an identical response; consume succeeds once and
fails the second time; login works with the new password and fails with the old) in
`tests/integration/password-reset-flow.test.ts` (depends on T006)
### Implementation for User Story 1
- [x] T008 [US1] Add `identity/auth/mapper/reset-token.ts``generateResetToken()` (raw token +
its SHA-256 hash) (depends on T001)
- [x] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId,
tokenHash, ttlSeconds)` (deletes any prior token for this user first, per data-model.md's
paired-key shape), `resolve(tokenHash)` (returns `userId` or null), `consume(tokenHash,
userId)` (deletes both keys) (depends on T008)
- [x] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public
result; internally, if the email resolves to an active account, issues a token and logs
the stub delivery event (structured log, research.md) (depends on T009)
- [x] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password
strength first (depends on T004), then resolves/consumes the token, 400s with a specific
reason if the token is missing/expired/used, hashes and stores the new password (depends
on T009, T004)
- [x] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume`
(both ungated — no session exists yet) in `identity/auth/controller/` + `routes/` +
`schema/`, registered from `src/api/routes.ts` (already registers `authRoutes` as a
whole, so no new registration call needed — depends on T010, T011)
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
**Checkpoint**: A locked-out user has a real, working self-service fix.
---
## Phase 4: User Story 3 - Login attempts are rate-limited (Priority: P1)
**Goal**: `POST /auth/login` throttles repeated attempts per submitted email, checked before any
credential verification.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before
`repo.findByEmail`/`verifyPassword` in `AuthService.login` (a fake repo/mapper that would
throw if called after an already-exceeded limit) in
`tests/unit/identity/login-rate-limit-ordering.test.ts`
- [x] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th
— even with the correct password — gets 429, a different email is unaffected) in
`tests/integration/login-rate-limit.test.ts` (depends on T001)
### Implementation for User Story 3
- [x] T016 [US3] In `AuthService.login`, call the existing
`checkRateLimit(`login:${email}`, authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds)` (from `@/infrastructure/cache`) as the very first
step, throwing `RateLimitError` if exceeded (depends on T001)
- [x] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
**Checkpoint**: All three user stories work independently and together — this feature's full
scope.
---
## Phase 5: Polish & Cross-Cutting Concerns
- [x] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T020 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
(particularly 010-identity-auth's own login/admin-account tests, now touched by this
feature's changes)
---
## Dependencies & Execution Order
- **Foundational (Phase 1)**: No dependencies — BLOCKS everything
- **User Story 2 (Phase 2)**: Depends on Foundational — BLOCKS User Story 1 (its consume
endpoint needs the shared validator)
- **User Story 1 (Phase 3)**: Depends on User Story 2
- **User Story 3 (Phase 4)**: Depends only on Foundational — independent of US1/US2, could be
built in parallel with either
- **Polish (Phase 5)**: Depends on all three
@@ -0,0 +1,100 @@
# Specification Quality Checklist: Full Observability
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This is `docs/10-implementation-roadmap.md`'s own Phase 11, second sub-area, per explicit user
direction (the first was 013-auth-hardening's security pass). The user explicitly chose "Full
observability" over "Reporting/analytics dashboards" as a distinct, separately-scoped sub-area
— FR-009 and several Assumptions exist specifically to keep this feature from drifting into
that adjacent, not-yet-started work.
- The three named infrastructure gaps (no per-request access log, a dead request-duration
histogram, a never-initialized tracer) and all eleven "key metrics to track" being completely
untracked today were confirmed by direct code inspection before writing this spec, not assumed.
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
required — every open question had a reasonable, documented default (see Assumptions).
## Implementation Notes (post-build)
- Registering a real `TracerProvider` alone was not sufficient to make span nesting work across
this feature's own async event-bus subscribers: without also registering an
`AsyncLocalStorageContextManager` (`@opentelemetry/context-async-hooks`, a third new
dependency beyond the two research.md originally named), the OpenTelemetry API's
`context.active()` is a no-op that does not propagate across `await` boundaries at all —
`orchestration.assignment` came out as its own unrelated root span/trace instead of nesting
under `ai.escalation`. Caught by the tracing integration test's own parent/child assertions
actually failing on the first implementation, not assumed correct from reading the SDK's docs.
- Installing `@opentelemetry/exporter-trace-otlp-http` alongside the already-pinned
`@opentelemetry/sdk-trace-base@^1.22.0` pulled two incompatible OpenTelemetry core/resources
major versions (1.x and 2.x) side by side. Resolved by bumping `sdk-trace-base` to `^2.11.0` to
match — this also happened to close a moderate DoS advisory in `@opentelemetry/core <2.8.0`
that the 1.x line was pinned to.
- T016 (graceful degradation under an unreachable OTLP endpoint) ended up as its own unit test
(`tests/unit/observability/tracing-graceful-degradation.test.ts`) rather than living in
`tracing.test.ts` as tasks.md originally described. Reason: `tracing.ts` always uses the
in-memory test exporter when `NODE_ENV=test`, so the integration suite's own running app can't
be pointed at a bad OTLP endpoint to exercise this. The unit test instead constructs a real
`BasicTracerProvider`/`BatchSpanProcessor`/`OTLPTraceExporter` pointed at a genuinely
unreachable address directly, and — importantly — verifies the SDK's _background_ export path
(what production actually exercises) never produces an unhandled rejection, rather than calling
`forceFlush()` directly, which is documented OpenTelemetry behavior that _does_ reject on a
failed export by design (the first version of this test asserted the wrong thing and failed
against real, correct SDK behavior — not a bug in this feature's own code).
- `sla.service.ts`'s pre-existing status-overwrite gap (a `'breached'` run's status silently
becomes `'completed'` if the ticket later resolves — see research.md §5) was worked around for
the metric's own correctness (read `run.status` before the overwrite) but left unfixed in the
underlying data, consistent with how 013-auth-hardening documented a pre-existing bug it found
without fixing it.
- Found and fixed one genuine cross-file test-isolation bug this feature's own new test caused:
`business-metrics.test.ts`'s "human resolution" case originally drove a ticket through a real
`HUMAN_ESCALATION` transition via `ticketsService.updateStatus`, which — same as any other
escalation in this codebase — triggers the real orchestration subscriber's default
`ROUND_ROBIN` auto-assignment against every agent in the shared throwaway database, including
other concurrently-running test files' own dedicated agents (reproduced deterministically
against `agent-ticket-queue.test.ts`). Fixed by driving the intermediate state-machine
transitions directly through `ticketsRepository.updateStatus` (no domain-event publish)
instead, reserving the real, event-publishing `ticketsService.updateStatus` call for only the
final `RESOLVED` transition the metric subscriber actually needs to observe.
- Separately, found (not caused by this feature — confirmed via `git checkout` to the clean
pre-014 commit and reproducing the identical failure) a pre-existing systemic collision risk in
ticket-code generation: `ticket-code.ts`'s `deriveProductCode` keeps only the first 4
alphabetic characters of `externalProductId`, so essentially every integration test file in
this codebase (nearly all of which name their test products `TEST_<SOMETHING>`) collapses to
the identical `"TEST"` code prefix. Running enough `TEST_*`-prefixed files concurrently (as
vitest does by default across worker threads/processes) makes independent files race for the
same `TEST-<year>-<sequence>` numbering space, occasionally exceeding
`tickets.service.ts`'s fixed `MAX_CODE_RETRIES = 5` and surfacing as a real `500`
(`Unique constraint failed on the fields: (code)`) instead of the retry silently absorbing it.
Confirmed independent of this feature (reproduces on `79bc2ef`, 013-auth-hardening's tip, with
none of this feature's code present) and left unfixed here — a ticket-code-generation
concurrency fix belongs to 003-ticketing's own module, out of scope for an observability
feature. Worth a dedicated future fix (e.g. a longer/hash-based product code, or a
database-level sequence rather than a `COUNT`-then-retry scheme).
@@ -0,0 +1,54 @@
# Contract: `/metrics` output
This feature adds no new HTTP endpoints — `GET /metrics` already exists and its response shape
(Prometheus text exposition format) is unchanged. This document is the contract for its
**content**: which metric series a consumer (Prometheus, or any scraper) can rely on after this
feature ships, replacing the usual per-endpoint request/response contract for a feature with no
new routes.
## Guarantees
1. Every metric already exposed today (the default `prom-client` process metrics, and
`supporthub_http_request_duration_seconds`) continues to appear, with the same name and label
set — FR-010. `supporthub_http_request_duration_seconds` gains real observations where today
it has none; its metric name/labels/type do not change.
2. Each of the eleven new series in [data-model.md](../data-model.md#metrics-prometheus-via-prom-client)
appears on `/metrics` from process start (a `Counter`/`Histogram` with zero observations
still exports its metadata — `# HELP`/`# TYPE` lines — even before its first increment; a
consumer's dashboard/alert config can reference it immediately without waiting for the first
event).
3. No metric name or label value is derived from unbounded, request-supplied input — every
label is one of: a fixed small enum (`outcome`, `resolved_by`, `matched`), a route pattern
(bounded by the number of registered routes), a tool name (bounded by the tool registry), an
error code or category ID (bounded by admin-configured product data, not raw user text).
This is a deliberate constraint, not an incidental one — unbounded label cardinality is a
well-known way to make a Prometheus deployment fall over, and every label chosen in
data-model.md was checked against this before being finalized.
4. `/health`, `/health/live`, `/health/ready` response shapes are unchanged (FR-010) — this
feature does not touch `health.service.ts` or `health.routes.ts`.
## Example (illustrative, not exhaustive)
```text
# HELP supporthub_http_request_duration_seconds Duration of HTTP requests in seconds
# TYPE supporthub_http_request_duration_seconds histogram
supporthub_http_request_duration_seconds_bucket{method="POST",route="/tickets",status_code="201",le="0.1"} 3
supporthub_http_request_duration_seconds_count{method="POST",route="/tickets",status_code="201"} 3
# HELP supporthub_ai_session_outcomes_total Count of AI support sessions by terminal outcome
# TYPE supporthub_ai_session_outcomes_total counter
supporthub_ai_session_outcomes_total{outcome="resolved"} 12
supporthub_ai_session_outcomes_total{outcome="escalated"} 4
# HELP supporthub_sla_run_outcomes_total Count of SLA runs by outcome
# TYPE supporthub_sla_run_outcomes_total counter
supporthub_sla_run_outcomes_total{outcome="met"} 9
supporthub_sla_run_outcomes_total{outcome="breached"} 1
```
## Verification
Integration tests assert against this contract by scraping `GET /metrics` (a real
`app.inject` call, real registry) before and after driving each metric's real underlying event
through the real API, parsing the specific series' value out of the text response and asserting
it moved by exactly the expected amount — never by mocking `prom-client` or the registry itself.
@@ -0,0 +1,72 @@
# Data Model: Full Observability
No Prisma schema changes — every entity here is in-process or exported to an external
observability sink, never persisted to Postgres.
## Request Context Store
`AsyncLocalStorage<RequestContextSnapshot>`, populated once per request in
`request-context.plugin.ts`'s existing `onRequest` hook (the same hook that already builds
`request.reqContext`), read by `logger.ts`'s Pino `mixin` function on every subsequent log call
made anywhere during that request's handling.
| Field | Type | Notes |
|---|---|---|
| `requestId` | `string` | Same value already assigned to `request.reqContext.requestId` |
| `correlationId` | `string` | Same value already assigned to `request.reqContext.correlationId` |
## Access Log Line (shape, not a stored entity)
Emitted once per completed request via the existing `logger` singleton from the new
`onResponse` hook.
| Field | Type | Notes |
|---|---|---|
| `method` | `string` | HTTP method |
| `route` | `string` | Parameterized route pattern (`request.routeOptions.url`), not the raw URL |
| `statusCode` | `number` | Response status |
| `durationMs` | `number` | `reply.elapsedTime` |
| `requestId` / `correlationId` | `string` | Via the mixin, same as every other line for this request |
| `event` | `string` | Fixed value `"http_request_completed"` — lets log queries filter to access-log lines specifically |
Log level: `info` for 2xx/3xx, `warn` for 4xx, `error` for 5xx — mirrors the existing
error-handler's own level choices (`app.ts`) so severity is consistent across both sources of
request-outcome logging.
## Metrics (Prometheus, via `prom-client`)
All registered in `infrastructure/observability/metrics.ts` on the existing default registry
(`metricsRegistry`, already exposed at `GET /metrics`), all prefixed `supporthub_` to match the
existing histogram and default-metrics prefix.
| Metric name | Type | Labels | Incremented/observed when |
|---|---|---|---|
| `supporthub_http_request_duration_seconds` | Histogram *(existing, now actually observed)* | `method`, `route`, `status_code` | Every completed HTTP request |
| `supporthub_ai_session_outcomes_total` | Counter | `outcome` (`resolved` \| `escalated`) | An AI support session reaches a terminal `resolved`/`escalated` status |
| `supporthub_ticket_resolutions_total` | Counter | `resolved_by` (`ai` \| `human`) | A ticket reaches `RESOLVED`, labeled from the ticket's `Resolution.resolvedBy` |
| `supporthub_ticket_resolution_duration_seconds` | Histogram | — | A ticket reaches `RESOLVED` — observes `resolvedAt - ticket.createdAt` |
| `supporthub_ticket_first_response_duration_seconds` | Histogram | — | The first `AGENT_MESSAGE` is posted on a ticket — observes `firstResponseAt - ticket.createdAt` |
| `supporthub_sla_run_outcomes_total` | Counter | `outcome` (`met` \| `breached`) | An SLA run completes on time (`met`) or is flagged by the breach sweep (`breached`) |
| `supporthub_escalations_total` | Counter | `reason` | An `ESCALATION_TRIGGERED` domain event fires (already published unconditionally today) |
| `supporthub_problems_created_total` | Counter | `category_id` (or `uncategorized`) | A `Problem` row is created (at ticket-intake time) |
| `supporthub_known_error_lookups_total` | Counter | `code` | A valid error code's known issues are looked up |
| `supporthub_knowledge_retrieval_outcomes_total` | Counter | `matched` (`true` \| `false`) | The AI's `searchProductKnowledge` tool call returns zero vs. one-or-more results |
| `supporthub_tool_invocations_total` | Counter | `tool`, `outcome` (`success` \| `failed`) | Every AI tool-call result, any tool |
Deliberately **not** separate metrics (per spec.md's Assumptions): "recurring problems" and
"most common errors" are read directly off `supporthub_problems_created_total` and
`supporthub_known_error_lookups_total` respectively via a monitoring stack's own `topk`/`rate`
query — no additional "top N" metric or logic is computed by this application.
## Traces / Spans (exported, not persisted)
| Span | Parent | Attributes | Created in |
|---|---|---|---|
| `ticket.create` | (root) | `ticket.id`, `product.externalProductId` | `ticketing/tickets/service/tickets.service.ts` |
| `ai.escalation` | `ticket.create` (if within the same request) or its own root (async paths) | `ticket.id`, `session.id` | `ai-support/sessions/service/session.service.ts`, around the escalation branch |
| `orchestration.assignment` | `ai.escalation` (via the `TICKET_UPDATED`/`HUMAN_ESCALATION` subscriber) | `ticket.id`, `strategy` | `orchestration/orchestration` + `orchestration/assignments`, wrapping the existing `handleHumanEscalation` call |
Span context propagation across the domain-event bus relies on the OpenTelemetry Context API's
own async-local propagation — since `eventBus.publish(...)` is `await`ed synchronously within
the same call chain (confirmed in `tickets.service.ts`/`escalation.service.ts`), no manual
context-carrying payload field is needed.
+152
View File
@@ -0,0 +1,152 @@
# Implementation Plan: Full Observability
**Branch**: `014-full-observability` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/014-full-observability/spec.md`
## Summary
Wires three already-scaffolded-but-inert observability primitives into something real: a
per-request structured access log (none exists today — Fastify's own request logging is fully
disabled), the existing-but-never-observed request-duration histogram, and a real OpenTelemetry
tracer provider behind the existing-but-never-called `getTracer()` helper. Adds eleven live
Prometheus counters/histograms for the business-health metrics `docs/09-testing-observability-
cicd.md` names, each wired at one existing choke point per metric (an event-bus subscriber where
one already exists for the transition, a single already-existing method otherwise) rather than
scattered across every call site. No new endpoints, no schema changes, no `supporthub-web` work
— see research.md for the exact hook point chosen for each of the fourteen instrumentation
targets (3 infra + 11 named metrics) and why.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
**Primary Dependencies**: New — `@opentelemetry/exporter-trace-otlp-http` (OTLP/HTTP span
export), `@opentelemetry/resources` (service-name resource attribute). Reused, already
installed — `@opentelemetry/api`, `@opentelemetry/sdk-trace-base` (provider, processors, and
both the console and in-memory exporters used here all come from this one package), `prom-client`,
`pino`. Reused Node built-in — `async_hooks`' `AsyncLocalStorage`.
**Storage**: No schema change. All new state is either in-process (Prometheus metric registry,
the ALS request-context store, the tracer provider) or exported to wherever tracing is
configured to send it — no new Postgres/Redis reads or writes beyond a handful of existing-table
lookups already needed to label a metric correctly (e.g. `resolutionRepository.findByTicketId`
to distinguish AI vs. human resolution).
**Testing**: Vitest — unit tests for the ALS-based logger mixin (a log call inside a request
context carries requestId/correlationId; one outside carries neither) and for the
SLA-compliance metric's "don't double-count an already-breached run as met" guard. Integration
tests against real Postgres/Redis for: the access-log line's presence/shape (captured via a
`logger.info` spy, same technique as 013's password-reset test), `/metrics` scraped before/after
real traffic showing the duration histogram and each of the eleven business counters/histograms
change by the expected amount when their real underlying event is driven through the real API,
and a real multi-span trace (read back from the test-environment `InMemorySpanExporter`) for the
two named cross-module paths.
**Target Platform**: Same Fastify modular monolith. Modifies
`infrastructure/observability/*` (logger, metrics, tracing, a new request-context store) and
`plugins/request-context.plugin.ts` (the new `onResponse` hook); adds small, single-call-site
instrumentation lines inside `ai-support/sessions`, `ai-support/knowledge`, `ai-support/tools`,
`ticketing/tickets`, `ticketing/messages`, `orchestration/sla`, and a handful of new subscribers
in `src/events/handlers/index.ts`. No module gains a new public export surface beyond what
`getTracer()` already exposed.
**Project Type**: Backend service — single project.
**Performance Goals**: The `onResponse` hook adds one Pino log call and one histogram `.observe`
per request — both already-paid-for infrastructure (the logger and the metric object already
exist), no new I/O on the request hot path. Trace export runs via `BatchSpanProcessor` (out of
the request's own async chain) so span export latency never adds to response time. Metric
increments at the eleven business hook points are in-memory counter operations, not database
writes — the handful of read lookups needed for correct labeling (e.g. the resolution lookup for
#3/#4) are single-row, already-indexed reads on tables these modules already query routinely.
**Constraints**: FR-007 — tracing must degrade gracefully; the API must start and serve traffic
normally with no collector configured or reachable. FR-009 — no new human-facing endpoint,
dashboard, or aggregation logic; every FR-008 metric is a raw counter/histogram for an external
scraper, full stop. FR-010 — `/health*` and the existing histogram's shape on `/metrics` must
not change for any existing consumer (only new metrics are added, nothing existing is renamed or
removed).
**Scale/Scope**: Zero new routes. Three modified observability infrastructure files plus one new
request-context store. Eleven new metric definitions plus their one-choke-point instrumentation
call each. Two new dependencies. No schema migration, no new module.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Not applicable — no identity/access surface touched. | PASS — N/A |
| II. Configuration Over Hardcoding | The tracing exporter destination (`OTEL_EXPORTER_OTLP_ENDPOINT`) is env-driven, not hardcoded per environment; no business policy value is introduced by this feature (no SLA/routing/threshold numbers). | PASS |
| III. Layered Architecture With Enforced Module Boundaries | No new module; existing module boundaries unchanged (each metric's instrumentation call lives inside the module that already owns the event, per research.md's per-metric table). The two repository-layer instrumentation calls (#1/#2, AI session status) are a deliberate, disclosed exception — see research.md §5's justification: observability calls are already a cross-cutting concern used from any layer in this codebase (e.g. `logger.error` inside `tool-executor.ts`), not the kind of business-logic leakage this principle targets. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI decision logic changed, only observation of its outcomes. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
| VI. Durable Audit & History | Directly implements this principle's own stated requirement — "every log line MUST carry a request ID/correlation ID" is written in the constitution today but not actually true until this feature (FR-001/FR-002). | PASS — this feature closes a pre-existing constitutional gap |
| VII. Concurrency-Safe, Durable Job Handling | The first-response-time metric (#5) has a benign, disclosed race (two concurrent first `AGENT_MESSAGE`s could both read "zero prior messages" and both observe) — acceptable because it is a best-effort observability metric, not the assignment/SLA correctness this principle is protecting; no persisted state or business decision depends on it. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — no model change. | PASS — N/A |
| Technology & Platform Constraints | Two new dependencies (both OpenTelemetry, both already in the stack's declared technology list — "OpenAPI" aside, tracing itself was always part of the stated stack via the pre-existing `@opentelemetry/api`/`sdk-trace-base` dependencies) — no new infrastructure category introduced. | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/014-full-observability/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── metrics-contract.md
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-api/
├── src/
│ ├── infrastructure/
│ │ └── observability/
│ │ ├── logger.ts # MODIFIED — mixin reads the new ALS store
│ │ ├── metrics.ts # MODIFIED — 11 new Counter/Histogram definitions
│ │ ├── tracing.ts # MODIFIED — real provider init, exporter selection
│ │ └── request-context.store.ts # NEW — AsyncLocalStorage<RequestContext>
│ ├── plugins/
│ │ └── request-context.plugin.ts # MODIFIED — onResponse access-log + histogram hook,
│ │ onRequest now runs the rest of the request
│ │ inside the ALS store
│ ├── events/
│ │ └── handlers/index.ts # MODIFIED — 3 new subscribers (human-resolution +
│ │ resolution-time on TICKET_UPDATED/RESOLVED,
│ │ escalation-rate on ESCALATION_TRIGGERED)
│ └── modules/
│ ├── ai-support/
│ │ ├── sessions/repository/session.repository.ts # MODIFIED — AI resolution/escalation
│ │ ├── knowledge/service/error-codes.service.ts # MODIFIED — most-common-errors
│ │ └── tools/service/tools.service.ts # MODIFIED — tool-failure + knowledge-
│ │ effectiveness
│ ├── ticketing/
│ │ ├── tickets/service/tickets.service.ts # MODIFIED — recurring-problems, plus
│ │ │ the two named trace spans
│ │ └── messages/service/messages.service.ts # MODIFIED — first-response-time
│ └── orchestration/
│ └── sla/service/sla.service.ts # MODIFIED — SLA-compliance
└── tests/
├── unit/observability/ # ALS mixin, SLA-compliance double-count guard
└── integration/observability/ # access log, /metrics scrape assertions (11 metrics
+ duration histogram), cross-module trace
```
**Structure Decision**: Single project, no new module. All changes are surgical additions inside
`infrastructure/observability` (the module that already owns this concern) plus one small,
justified instrumentation line inside each of six existing business modules, following the
per-metric hook points research.md already identified against the real, current code.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,70 @@
# Quickstart: Full Observability
Manual verification steps for each user story, against a running instance backed by real
Postgres/Redis (the throwaway Docker containers already used throughout this project's test
suite work equally well for a manual run).
## Scenario 1 — Per-request access log (User Story 1)
1. Start the API. Send any request (e.g. `GET /health`).
2. **Expected**: exactly one log line appears with `event: "http_request_completed"`, the
request's method, route, status code, and a `requestId`.
3. Send a request to a route that triggers additional internal logging (e.g. a login attempt).
4. **Expected**: every log line produced while handling that request — the access-log line and
any domain log lines — carries the same `requestId`/`correlationId`.
5. Send a request to a route that doesn't exist.
6. **Expected**: a 404 access-log line is still emitted (not silently dropped).
## Scenario 2 — Live request-health metrics (User Story 2)
1. Send a mix of successful and failing requests (e.g. a valid login, then three wrong-password
logins).
2. Scrape `GET /metrics`.
3. **Expected**: `supporthub_http_request_duration_seconds_count` has observations labeled
`route="/auth/login"` with both `status_code="200"` and `status_code="401"` present, letting
an operator compute the error rate for that route from these two series alone.
## Scenario 3 — Cross-module trace (User Story 3)
1. With the API running in a mode where tracing exports to the console (no
`OTEL_EXPORTER_OTLP_ENDPOINT` configured), drive a request that escalates a ticket to a human
and triggers automatic orchestration/assignment.
2. **Expected**: console output shows a `ticket.create`-or-`ai.escalation` root span and an
`orchestration.assignment` child span sharing the same trace ID, with the child's start time
at or after the parent's.
3. Stop the (nonexistent) collector / leave `OTEL_EXPORTER_OTLP_ENDPOINT` pointed at an
unreachable address.
4. **Expected**: the API still starts and serves requests normally; only a logged export-failure
warning appears, nothing surfaces to any HTTP response.
## Scenario 4 — Business-health metrics (User Story 4)
For each metric, scrape `/metrics`, note the current value, drive the real event, scrape again,
and confirm the expected series moved by exactly one (or by the expected duration observation):
1. Complete an AI session without escalating → `supporthub_ai_session_outcomes_total{outcome="resolved"}` +1.
2. Complete an AI session that escalates, then have a human agent resolve the ticket →
`supporthub_ai_session_outcomes_total{outcome="escalated"}` +1, and once resolved,
`supporthub_ticket_resolutions_total{resolved_by="human"}` +1.
3. Resolve any ticket → `supporthub_ticket_resolution_duration_seconds` gains one new observation.
4. Post the first agent reply on a ticket → `supporthub_ticket_first_response_duration_seconds`
gains one new observation.
5. Let an SLA run complete on time, and separately let one breach (via the existing breach-sweep
test helper) → `supporthub_sla_run_outcomes_total{outcome="met"}` and
`{outcome="breached"}` each +1 respectively.
6. Trigger an escalation → `supporthub_escalations_total{reason="<the actual reason>"}` +1.
7. Create a ticket for a categorized problem →
`supporthub_problems_created_total{category_id="<id>"}` +1.
8. Look up a valid error code's known issues →
`supporthub_known_error_lookups_total{code="<code>"}` +1.
9. Have the AI's `searchProductKnowledge` tool return zero results, then results →
`supporthub_knowledge_retrieval_outcomes_total{matched="false"}` then `{matched="true"}`,
each +1 in turn.
10. Have any AI tool invocation fail → `supporthub_tool_invocations_total{tool="<name>",
outcome="failed"}` +1.
## What "done" looks like
All four scenarios pass against a real Postgres/Redis, `/health*` and the existing
`supporthub_http_request_duration_seconds` metric's shape are unchanged for any existing
consumer, and the API starts and serves traffic normally with no tracing collector configured.
+172
View File
@@ -0,0 +1,172 @@
# Research: Full Observability
All decisions below were made against the actual current code (grep/read), not assumption —
several existing pieces (the histogram, `getTracer()`) are dead scaffolding that looked complete
from their exports alone but do nothing today.
## 1. Per-request access log
**Decision**: Add an `onResponse` hook (Fastify fires this for every completed response,
including 404s and early replies from other hooks like the rate limiter, satisfying the FR-001
edge case) that logs one line via the existing `logger` singleton: `{method, route, statusCode,
durationMs, requestId, correlationId}`. `route` uses `request.routeOptions.url` (the
parameterized pattern, e.g. `/tickets/:id`) rather than `request.url`, to keep label/log
cardinality bounded — the raw URL contains IDs. `reply.elapsedTime` (Fastify's own built-in
per-request timer) supplies duration with no manual `Date.now()` bookkeeping.
**Why not Fastify's built-in request logger**: `app.ts` deliberately sets `logger: false` and
routes all logging through the shared Pino `logger` singleton (see its own comment: "Managed
centrally via Pino logger instance"). Re-enabling Fastify's built-in logger would mean two
independent logging paths with two different configurations; a hook that calls the existing
singleton keeps one path.
**Where**: `request-context.plugin.ts` already owns the per-request lifecycle (it's the one
place with an `onRequest` hook establishing `reqContext`) — its `onResponse` counterpart is
added in the same file, not a new plugin, so request-lifecycle logging concerns stay together.
## 2. Attaching request ID/correlation ID to every log line (FR-002)
**Decision**: `AsyncLocalStorage<RequestContext>`, populated in the same `onRequest` hook that
already builds `reqContext`, combined with Pino's `mixin` option (a function called for every
log line, merging its return value into that line) reading from the store. This makes every
call through the existing shared `logger` singleton automatically carry `requestId`/
`correlationId` with **zero changes to any existing call site** — dozens of `logger.info/warn/
error(...)` calls across every module already pass ad hoc fields but not always `requestId`
consistently.
**Why not `request.log`**: Fastify's per-request child logger (`request.log`) is the standard
Fastify idiom for this, but it would require passing `request` (or `request.log`) into every
service/repository/mapper that currently imports the plain `logger` singleton directly — a
sweeping, high-risk refactor across nearly every module for a feature whose whole point is
*reducing* risk. The ALS+mixin approach reaches the same outcome (every log line correlated)
without touching a single existing call site.
**Merge order**: Pino applies `mixin()`'s fields before merging the call's own object, so an
explicit `requestId` passed at a call site (several already do this manually, e.g.
`app.ts`'s error handler) still wins — no behavior change for those call sites, just now
redundant (harmless).
## 3. Request-duration histogram + request-count
**Decision**: `httpRequestDurationHistogram.observe({method, route, status_code},
reply.elapsedTime / 1000)` in the same `onResponse` hook. Prometheus histograms automatically
expose a `<name>_count` and `<name>_sum` per label combination — FR-004's "compute error rate
per route/status" is satisfied by that built-in output; no separate counter metric is added, to
avoid two metrics tracking overlapping information.
## 4. Distributed tracing
**Decision**: Initialize a real `BasicTracerProvider` (from the already-installed
`@opentelemetry/sdk-trace-base` — no new dependency for the SDK itself) at process start, with
`trace.setGlobalTracerProvider(...)` so the existing, previously-inert `getTracer()` helper
starts returning a working tracer with zero change to its own signature. Exporter selection is
config-driven (`OTEL_EXPORTER_OTLP_ENDPOINT`, following the OpenTelemetry project's own standard
env var name rather than inventing a new one):
- Set → `OTLPTraceExporter` (new dependency: `@opentelemetry/exporter-trace-otlp-http`, the
lighter HTTP/JSON variant, avoiding the gRPC exporter's heavier dependency footprint), wrapped
in a `BatchSpanProcessor`.
- Unset (local dev, and any environment that hasn't configured a collector) →
`ConsoleSpanExporter` (part of `sdk-trace-base`, zero extra dependency) wrapped in a
`SimpleSpanProcessor`, so spans are visible immediately without standing up a collector.
- Test environment → `InMemorySpanExporter` (also part of `sdk-trace-base`, built specifically
for tests) wrapped in a `SimpleSpanProcessor` — this lets integration tests assert on real,
actually-exported span data (names, parent/child nesting, attributes) with a real
`TracerProvider` doing real work, the only substitution is *where the spans end up*, the same
"real infrastructure, substitute only the destination" pattern already used for Pino's
transport (`pino-pretty` in development, plain JSON otherwise).
**New dependencies**: `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/resources` (for
a `service.name: supporthub-api` resource attribute — without it, every span is anonymous in
whatever backend receives them).
**Graceful degradation (FR-007)**: `BatchSpanProcessor`'s own export failures are caught and
logged by the OpenTelemetry SDK internally (it never throws into application code); nothing in
this feature needs to add its own try/catch around span creation for this to hold, but the SDK's
internal diagnostic logger is wired to `logger.warn` (via `diag.setLogger`) so export failures
are visible in this project's own log stream rather than swallowed silently.
**Where spans are added (FR-006)**: two entry points, wrapping already-existing method calls
rather than restructuring them:
- `ai-support/sessions/service/session.service.ts`'s escalation path — a span around the call
that ultimately triggers `orchestrationService.handleHumanEscalation` (via the
`TICKET_UPDATED``HUMAN_ESCALATION` domain-event subscriber in
`src/events/handlers/index.ts`), and a child span inside
`orchestration/orchestration`'s and `orchestration/assignments`'s own handling — showing the
AI-diagnosis → escalation → assignment path as one connected trace.
- `ticketing/tickets/service/tickets.service.ts`'s ticket-creation method — a root span for
ticket intake, with the domain-event-driven downstream reactions (SLA-run creation, etc.)
as child spans, per FR-006's second named path.
Trace context propagates across the event-bus's synchronous `await eventBus.publish(...)` calls
for free (both publisher and subscriber run within the same Node async-context chain the OTel
context API rides on — no manual context passing needed, since nothing here crosses a process/
queue boundary; BullMQ jobs are explicitly out of scope for this feature's two named paths).
## 5. The eleven named business-health metrics (FR-008) — instrumentation points
Each is a `prom-client` `Counter` or `Histogram`, registered once in
`infrastructure/observability/metrics.ts` alongside the existing histogram, and incremented/
observed at one single already-existing choke point per metric — chosen specifically to avoid
scattering an instrumentation call across every one of a metric's several call sites.
| # | Metric | Type | Hook point (file : method) | Label(s) |
|---|---|---|---|---|
| 1 | AI resolution rate | Counter | `ai-support/sessions/repository/session.repository.ts` : `updateStatus`, when `status === 'resolved'` | — |
| 2 | AI escalation rate | Counter | same method, when `status === 'escalated'` | — |
| 3 | Human resolution rate | Counter | new `TICKET_UPDATED` subscriber (`events/handlers/index.ts`) on `newStatus === 'RESOLVED'`, looking up `resolutionRepository.findByTicketId` for `resolvedBy` | `resolvedBy !== 'ai'` only |
| 4 | Average resolution time | Histogram | same subscriber — observes `resolvedAt - ticket.createdAt` | — |
| 5 | First response time | Histogram | `ticketing/messages/service/messages.service.ts` : `post`, when `type === 'AGENT_MESSAGE'` and no prior `AGENT_MESSAGE` exists for the ticket | — |
| 6 | SLA compliance | Counter | `orchestration/sla/service/sla.service.ts` : `complete` (outcome `met`, only if the run wasn't already `breached`) and `runBreachDetectionSweep` (outcome `breached`) | `outcome` |
| 7 | Escalation rate | Counter | new `ESCALATION_TRIGGERED` subscriber (`events/handlers/index.ts`) — this event is already published unconditionally on every escalation (`escalation.service.ts`) but "for audit, not for logic" (its own comment) and has zero subscribers today | `reason` |
| 8 | Recurring problems | Counter | `ticketing/tickets/service/tickets.service.ts` — the ticket-creation method's existing `problemsRepo.create(...)` call | `categoryId` (or `uncategorized`) |
| 9 | Most common errors | Counter | `ai-support/knowledge/service/error-codes.service.ts` : `findKnownIssuesByErrorCode`, after a valid code is confirmed to exist | `code` |
| 10 | Knowledge effectiveness | Counter | `ai-support/tools/service/tools.service.ts`'s single `executeTool(...)` call site, when `block.name === 'searchProductKnowledge'` | `matched` (results non-empty vs empty) |
| 11 | Tool failure rate | Counter | same call site, every tool invocation | `tool`, `outcome` |
**Why the event bus for #3, #4, #7 instead of editing `resolutions.service.ts`/
`escalation.service.ts` directly**: those two modules' domain events (`TICKET_UPDATED` with
`newStatus`, and `ESCALATION_TRIGGERED`) are already published unconditionally for every
relevant transition (confirmed by reading `tickets.service.ts` and `escalation.service.ts`
directly) specifically so that a new concern reacting to "a ticket resolved" or "an escalation
happened" never needs to modify the module that owns the transition — the exact precedent
`src/events/handlers/index.ts`'s existing four subscribers already establish for 005/007/008.
Metrics is exactly this kind of concern.
**Why the repository layer for #1/#2 instead of the event bus**: AI-session resolved/escalated
is not currently published as a domain event at all (only ticket-level and escalation-level
events exist) and `session.service.ts` calls `this.sessions.updateStatus(...)` from ten
different branches — adding a domain-event publish there to reuse the event-bus pattern would
mean either introducing a new event type used by exactly one subscriber (this feature) or
touching all ten call sites to route through a new shared wrapper. Instrumenting the one
repository method both approaches would have to fire through instead is the minimal, lowest-risk
option. This mirrors how `logger` calls already appear directly inside repository/service code
throughout this codebase (e.g. `tool-executor.ts`'s `logger.error`) — observability calls are
already treated as a cross-cutting concern usable from any layer, not something Constitution
Principle III's "repository is Prisma-only" rule was written to police (that rule targets
business-logic leakage and direct Prisma access from the wrong layer, not a metrics increment
alongside an existing Prisma call).
**A pre-existing correctness note surfaced while researching #6**: `sla.service.ts`'s
`complete()` only skips its update when the run is *already* `'completed'` — not when it is
`'breached'` — so a run that breached and then later resolved would have its `status`
overwritten from `'breached'` back to `'completed'` in the database, silently losing the breach
record. This is a pre-existing 008/012 behavior, not something this feature changes (the SLA
run's persisted status is out of scope for an observability feature) — the metric itself reads
`run.status` *before* calling `complete()`'s own update, so the metric is accurate (correctly
counted as `breached`, never double-counted as `met`) regardless of this separate, pre-existing
data-quality gap. Documented in this feature's own checklist Notes as a discovered issue for a
future fix, the same way 013-auth-hardening documented the `orchestration-strategies.test.ts`
bug it found without fixing it.
## 6. Test strategy for the eleven metrics and tracing
**Decision**: Integration tests scrape the real `/metrics` endpoint's text output (a real
`app.inject({method: 'GET', url: '/metrics'})` call, no mocking) before and after driving the
real underlying event through the real API (create a ticket, resolve an AI session, trigger an
escalation, etc. — exactly as every prior feature's integration suite already does against real
Postgres/Redis), asserting the specific metric line's value increased by the expected amount.
Tracing is verified by reading back spans from the `InMemorySpanExporter` (test-environment
exporter, per §4) after a real cross-module request, asserting span names and parent/child
`spanId`/`parentSpanId` relationships — a real trace, produced by a real `TracerProvider`, just
captured in memory instead of shipped to a collector.
+131
View File
@@ -0,0 +1,131 @@
# Feature Specification: Full Observability
**Feature Branch**: `014-full-observability`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "Full observability: wire the already-scaffolded logging, metrics, and tracing infrastructure into an actually working end-to-end observability layer — structured per-request access logs, a working request-duration histogram, real OpenTelemetry tracing with exported spans across critical request paths, and live Prometheus counters for the key operational metrics named in docs/09-testing-observability-cicd.md."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Trace one request end to end from its logs (Priority: P1)
An engineer investigating a production incident (a customer's ticket got stuck, an API call failed) needs to reconstruct exactly what the system did for that one request: which route was hit, how long it took, what it returned, and — because a support case touches many internal calls (AI session → tool calls → escalation → assignment → SLA events) — which of those internal log lines belong to the same originating request.
**Why this priority**: Without a per-request access log, there is currently no record that a given request even happened unless it errored. This is the minimum viable observability floor everything else builds on.
**Independent Test**: Can be fully tested by sending a request to any route and confirming exactly one structured access-log line is emitted for it, carrying the same request ID as any other log line produced while handling that request.
**Acceptance Scenarios**:
1. **Given** the API is running, **When** any HTTP request completes (success or failure), **Then** exactly one structured log line is emitted recording its method, route, status code, and duration.
2. **Given** a request carries an inbound correlation ID header (or one is generated for it), **When** that request triggers further log lines anywhere in the codebase during its handling, **Then** every one of those log lines carries the same request ID and correlation ID as the access-log line for that request.
3. **Given** a request fails with an unhandled error, **When** the access log line is emitted, **Then** it is distinguishable (by log level) from a successful request without needing to duplicate the existing error-handler logging.
---
### User Story 2 - See live request-health metrics (Priority: P1)
An engineer wants to know, right now, whether the API is healthy under current traffic — request volume, latency distribution, and error rate by route — without needing to grep logs.
**Why this priority**: A request-duration metric already exists in code but is never recorded, so `/metrics` currently reports nothing useful about request health. This is the second half of the observability floor (logs tell you what happened to one request; metrics tell you the shape of all of them).
**Independent Test**: Can be fully tested by sending a mix of successful and failing requests, then scraping `/metrics` and confirming the request-duration histogram and a request-count-by-status metric both reflect that traffic.
**Acceptance Scenarios**:
1. **Given** the API has served requests since it started, **When** `/metrics` is scraped, **Then** the request-duration histogram has observations labeled by method, route, and status code matching that traffic.
2. **Given** some requests succeeded and others returned 4xx/5xx, **When** `/metrics` is scraped, **Then** a request-count metric lets an operator compute error rate by route and status class.
---
### User Story 3 - Trace a single incident's cross-module path (Priority: P2)
An engineer debugging why a specific ticket took an unexpectedly long or unexpected path (e.g., AI failed to resolve it, escalation didn't fire when expected) wants to see the causal chain of operations across modules for that one ticket — not just isolated log lines, but a connected trace showing how long each step took relative to the others.
**Why this priority**: Distributed tracing infrastructure already exists in the dependency list and a `getTracer()` helper is exported, but no tracer provider is ever initialized and no code ever calls it — today it silently does nothing. This is more valuable than plain logs for understanding *why* a multi-step flow behaved the way it did, but the system is usable without it (User Stories 1-2 already restore basic visibility), so it is P2.
**Independent Test**: Can be fully tested by triggering a request that flows through at least two instrumented modules (e.g., an AI escalation that results in orchestration/assignment) and confirming a trace is produced whose spans are parented correctly and whose combined duration accounts for the modules involved.
**Acceptance Scenarios**:
1. **Given** tracing is enabled, **When** the API starts, **Then** a real tracer provider is active (not the OpenTelemetry no-op default) and spans created via the existing `getTracer()` helper are actually exported somewhere inspectable.
2. **Given** a request flows through multiple instrumented operations (e.g., AI diagnosis triggers an escalation which triggers orchestration/assignment), **When** that request completes, **Then** the resulting trace shows each operation as a distinct, correctly-nested span under one root.
3. **Given** tracing is not configured with an external collector in a given environment, **When** the API starts, **Then** it still starts successfully (tracing degrades gracefully, it never blocks startup or request handling).
---
### User Story 4 - See the business-health metrics this project committed to tracking (Priority: P2)
An engineer or team lead wants live visibility (via the same `/metrics` endpoint, for consumption by whatever monitoring stack is deployed) into the operational health metrics this project's own design doc names as important: AI resolution rate, AI escalation rate, human resolution rate, average resolution time, first response time, SLA compliance, escalation rate, recurring problems, most common errors, knowledge effectiveness, and tool failure rate.
**Why this priority**: These are real, currently-invisible gaps — none of them are tracked anywhere today, live or otherwise. They are P2 (not P1) because they instrument business outcomes that already have a durable system of record (the ticket/problem/SLA/escalation tables) — a missing counter is a visibility gap, not a data-loss risk, unlike User Stories 1-2's request-level blind spot.
**Why this scope boundary**: This story is about each metric *existing and being live-updated correctly* at the point the underlying event occurs, exposed as raw counters/histograms on `/metrics` for an external monitoring stack to graph and alert on. It explicitly does NOT include building any dashboard, chart, or human-facing report — that is a separate, not-yet-started project phase (reporting/analytics dashboards).
**Independent Test**: Can be fully tested, metric by metric, by driving the real underlying event (resolve a ticket via AI, resolve one via a human agent, breach an SLA, trigger an escalation, log a known error, etc.) against a running instance and confirming the corresponding value on `/metrics` changed by exactly the expected amount.
**Acceptance Scenarios**:
1. **Given** an AI session resolves a ticket without escalating, **When** `/metrics` is scraped, **Then** the AI-resolution counter has incremented and the AI-escalation counter has not.
2. **Given** an AI session escalates to a human and that human later resolves the ticket, **When** `/metrics` is scraped, **Then** the AI-escalation counter and the human-resolution counter have both incremented.
3. **Given** a ticket is resolved, **When** `/metrics` is scraped, **Then** the resolution-time histogram has a new observation reflecting that ticket's actual open-to-resolved duration.
4. **Given** an agent sends the first reply on a ticket, **When** `/metrics` is scraped, **Then** the first-response-time histogram has a new observation.
5. **Given** an SLA run resolves as either met or breached, **When** `/metrics` is scraped, **Then** the SLA-compliance counter reflects that outcome.
6. **Given** an escalation event fires, **When** `/metrics` is scraped, **Then** the escalation-rate counter increments, labeled by trigger reason.
7. **Given** an AI tool invocation succeeds or fails, **When** `/metrics` is scraped, **Then** the tool-failure-rate counter reflects the outcome, labeled by tool name.
8. **Given** a known error code is surfaced to a customer, **When** `/metrics` is scraped, **Then** a counter labeled by that error code has incremented (supports both "most common errors" and, via repeated occurrence on the same product/category, "recurring problems").
9. **Given** the AI's knowledge retrieval step either does or does not find a usable match for the customer's problem, **When** `/metrics` is scraped, **Then** a knowledge-effectiveness counter reflects that outcome.
---
### Edge Cases
- What happens when the configured tracing exporter/collector is unreachable? The API must still start and continue serving requests; span export failures must be logged but never surface to the request/response cycle.
- What happens to in-flight metrics/traces if the process crashes before a scrape/export completes? Acceptable data loss for that window — this feature does not need to guarantee zero metric loss across a crash, only correctness of what is recorded and exported during normal operation.
- What happens when a request has no matching route (404) or is rejected before reaching a handler (e.g., by a global rate limiter)? It must still produce exactly one access-log line and one metrics observation, so operators can see rejected traffic, not just successfully-routed traffic.
- What happens when two requests share the same client-supplied correlation ID (e.g., a retried request)? Each still gets its own request ID and its own access-log line; only the correlation ID is shared, by design (that is what lets an operator group retries together).
- How does the system behave for a route that legitimately never touches any of the business-event counters (e.g., a health check)? No business-metric line is expected for it — only the generic request-count/duration metrics from User Story 2 apply.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST emit exactly one structured access-log line per completed HTTP request (including requests that error, 404, or are rejected by a global hook before reaching a route handler), containing at minimum: HTTP method, route/path, response status code, duration, request ID, and correlation ID.
- **FR-002**: System MUST attach the request ID and correlation ID already established by the existing request-context mechanism to every log line produced while handling that request, not only the access-log line.
- **FR-003**: System MUST record every completed HTTP request's duration into the existing request-duration metric, labeled at minimum by method, route, and status code.
- **FR-004**: System MUST expose a request-count metric (or equivalent derivable from FR-003's histogram) sufficient to compute error rate per route and status class.
- **FR-005**: System MUST initialize a real distributed-tracing pipeline at startup so that spans created via the existing `getTracer()` helper are captured and exported to an inspectable destination, rather than discarded by the OpenTelemetry no-op default.
- **FR-006**: System MUST create spans for the AI diagnosis → escalation → orchestration/assignment path and for the ticket-creation → orchestration path, correctly nested under one root span per originating request, so a single incident's cross-module timing is visible in one trace.
- **FR-007**: System MUST continue to start up and serve requests normally if the configured tracing export destination is unreachable; export failures MUST be logged, never raised to the request/response cycle.
- **FR-008**: System MUST expose live counters/histograms on the existing `/metrics` endpoint for each of: AI resolution rate, AI escalation rate, human resolution rate, average resolution time, first response time, SLA compliance, escalation rate, recurring problems, most common errors, knowledge effectiveness, and tool failure rate — each updated at the moment its underlying real event occurs (not computed by a batch job or exposed through any new endpoint).
- **FR-009**: System MUST NOT introduce any new human-facing dashboard, chart, or reporting API as part of this feature — every metric from FR-008 is a raw, unaggregated-by-this-system counter/histogram intended for an external monitoring stack to graph, in keeping with the explicit scope boundary against the separate reporting/analytics dashboards work.
- **FR-010**: Existing `/health`, `/health/live`, `/health/ready`, and `/metrics` endpoints MUST continue to function unchanged in shape for any existing consumer.
### Key Entities
- **Access log line**: A structured log record emitted once per completed HTTP request; not a persisted database entity — it exists only in the log stream.
- **Request-duration metric**: A histogram, keyed by method/route/status, recording how long each request took.
- **Trace / span**: A record of one operation's start/end time and its parent-child relationship to other operations within the same originating request, exported to wherever tracing is configured to send it.
- **Business-event counter**: One of the eleven named live metrics in FR-008/User Story 4, each incremented (or observed, for the two duration-based ones) at the exact point its real-world event already occurs elsewhere in the system (ticket resolution, SLA run completion, escalation firing, tool invocation, etc.) — this feature adds the instrumentation call at each of those existing points, it does not change what those points do.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Given any request made to the running API, an operator can identify, from logs alone, its method, route, outcome, duration, and every other log line produced while handling it, within seconds of it happening.
- **SC-002**: An operator watching `/metrics` can determine current request error rate and latency distribution per route without needing to read application logs.
- **SC-003**: An operator can find and inspect the complete cross-module trace for a specific incident that touched at least two instrumented modules, showing correctly-attributed timing per module.
- **SC-004**: All eleven named business-health metrics are visible on `/metrics` and each one's value changes correctly and immediately in response to its real underlying event, verified against real (non-mocked) system behavior.
- **SC-005**: Enabling this feature's tracing pipeline introduces no observable request-handling failure, and the API starts and serves traffic normally even when the tracing destination is unreachable.
## Assumptions
- "Exported to an inspectable destination" (FR-005) means a destination this project's own test/dev environment can actually verify against — an OTLP-compatible collector endpoint in production-like environments, and an in-process/console exporter for local development and automated tests, both driven by configuration rather than hardcoded per environment. No specific commercial tracing backend (e.g., Jaeger, Honeycomb, Datadog) is mandated by this feature; wiring a specific backend in a given deployment is an operations concern outside this spec.
- The existing Prometheus (`prom-client`) and Pino stack are the metrics/logging technology already chosen for this project (confirmed by existing code) and are reused rather than replaced.
- "Knowledge effectiveness" is scoped to whether the AI's knowledge-retrieval step found and used a matching entry for a given diagnosis attempt (a binary outcome per attempt), not a more elaborate relevance-scoring scheme — no such scoring exists elsewhere in the system to build on.
- "Recurring problems" and "most common errors" (FR-008) are satisfied by labeled counters an operator's monitoring stack can rank/aggregate over any time window (e.g., `topk` in PromQL) — this feature does not need to compute or store a "top N" itself, consistent with FR-009's boundary against building reporting logic.
- This feature is backend-only (`supporthub-api`); no `supporthub-web` changes are in scope, since nothing here is presented to any human through a UI.
- Existing `RequestContext` (`requestId`/`correlationId`), already populated by both the customer and staff auth paths (010-identity-auth), is reused as the identifier scheme for FR-001/FR-002 rather than introducing a second identifier scheme.
+222
View File
@@ -0,0 +1,222 @@
---
description: 'Task list for 014-full-observability'
---
# Tasks: Full Observability
**Input**: Design documents from `specs/014-full-observability/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/metrics-contract.md](./contracts/metrics-contract.md), [quickstart.md](./quickstart.md)
**Organization**: Tasks are grouped by user story (US1 = P1 access log, US2 = P1 request-health
metrics, US3 = P2 tracing, US4 = P2 business-health metrics). US2 shares its hook point with
US1 (both live in the same `onResponse` hook) so US2 depends on US1's hook existing, not on its
own separate one. US3 and US4 are each independent of US1/US2 and of each other.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 Add `@opentelemetry/exporter-trace-otlp-http` and `@opentelemetry/resources` to
`package.json` (`npm install`)
- [x] T002 Add `src/infrastructure/observability/request-context.store.ts` — a module-level
`AsyncLocalStorage<{requestId: string; correlationId: string}>` with a `run()` passthrough
and a `getStore()` re-export
- [x] T003 [P] Wire `logger.ts`'s Pino options with a `mixin` function reading from T002's store
(returns `{}` when no store is active — a log call outside any request, e.g. at startup,
must not throw) (depends on T002)
**Checkpoint**: Every subsequent log call through the shared `logger` singleton is
request-correlated automatically, once a request actually runs inside the store (US1 wires that
part next).
---
## Phase 2: User Story 1 - Trace one request end to end from its logs (Priority: P1)
**Goal**: One structured access-log line per request; every other log line produced during that
request's handling shares its request ID.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T004 [P] [US1] Unit test: a `logger.info(...)` call made inside T002's `store.run(...)`
carries `requestId`/`correlationId` in its output; one made outside carries neither, in
`tests/unit/observability/request-context-mixin.test.ts` (depends on T003)
### Implementation for User Story 1
- [x] T005 [US1] In `plugins/request-context.plugin.ts`'s existing `onRequest` hook, after
building `request.reqContext`, call the T002 store's `run()` wrapping the remainder of the
request's handling (Fastify's `onRequest` hooks accept a `done` callback / return a
promise — the run wraps whichever style this hook currently uses) so every subsequent
hook/handler for this request executes inside the ALS context (depends on T002)
- [x] T006 [US1] Add an `onResponse` hook (same plugin) that logs one line via the shared
`logger`: `{event: "http_request_completed", method, route: request.routeOptions.url,
statusCode: reply.statusCode, durationMs: reply.elapsedTime}`, at `info`/`warn`/`error`
level by status class (depends on T005)
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (one access-log line per
request incl. 404; shared requestId across the access-log line and an internal log line
from the same request) in `tests/integration/observability/access-log.test.ts`, using a
`logger.info`/`logger.warn` spy the same way `password-reset-flow.test.ts` (013) already
does (depends on T006)
**Checkpoint**: Quickstart Scenario 1 passes. Every request is now visible in logs even when it
never errors.
---
## Phase 3: User Story 2 - See live request-health metrics (Priority: P1)
**Goal**: The existing (previously dead) request-duration histogram actually has observations;
error rate per route/status is computable from `/metrics` alone.
**Independent Test**: Quickstart Scenario 2.
### Implementation for User Story 2
- [x] T008 [US2] In the same `onResponse` hook added by T006, call
`httpRequestDurationHistogram.observe({method, route: request.routeOptions.url, status_code:
String(reply.statusCode)}, reply.elapsedTime / 1000)` (depends on T006)
- [x] T009 [US2] Integration test covering Quickstart Scenario 2 (send a mix of successful/
failing requests to the same route, scrape `/metrics`, assert both status-code label
values are present with the expected counts) in
`tests/integration/observability/request-metrics.test.ts` (depends on T008)
**Checkpoint**: Quickstart Scenario 2 passes. `/metrics` now reflects real request traffic.
---
## Phase 4: User Story 3 - Trace a single incident's cross-module path (Priority: P2)
**Goal**: A real `TracerProvider` is active; `getTracer()` produces spans that are actually
exported; two named cross-module paths are instrumented.
**Independent Test**: Quickstart Scenario 3.
### Implementation for User Story 3
- [x] T010 [P] [US3] Rewrite `infrastructure/observability/tracing.ts` to initialize a
`BasicTracerProvider` at module load with a `Resource` (`service.name: "supporthub-api"`)
and register it via `trace.setGlobalTracerProvider(...)`; exporter/processor chosen by
`NODE_ENV`/`OTEL_EXPORTER_OTLP_ENDPOINT` per research.md §4 (`InMemorySpanExporter` +
`SimpleSpanProcessor` in test, `OTLPTraceExporter` + `BatchSpanProcessor` when the env var
is set, `ConsoleSpanExporter` + `SimpleSpanProcessor` otherwise); export a
`getTestSpanExporter()` accessor (test env only) for T015 to read exported spans back;
`getTracer()`'s own exported signature is unchanged (depends on T001)
- [x] T011 [US3] Wire the OpenTelemetry SDK's internal diagnostic logger
(`diag.setLogger(...)`) to the shared `logger.warn`, so span-export failures land in this
project's own log stream instead of stderr or nowhere (depends on T010)
- [x] T012 [P] [US3] Add a `ticket.create` span (`getTracer().startActiveSpan(...)`) around
`tickets/service/tickets.service.ts`'s ticket-creation method, with `ticket.id` and
`product.externalProductId` attributes, ended in a `finally` (depends on T010)
- [x] T013 [P] [US3] Add an `ai.escalation` span around `ai-support/sessions/service/
session.service.ts`'s escalation branch(es), with `ticket.id`/`session.id` attributes
(depends on T010)
- [x] T014 [US3] Add an `orchestration.assignment` span wrapping the existing
`orchestrationService.handleHumanEscalation` call in the `TICKET_UPDATED`/
`HUMAN_ESCALATION` subscriber (`src/events/handlers/index.ts`), with `ticket.id`/
`strategy` attributes, so it nests under T013's span when both occur in the same request
(depends on T010, T013)
- [x] T015 [US3] Integration test covering Quickstart Scenario 3 steps 1-2: drive a real
ticket-creation → escalation → orchestration/assignment flow, read spans back via T010's
`getTestSpanExporter()`, assert `ticket.create`/`ai.escalation`/`orchestration.assignment`
all share one trace ID with correct parent/child `spanId` relationships, in
`tests/integration/observability/tracing.test.ts` (depends on T012, T013, T014)
- [x] T016 [US3] Integration test covering Quickstart Scenario 3 steps 3-4: point
`OTEL_EXPORTER_OTLP_ENDPOINT` at an unreachable address, confirm `buildApp()` still
resolves and a request still completes successfully, in the same test file (depends on
T010)
**Checkpoint**: Quickstart Scenario 3 passes. A real, inspectable trace exists for the first
time; tracing failure never blocks the app.
---
## Phase 5: User Story 4 - See the business-health metrics this project committed to tracking (Priority: P2)
**Goal**: All eleven named metrics (data-model.md) are live on `/metrics`, each updated at the
exact real event research.md identified.
**Independent Test**: Quickstart Scenario 4.
### Implementation for User Story 4
- [x] T017 [US4] Define all eleven new `Counter`/`Histogram` objects in
`infrastructure/observability/metrics.ts` per data-model.md's table, exported individually
(depends on T001)
- [x] T018 [P] [US4] Increment `supporthub_ai_session_outcomes_total` in `ai-support/sessions/
repository/session.repository.ts`'s `updateStatus`, labeled `outcome` when `status` is
`'resolved'`/`'escalated'` (depends on T017)
- [x] T019 [P] [US4] Add a `TICKET_UPDATED`/`newStatus === 'RESOLVED'` subscriber in
`src/events/handlers/index.ts` that looks up `resolutionRepository.findByTicketId`,
increments `supporthub_ticket_resolutions_total{resolved_by}` (`ai` vs. any other value),
fetches the ticket for `createdAt`, and observes
`supporthub_ticket_resolution_duration_seconds` (depends on T017)
- [x] T020 [P] [US4] In `ticketing/messages/service/messages.service.ts`'s `post`, when
`type === 'AGENT_MESSAGE'`, check for a prior `AGENT_MESSAGE` on the ticket and — only for
the first one — observe `supporthub_ticket_first_response_duration_seconds` against the
ticket's `createdAt` (depends on T017)
- [x] T021 [P] [US4] In `orchestration/sla/service/sla.service.ts`: in `complete()`, read
`run.status` before updating and increment `supporthub_sla_run_outcomes_total{outcome:
"met"}` only if it was not already `'breached'`; in `runBreachDetectionSweep()`, increment
`{outcome: "breached"}` for each newly-flagged run (depends on T017)
- [x] T022 [P] [US4] Add an `ESCALATION_TRIGGERED` subscriber in `src/events/handlers/index.ts`
that increments `supporthub_escalations_total{reason}` from the event payload's `reason`
(depends on T017)
- [x] T023 [P] [US4] In `ticketing/tickets/service/tickets.service.ts`'s ticket-creation method,
increment `supporthub_problems_created_total{category_id}` right after `problemsRepo.create`
succeeds (`categoryId ?? 'uncategorized'`) (depends on T017)
- [x] T024 [P] [US4] In `ai-support/knowledge/service/error-codes.service.ts`'s
`findKnownIssuesByErrorCode`, increment `supporthub_known_error_lookups_total{code}` once
the error code is confirmed to exist (after the `NotFoundError` branch, not before)
(depends on T017)
- [x] T025 [P] [US4] In `ai-support/tools/service/tools.service.ts`'s single `executeTool(...)`
call site: increment `supporthub_tool_invocations_total{tool, outcome}` for every call, and
— only when `block.name === 'searchProductKnowledge'` — increment
`supporthub_knowledge_retrieval_outcomes_total{matched}` from whether `result.output` is a
non-empty array (depends on T017)
- [x] T026 [US4] Unit test: `sla.service.ts`'s `complete()` does not increment the `met` outcome
for a run already `'breached'` (a fake repo returning `status: 'breached'`) in
`tests/unit/observability/sla-compliance-metric.test.ts` (depends on T021)
- [x] T027 [US4] Integration test covering Quickstart Scenario 4 (all ten sub-scenarios — the
eleventh, tool-failure, is covered by the same test file's tool-invocation case) —
scrape `/metrics` before/after driving each real event through the real API, in
`tests/integration/observability/business-metrics.test.ts` (depends on T018, T019, T020,
T021, T022, T023, T024, T025)
**Checkpoint**: Quickstart Scenario 4 passes. All eleven named metrics are live and correct
against real infrastructure.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T028 [P] Update `specs/014-full-observability/checklists/requirements.md` Notes with any
implementation-time findings (including the pre-existing SLA-run status data-quality gap
research.md §5 already surfaced)
- [x] T029 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T030 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
(particularly every module touched by a single-line instrumentation addition: ai-support
sessions/knowledge/tools, ticketing tickets/messages, orchestration/sla, and the event-bus
handlers)
---
## Dependencies & Execution Order
- **Foundational (Phase 1)**: No dependencies — BLOCKS User Story 1 (and transitively 2)
- **User Story 1 (Phase 2)**: Depends on Foundational — BLOCKS User Story 2 (shares its hook)
- **User Story 2 (Phase 3)**: Depends on User Story 1
- **User Story 3 (Phase 4)**: Depends only on Foundational (T001) — independent of US1/US2/US4
- **User Story 4 (Phase 5)**: Depends only on Foundational (T001/T017) — independent of
US1/US2/US3
- **Polish (Phase 5)**: Depends on all four user stories
@@ -0,0 +1,94 @@
# Specification Quality Checklist: Reporting and Analytics Dashboards
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This is `docs/10-implementation-roadmap.md`'s own Phase 11, third sub-area, per explicit user
direction (013 was the security pass, 014 was full observability). Backend-first scope
(Assumptions) follows the same pattern already established three times this session
(010-identity-auth, 011-agent-ticket-queue, and 014-full-observability's own frontend-free
scope) — a `supporthub-web` dashboard UI is a natural, separate follow-on, not re-litigated
here via a fresh question.
- The pre-scaffolded-but-inert `platform/reports` module (`ReportsService.generateSummaryReport`
currently returns `{}`) and the `ANALYTICS` queue stub (`src/jobs/analytics`, logs only) were
both confirmed via direct code inspection before writing this spec — the same
"provisioned before this session's rebuild but never wired up" pattern found repeatedly this
session. This feature wires up the former; the Assumptions section explicitly keeps the latter
out of scope (synchronous queries, no pre-aggregation job, for this first cut).
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
required — every open question (default date window, SLA-risk threshold, top-N limit) had a
reasonable, documented, CONFIGURABLE default (see Assumptions), matching the roadmap's own
"never hardcode a placeholder value and ship it as final" instruction.
## Implementation Notes (post-build)
- Named the Product dashboard's own repository class `ProductReportRepository` (not
`ProductRepository`) once it became clear resolving `externalProductId -> Product` should
reuse `catalog/products`' own already-public `productsRepository.findByExternalProductId`
rather than duplicating that lookup — avoids a name collision and keeps "one authority per
concern" (Constitution Principle I's spirit) for product resolution.
- `ManagementRepository` and `SupportRepository` both needed byte-identical
first-response-duration and resolution-duration queries. Extracted into a shared
`SharedReportRepository` both compose, rather than duplicating the Prisma query (or the
averaging helper alone) twice — discovered while writing the second repository and seeing the
copy-paste, not planned upfront in research.md.
- "Top errors"/"most common errors" resolution-back-to-`code` logic moved into
`ErrorCodesService.getTopErrorCodesForProduct` (a new method on the module that already owns
`ErrorCode`), rather than the reports module reaching into `errorCodesRepository`/
`errorCodeLookupRepository` directly — cleaner module-boundary ownership than research.md's
original per-repository sketch implied.
- The AI dashboard's "failed troubleshooting then escalated" figure (spec.md User Story 4) has
no single stored flag anywhere in this codebase — `classifyStepOutcome`'s per-step verdicts are
never persisted as their own durable record. Implemented as a documented proxy instead: an
escalated session with `toolCallCount > 0` attempted troubleshooting before giving up, one with
zero attempts escalated immediately. Documented directly in `ai.repository.ts`'s own code
comment, the same "honest, documented simplification" precedent research.md §7 already set for
the confidence-distribution bucketing.
- Three of this module's public exports needed adding to their owning modules' top-level
`index.ts` (not previously exposed): `decideConfidenceBand`/`ConfidenceBand` and
`knowledgeReferenceRepository` from `ai-support/sessions`, matching the "extend an existing
module's public surface for a later feature" precedent already used repeatedly this session
(004's `productsRepository`, 009's `problemsRepository`).
- Found a real regression during T028's full regression pass: `known-issues.test.ts` (004-
product-knowledge, pre-existing) calls `findKnownIssuesByErrorCode` and its own `afterAll`
deleted `ErrorCode` rows before this feature's new `ErrorCodeLookup` FK (RESTRICT) existed —
once T004 started writing a lookup row on every call, that cleanup order started failing with
an FK violation. Fixed by deleting `ErrorCodeLookup` rows first in that test's own `afterAll`.
This feature's own new test files never delete `ErrorCode` rows at all, so they weren't
affected the same way (leftover rows there are the same accepted throwaway-data tradeoff
already established elsewhere this session).
- Confirmed (not caused by this feature — the exact pre-existing issue 014-full-observability's
own checklist already documented and root-caused via `git checkout` comparison) that this
feature's own new integration test files, which also name their test products `TEST_*`,
occasionally hit the same shared `deriveProductCode` "TEST" prefix collision under vitest's
concurrent file execution when run alongside other `TEST_*`-prefixed files. Every dashboard
test passes reliably run individually or in small groups; the intermittent 500 in a full
combined run is the same known, out-of-scope, 003-ticketing concern.
@@ -0,0 +1,59 @@
# Contract: Reporting API
All four routes require a valid staff session with role `ADMIN` (`requireRole('ADMIN')`), the
same gate every admin-only surface uses since 010-identity-auth. All return the standard
envelope: `{ success: true, data: <shape>, meta: null }` on success, `{ success: false, error:
{code, message, details} }` on failure — no change to this codebase's existing response
convention.
## `GET /admin/reports/management`
**Query**: `from?`, `to?` (ISO dates).
**200**: `ManagementDashboard` (data-model.md).
**400** `VALIDATION_ERROR`: `from` is after `to`.
**401/403**: missing/invalid session, or a non-`ADMIN` role.
## `GET /admin/reports/product/:externalProductId`
**Path**: `externalProductId` — the SaaS-facing product identifier (same convention every other
admin product-scoped route already uses, e.g. `GET /admin/products/:externalProductId/knowledge`
from 004-product-knowledge).
**Query**: `from?`, `to?`.
**200**: `ProductDashboard`.
**404** `NOT_FOUND`: no product with that `externalProductId` (FR-006 — never an empty-but-200
response for an unknown product).
**400** `VALIDATION_ERROR`: `from` is after `to`.
## `GET /admin/reports/support`
**Query**: `from?`, `to?` (applies only to the performance figures — workload/SLA-risk/breached
are always current, per data-model.md's `SupportDashboard.generatedAt`).
**200**: `SupportDashboard`.
## `GET /admin/reports/ai`
**Query**: `from?`, `to?`.
**200**: `AiDashboard`.
## Guarantees
1. Every rate/average field is `number | null``null` means no qualifying data existed in the
requested range (FR-007). A consumer must never see `NaN` or a silently-substituted `0` for
"no data."
2. Every count field is a plain `number`, always present, `0` is a legitimate, meaningful value
for a count (distinct from the `null`-for-no-data rule above, which applies only to
rates/averages).
3. `from`/`to` in every response echo the *resolved* range actually used (including the default,
when omitted) — a caller never has to separately know what "the default" was.
4. No route in this contract mutates any data — a repeated identical request returns the same
shape (though not necessarily identical figures, since the underlying data can change between
requests) with no side effect.
@@ -0,0 +1,93 @@
# Data Model: Reporting and Analytics Dashboards
## New Prisma Model
### `ErrorCodeLookup`
Append-only audit record — see research.md §6 for why this is the one new table this feature
needs.
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `errorCodeId` | `String` | FK → `ErrorCode.id` |
| `productId` | `String` | FK → `Product.id` — denormalized from `errorCode.productId` so the Product dashboard's range query never needs to join back through `ErrorCode` just to filter by product |
| `createdAt` | `DateTime @default(now())` | |
Indexes: `@@index([productId, createdAt])` (the Product dashboard's own access pattern).
No `updatedAt`, no soft-delete, no unique constraint — every lookup is its own row, duplicates
across time are the entire point (frequency is what "top errors" measures).
## Response Shapes (not persisted — computed per request)
### Management dashboard — `GET /admin/reports/management`
```ts
interface ManagementDashboard {
range: { from: string; to: string }; // ISO 8601, echoes the resolved (possibly defaulted) range
totalCases: number;
aiResolved: number;
humanEscalated: number;
resolved: number;
open: number;
slaCompliance: { met: number; breached: number; rate: number | null }; // rate = met / (met + breached)
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
```
### Product dashboard — `GET /admin/reports/product/:externalProductId`
```ts
interface ProductDashboard {
productId: string; // externalProductId, echoed back
range: { from: string; to: string };
supportVolume: number;
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
recurringProblems: Array<{ categoryId: string | null; count: number }>; // same data, top N, descending
aiResolutionRate: number | null;
humanEscalationRate: number | null;
topErrors: Array<{ code: string; count: number }>; // top N, descending
}
```
### Support dashboard — `GET /admin/reports/support`
```ts
interface SupportDashboard {
generatedAt: string; // workload/risk are point-in-time, not range-scoped (research.md §2)
range: { from: string; to: string }; // still applies to the performance figures below
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
slaAtRisk: number;
slaBreached: number;
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
```
### AI dashboard — `GET /admin/reports/ai`
```ts
interface AiDashboard {
range: { from: string; to: string };
totalSessions: number;
aiResolutionRate: number | null;
humanHandoffRate: number | null;
failedTroubleshootingEscalationRate: number | null;
knowledgeMatchRate: number | null;
confidenceDistribution: { proceed: number; ask: number; escalate: number };
toolInvocations: { success: number; failed: number };
}
```
## Query Parameters (all four routes)
| Param | Type | Notes |
|---|---|---|
| `from` | ISO date, optional | Defaults to `to - REPORTING_DEFAULT_WINDOW_DAYS` |
| `to` | ISO date, optional | Defaults to now |
`from > to` is a 400 `VALIDATION_ERROR` (spec.md Edge Cases), not silently swapped.
+137
View File
@@ -0,0 +1,137 @@
# Implementation Plan: Reporting and Analytics Dashboards
**Branch**: `015-reporting-dashboards` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/015-reporting-dashboards/spec.md`
## Summary
Wires the pre-scaffolded, unused `platform/reports` module into four real, admin-gated,
read-only aggregation endpoints (Management, Product, Support, AI) matching
`docs/09-testing-observability-cicd.md`'s own dashboard table — each computed synchronously,
on request, directly from existing durable tables (Ticket, Problem, SLARun, EscalationEvent,
AISupportSession, AIDiagnosis, AIAction, Resolution, Assignment). The one new piece of state is
a small durable `ErrorCodeLookup` audit table, needed only because no existing record lets "top
errors" be computed historically (014-full-observability's own equivalent is a process-lifetime
Prometheus counter, unusable for a dated report). No presentation layer — see spec.md's
Assumptions for why `supporthub-web` work is a separate follow-on.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
**Primary Dependencies**: None new — Prisma's own `groupBy`/`count`/`aggregate`/`findMany`, no
raw SQL (research.md §5), reusing `decideConfidenceBand` (005-ai-support) and the
`Resolution.resolvedBy` convention (014-full-observability) rather than reimplementing either.
**Storage**: One new table, `ErrorCodeLookup` (`id`, `errorCodeId` FK, `productId` FK,
`createdAt`) — append-only, no update/delete path, indexed `(productId, createdAt)` for the
Product dashboard's range-scoped ranking query. No change to any existing table.
**Testing**: Vitest — unit tests for the "no data → `null`, never `NaN`" averaging helper and the
confidence-bucketing reuse; integration tests against real Postgres/Redis driving each
dashboard's real underlying data (tickets in various terminal states, SLA runs met/breached,
escalation events, AI sessions/diagnoses/actions, error-code lookups) and asserting every
returned figure against hand-computed expected values — the same rigor and mixed
HTTP-driven/direct-repository setup style as 014's `business-metrics.test.ts`.
**Target Platform**: Same Fastify modular monolith. Rewrites `platform/reports` (service,
new controller, new routes, new schema for the date-range/product-id query params) from its
current one-stub-method state into the real module. Adds one line to
`ai-support/knowledge/service/error-codes.service.ts`'s existing `findKnownIssuesByErrorCode`
(the same call site 014 already instrumented) to also write the new durable audit row.
**Project Type**: Backend service — single project.
**Performance Goals**: Every dashboard query is bounded by the requested date range (default 30
days, config) and, where a full-row fetch is needed for in-application averaging (research.md
§5), only the two timestamp columns needed for that specific average — never a full-table scan
with no range filter. Acceptable at current data volumes per spec.md's own Assumptions;
pre-aggregation is explicitly deferred to if/when load testing (a separate, not-yet-started
Phase 11 sub-area) shows it's actually needed.
**Constraints**: FR-006 — an unknown `productId` on the Product dashboard is a 404, never an
empty-but-200 response. FR-007 — every rate/average is `number | null`, `null` meaning "no
qualifying data," computed by checking the qualifying count before ever dividing. FR-008 — every
route requires `requireRole('ADMIN')`, the same gate every admin surface uses since
010-identity-auth.
**Scale/Scope**: Four new `GET` routes, one new Prisma model + migration, four new service
methods (one per dashboard) replacing the single stub method, one new schema file for query-param
validation, three new env-configured values (Constitution Principle II). No new module — this
extends `platform/reports`, already the correct architectural home.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Not applicable — no identity/access surface touched; every figure is derived from SupportHub's own domain data (tickets, problems, SLA, escalation, AI sessions), squarely inside SupportHub's own sole-authority domain per this principle's own second sentence. | PASS |
| II. Configuration Over Hardcoding | The default reporting window, the SLA-risk threshold, and the top-N ranking limit are all new env-configured values (`REPORTING_DEFAULT_WINDOW_DAYS`, `REPORTING_SLA_RISK_THRESHOLD_MINUTES`, `REPORTING_TOP_N_LIMIT`), never hardcoded — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS |
| III. Layered Architecture With Enforced Module Boundaries | All new code lives inside `platform/reports` (already its correct home) following Route → Schema → Controller → Service → Repository → Prisma; cross-module reads (tickets, AI support, orchestration, SLA/escalation, problem resolution) go through each owning module's own public `index.ts`, the same precedent every prior feature this session established (e.g. `tool-executor.ts` reading `ticketsService` from `@/modules/ticketing/tickets`). | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI tool-execution or decision logic changed; the AI dashboard only reports on outcomes the existing, already-deterministic confidence-band/tool-policy code already produced. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution-recording logic changed. | PASS — N/A |
| VI. Durable Audit & History | The one new table (`ErrorCodeLookup`) is itself an append-only audit record, directly in this principle's spirit — "which error codes came up, when" becomes durably answerable for the first time. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Not applicable — read-only aggregation queries, no job handlers, no assignment/SLA state mutated. | PASS — N/A |
| VIII. Problem and Ticket Are Separate, Related Entities | Respected — the Product dashboard's problem-type breakdown queries `Problem` directly, never conflating it with `Ticket`. | PASS |
| Technology & Platform Constraints | No new dependencies; one new Prisma model via the established non-interactive migration workflow (`prisma migrate diff` → hand-written `migration.sql``prisma migrate deploy`) this session has used for every prior schema change. | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/015-reporting-dashboards/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── reports-api-contract.md
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ ├── schema.prisma # MODIFIED — new ErrorCodeLookup model
│ └── migrations/
│ └── <timestamp>_add_error_code_lookup/migration.sql # NEW
├── src/
│ ├── config/
│ │ └── env.ts / reporting.ts (or similar) # MODIFIED — 3 new env-configured values
│ └── modules/
│ ├── platform/
│ │ └── reports/ # REWRITTEN (was a 1-method stub)
│ │ ├── controller/
│ │ ├── mapper/ # date-range parsing/defaulting, averaging helper
│ │ ├── repository/ # the 4 dashboards' Prisma queries
│ │ ├── routes/
│ │ ├── schema/ # query-param validation
│ │ ├── service/
│ │ └── index.ts
│ └── ai-support/
│ └── knowledge/
│ ├── repository/ # MODIFIED — errorCodeLookupRepository
│ └── service/
│ └── error-codes.service.ts # MODIFIED — one new line at the existing
│ lookup call site
└── tests/
├── unit/platform/reports/ # averaging/no-data-null helper, confidence
│ bucketing reuse
└── integration/platform-reports/ # all four dashboards against real data
```
**Structure Decision**: Single project, no new module — `platform/reports` already exists as the
correct architectural home and simply needs its real implementation built out, following the
same Route → Schema → Controller → Service → Repository → Prisma layering every other module
already uses.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,52 @@
# Quickstart: Reporting and Analytics Dashboards
Manual verification steps for each user story, against a running instance backed by real
Postgres/Redis, logged in as an ADMIN.
## Scenario 1 — Management dashboard (User Story 1)
1. Create several tickets within a known date range: some reaching `AI_RESOLVED`/`RESOLVED` via
an AI session, some escalated to a human and resolved via `resolutionsService.record`, some
left open.
2. Let one ticket's SLA run complete on time and another breach (via the existing breach sweep).
3. `GET /admin/reports/management?from=<range start>&to=<range end>`.
4. **Expected**: `totalCases`, `aiResolved`, `humanEscalated`, `resolved`, `open` all match what
was actually created; `slaCompliance.met`/`.breached` match the two SLA outcomes;
`averageResponseSeconds`/`averageResolutionSeconds` are non-null and plausible.
5. Request the same endpoint for a date range with no activity at all.
6. **Expected**: every count is `0`, every rate/average is `null`, not an error.
## Scenario 2 — Product dashboard (User Story 2)
1. Create tickets for two distinct products in the same range, one with a categorized problem.
2. Look up a known error code for one product several times, a different code once.
3. `GET /admin/reports/product/:externalProductId` for each product.
4. **Expected**: each product's `supportVolume`/`problemsByCategory`/`aiResolutionRate` reflect
only its own tickets; `topErrors` ranks the more-frequently-looked-up code first.
5. Request the endpoint for a nonexistent `externalProductId`.
6. **Expected**: `404 NOT_FOUND`, not an empty `200`.
## Scenario 3 — Support dashboard (User Story 3)
1. Assign several tickets across two agents (some via the real orchestration flow).
2. Let one ticket's SLA run sit within `REPORTING_SLA_RISK_THRESHOLD_MINUTES` of its resolution
due date without breaching.
3. `GET /admin/reports/support`.
4. **Expected**: `workloadByAgent` matches each agent's real current open-assignment count;
`slaAtRisk` counts exactly the near-due run, distinct from `slaBreached`.
## Scenario 4 — AI dashboard (User Story 4)
1. Run AI sessions to a mix of terminal outcomes (`resolved`, `escalated`), with some tool
invocations succeeding and others failing, and diagnoses spanning a range of confidence
values.
2. `GET /admin/reports/ai`.
3. **Expected**: `aiResolutionRate`/`humanHandoffRate` reflect the real outcome mix;
`confidenceDistribution` buckets match `decideConfidenceBand`'s own classification of each
diagnosis's stored confidence against the system-default thresholds; `toolInvocations`
reflects the real success/failure counts.
## What "done" looks like
All four scenarios pass against a real Postgres/Redis, every figure independently verified
against hand-computed expected values, and no route is reachable by a non-admin session.
+143
View File
@@ -0,0 +1,143 @@
# Research: Reporting and Analytics Dashboards
## 1. Where this lives
**Decision**: Wire up the existing, pre-scaffolded `src/modules/platform/reports` module (today
just `ReportsService.generateSummaryReport()` returning `{}`, confirmed unused anywhere) rather
than creating a new module. Its four real methods (`getManagementDashboard`,
`getProductDashboard`, `getSupportDashboard`, `getAiDashboard`) replace the one stub method.
Routes live at `GET /admin/reports/management`, `GET /admin/reports/product/:externalProductId`,
`GET /admin/reports/support`, `GET /admin/reports/ai`, admin-gated the same way every other
admin-only endpoint since 010-identity-auth already is (`requireRole('ADMIN')`).
**Why not the `ANALYTICS` queue** (`src/jobs/analytics`, also pre-scaffolded, also inert): a
queued background job fits pre-computing a report nobody is currently waiting on; a dashboard
request is someone waiting right now for an answer. Per spec.md's Assumptions, this first cut is
synchronous, direct-query aggregation — the queue stub stays exactly as inert as it already was,
untouched by this feature.
## 2. Per-dashboard queries
All four use Prisma's `groupBy`/`count`/`aggregate`, scoped by `createdAt` (or the
milestone-specific timestamp named below) within `[from, to]`, computed directly against the
tables that already own each fact — no new table, no denormalized rollup.
### Management (FR-001)
| Figure | Source |
|---|---|
| Total cases | `Ticket.count({ createdAt in range })` |
| AI resolved | `Ticket.count({ createdAt in range, status: 'AI_RESOLVED' })` — a ticket that reached `AI_RESOLVED` and stayed there or moved straight to `RESOLVED` without a `Resolution.resolvedBy` other than `'ai'`; see §4 below for the exact "who resolved it" rule shared with the Product dashboard |
| Human escalated | `Ticket.count({ createdAt in range, status in [HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED] })` minus AI-only-path tickets — i.e. any ticket that ever reached `HUMAN_ESCALATION`; the state machine research.md already establishes this as one-way (003-ticketing) |
| Resolved (either path) | `Ticket.count({ createdAt in range, status in [RESOLVED, CLOSED] })` |
| Open | `Ticket.count({ createdAt in range, status not in [RESOLVED, CLOSED] })` |
| SLA compliance / breach count | `SLARun.groupBy(['status'], { ticket: { createdAt in range } })`, `status: 'completed'` = met, `'breached'` = breached (mirrors 014's own metric semantics — see 014 research.md §5's "read status before the overwrite" caveat, which applies equally here: a `'breached'`-then-`'completed'` run is still counted breached, by reading the `breachedAt`/`firstResponseBreachedAt` timestamps rather than only the current `status` string) |
| Escalation count | `EscalationEvent.count({ createdAt in range })` |
| Average response time | `avg(firstAgentMessage.createdAt - ticket.createdAt)` over tickets with at least one `AGENT_MESSAGE` in range — computed in application code over a bounded query result (see §5, no raw SQL) |
| Average resolution time | `avg(resolution.createdAt - ticket.createdAt)` over tickets with a `Resolution` row in range |
### Product (FR-002)
Same shape as Management, `WHERE Ticket.productId = :productId` (resolved from the given
`externalProductId`, 404 if not found — FR-006), plus:
| Figure | Source |
|---|---|
| Problem-type breakdown | `Problem.groupBy(['categoryId'], { productId, createdAt in range })` |
| Recurring problems | Same grouped result, sorted descending, top N (config, default 10) |
| Top errors | `reuses 014's own instrumentation point conceptually but queries fresh` — no, see §6: there is no persisted "error code lookup" table, only 014's in-memory Prometheus counter, which is NOT queryable historically. Resolved by adding a durable audit read instead: see §6. |
### Support (FR-003)
| Figure | Source |
|---|---|
| Per-agent workload | `Assignment.groupBy(['agentId'], { isCurrent: true })` — a snapshot of *right now*, not date-ranged (workload is inherently current, not historical — spec.md's own framing: "how much work is currently assigned") |
| SLA risk / breached | `SLARun.findMany({ status: 'running', resolutionDueAt: {gte: now} })` filtered in application code by "due within `SLA_RISK_THRESHOLD_MINUTES` of now" for risk, vs. `status: 'breached'` for already-breached |
| Escalation count | Same as Management, unfiltered by product |
| Response/resolution performance | Same computation as Management's averages |
### AI (FR-004)
| Figure | Source |
|---|---|
| AI resolution rate / human-handoff rate | `AISupportSession.groupBy(['status'], { startedAt in range })``resolved` vs. `escalated`/`ended_by_agent` as a share of total terminal sessions |
| Failed-troubleshooting-then-escalated rate | Sessions with `status: 'escalated'` that have at least one `AIInteraction`/`AIAction` recording a failed troubleshooting attempt — see 005-ai-support's own runbook-step-outcome classification (`classifyStepOutcome`), reused rather than reinvented |
| Knowledge-match rate | `AIKnowledgeReference` presence per session (`recordMany` is only ever called with actual retrieval results — 005-ai-support's own `diagnose.ts`) vs. sessions with zero references recorded |
| Confidence distribution | `AIDiagnosis.findMany({ createdAt in range })`, bucketed in application code against `aiConfig.defaultHighConfidence`/`defaultLowConfidence` (see §7 — NOT a per-diagnosis resolved policy) |
| Tool success/failure | `AIAction` joined to `AIActionResult`, grouped by `result.status` |
## 3. "No data" convention (FR-007)
**Decision**: every rate/average field is `number | null``null` means "no qualifying records
in range," distinguished in the response shape from a genuine `0` (e.g., a real 0% AI resolution
rate because everything escalated is a valid, meaningful `0`; "nobody's data exists yet" is
`null`). Application code computes every average by fetching the qualifying count first and
returning `null` before ever dividing, never relying on `0/0` producing `NaN` and hoping a caller
notices.
## 4. "Who resolved it" — reused from 014, not reinvented
014-full-observability's own event subscriber already established the authoritative rule: a
ticket's `Resolution.resolvedBy` field (`"ai"` | an `agentId`) is the single source of truth for
whether a resolution was AI- or human-driven (014 research.md §5). This feature's Management/
Product dashboards reuse the exact same join (`Resolution.findMany` scoped to the range,
`resolvedBy === 'ai'` vs. not) rather than re-deriving it from `Ticket.status` transitions a
second, potentially-inconsistent way.
## 5. No raw SQL
**Decision**: every duration average (response time, resolution time) is computed by fetching
the bounded set of qualifying rows (ticket `createdAt` + the milestone timestamp) via Prisma and
averaging in application code, not a raw `$queryRaw` computing `AVG(EXTRACT(EPOCH FROM ...))` in
SQL. At the data volumes spec.md's Assumptions accept for this first cut (no pre-aggregation,
synchronous queries), a bounded per-range fetch is simple, type-safe, and testable without
hand-writing SQL — consistent with this codebase's near-total avoidance of `$queryRaw` elsewhere
(confirmed by grep: no existing module uses it for reporting-shaped queries).
## 6. Top errors needs a durable, queryable record — a real gap 014 left open
014-full-observability's `supporthub_known_error_lookups_total` Prometheus counter is
process-lifetime, in-memory, and reset on every restart — useless for "top errors in the last 30
days." Since no durable "error code lookup" record exists anywhere in this codebase today (the
existing `error-codes.service.ts` just reads `KnownIssue`/`ErrorCode` rows, never records that a
lookup happened), this feature adds one small, focused piece of new state: a durable
`ErrorCodeLookup` audit row (`errorCodeId`, `productId`, `createdAt`), written by
`error-codes.service.ts`'s already-existing `findKnownIssuesByErrorCode` (the same call site
014 instrumented for its own live counter — this feature adds one more line there, a durable
write alongside the existing live-metric increment, not a replacement for it). This is the one
schema change this feature needs; every other dashboard figure is computed from tables that
already exist.
## 7. Confidence distribution uses the system default threshold, not a per-diagnosis policy
**Decision**: bucket every `AIDiagnosis.confidence` value in range against the env-configured
system-wide defaults (`aiConfig.defaultHighConfidence`/`defaultLowConfidence`), the same
`decideConfidenceBand` pure function 005-ai-support already exports — reused directly, not
reimplemented.
**Why not resolve each diagnosis's actual applicable per-product/category policy** (what the
live reasoning path itself does): `AIDiagnosis.product`/`feature` are the AI's own free-text
classification output, not foreign keys to `Product`/`Category` — there is no reliable, existing
join from a diagnosis row back to which `ConfidencePolicy` row actually applied to it at the time
without speculatively string-matching free text against product names, which this codebase does
nowhere else and which research.md declines to invent here. A dashboard-level aggregate
distribution using the system-wide default is an honest, documented simplification (spec.md
Assumptions) — precise enough to show a meaningful shape without fabricating a false precision
the data doesn't actually support.
## 8. New configuration (Constitution Principle II — nothing hardcoded)
| Env var | Default | Used by |
|---|---|---|
| `REPORTING_DEFAULT_WINDOW_DAYS` | `30` | Every dashboard's `from`/`to` default when omitted (FR-005) |
| `REPORTING_SLA_RISK_THRESHOLD_MINUTES` | `60` | Support dashboard's "at risk" classification (FR-003) |
| `REPORTING_TOP_N_LIMIT` | `10` | Product dashboard's recurring-problems/top-errors ranking length |
## 9. Test strategy
Integration tests create real tickets/problems/SLA runs/escalation events/AI sessions/diagnoses/
actions/error-code lookups directly against real Postgres (mixing real HTTP-driven setup where a
realistic flow matters and direct repository/Prisma writes where only the aggregation math is
under test — the same mix 014's own `business-metrics.test.ts` used), then request each
dashboard endpoint and assert every figure against hand-computed expected values. Unit tests
cover the "no data → null, never NaN" guard and the confidence-bucketing pure-function reuse.
+257
View File
@@ -0,0 +1,257 @@
# Feature Specification: Reporting and Analytics Dashboards
**Feature Branch**: `015-reporting-dashboards`
**Created**: 2026-09-09
**Status**: Draft
**Input**: User description: "Reporting and analytics dashboards: real, read-only aggregation endpoints backing the four dashboards named in docs/09-testing-observability-cicd.md (Management, Product, Support, AI) — wiring up the pre-scaffolded but never-implemented platform/reports module into actual database-backed aggregation queries, admin-gated, with a date-range filter."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Management sees organization-wide support health (Priority: P1)
An admin or team lead opens a single view showing how support is doing overall for a chosen
period: how many cases came in, how many were resolved (by AI vs. by a human), how many are
still open, whether SLA commitments are being met, and how escalation is trending.
**Why this priority**: This is the one dashboard covering the whole roadmap's own top-level
success criteria (`docs/10-implementation-roadmap.md`'s checklist) in one place — the first
thing anyone asks about a support operation is "how are we doing," and today there is no way to
answer that except querying the database by hand.
**Independent Test**: Can be fully tested by creating a known set of tickets in various terminal
states (AI-resolved, human-resolved, still open) plus a mix of met/breached SLA runs and
escalations within a chosen date range, then requesting the Management dashboard for that range
and confirming every figure matches what was actually created.
**Acceptance Scenarios**:
1. **Given** a mix of tickets created within a chosen date range — some AI-resolved, some
human-resolved, some still open — **When** the Management dashboard is requested for that
range, **Then** total cases, AI-resolved count, human-escalated count, resolved count, and
open count all match the actual data exactly.
2. **Given** SLA runs that completed on time and others that breached within the range,
**When** the dashboard is requested, **Then** SLA compliance (a rate) and SLA breach count
both reflect the real outcomes.
3. **Given** some tickets have a recorded first agent response and a resolution timestamp,
**When** the dashboard is requested, **Then** average response time and average resolution
time are computed only from tickets that actually reached those milestones within the range
(a still-open ticket contributes to "open count" but never a fabricated resolution time).
4. **Given** a date range with zero activity, **When** the dashboard is requested, **Then** every
count is zero and every average is reported as "no data" rather than a computed zero or a
division-by-zero error.
---
### User Story 2 - See support broken down by product (Priority: P1)
An admin viewing support data for a specific product (or comparing products) sees volume,
problem-type breakdown, which problems recur most, how well AI is resolving that product's
issues versus escalating them, and which error codes come up most often.
**Why this priority**: SupportHub serves multiple SaaS products (Constitution Principle I); a
number that isn't broken out by product hides which integration actually needs attention — this
is as fundamental as the Management view, just sliced differently.
**Independent Test**: Can be fully tested by creating tickets/problems/error-code lookups across
two distinct products within a date range, requesting the Product dashboard for each product,
and confirming each one's figures include only its own product's data.
**Acceptance Scenarios**:
1. **Given** tickets exist for two different products in the same date range, **When** the
Product dashboard is requested scoped to one product, **Then** support volume and every other
figure reflect only that product's tickets, never the other product's.
2. **Given** problems in several categories for one product, **When** the dashboard is
requested, **Then** the problem-type breakdown and "recurring problems" ranking both reflect
the real category distribution, most-frequent first.
3. **Given** a mix of AI-resolved and human-escalated tickets for one product, **When** the
dashboard is requested, **Then** AI resolution rate and human escalation rate are both
computed as a percentage of that product's own total, not the platform-wide total.
4. **Given** several known-error-code lookups for one product, some codes looked up more than
others, **When** the dashboard is requested, **Then** "top errors" lists those codes ranked by
lookup frequency.
---
### User Story 3 - Support sees team workload and performance (Priority: P2)
An admin or team lead sees how much work is currently assigned across agents, which tickets are
at SLA risk, how much escalation is happening, and how quickly the team is responding to and
resolving tickets.
**Why this priority**: This view is about ongoing operational load, not historical trend — useful
for day-to-day team management, but the organization can already see whether it's healthy
overall from User Story 1 without this one; P2 reflects that it adds an operational lens rather
than a new class of information.
**Independent Test**: Can be fully tested by assigning several tickets to known agents (some
close to SLA breach, some not), then requesting the Support dashboard and confirming workload
per agent and the SLA-risk count both match reality.
**Acceptance Scenarios**:
1. **Given** several tickets are currently assigned across two agents, **When** the Support
dashboard is requested, **Then** each agent's current open-assignment count matches what was
actually assigned to them (not a stale count from a previous, now-unassigned period).
2. **Given** a ticket's SLA run is running and past a configurable risk threshold of its
resolution due date (but not yet breached), **When** the dashboard is requested, **Then** it
is counted as "at risk," distinct from both "on track" and "breached."
3. **Given** response and resolution durations for several resolved tickets in the period,
**When** the dashboard is requested, **Then** response-performance and resolution-performance
figures are computed only from tickets that actually reached those milestones.
---
### User Story 4 - See how well the AI is performing (Priority: P2)
An admin sees, for a chosen period, how often the AI resolves issues on its own versus escalating
them, how often its attempted troubleshooting fails outright, how often it finds relevant
knowledge, how confident its diagnoses tend to be, how reliably its tools succeed, and how often
it ultimately hands off to a human.
**Why this priority**: This is the dashboard that validates the AI-first design's core premise
(Constitution Principle IV) is actually working in practice — valuable, but a narrower audience
than the org-wide and per-product views above, hence P2.
**Independent Test**: Can be fully tested by running several AI sessions to different terminal
outcomes (resolved, escalated, escalated-after-failed-troubleshooting) with a mix of tool
successes/failures and confidence levels recorded, then requesting the AI dashboard and
confirming every figure matches the real session data.
**Acceptance Scenarios**:
1. **Given** a mix of AI sessions ending resolved vs. escalated in the period, **When** the AI
dashboard is requested, **Then** AI resolution rate and human-handoff rate both reflect the
real outcome mix as percentages of total sessions.
2. **Given** some AI tool invocations succeeded and others failed in the period, **When** the
dashboard is requested, **Then** tool success/failure figures reflect the real invocation
outcomes.
3. **Given** diagnoses were recorded with a range of confidence values, **When** the dashboard is
requested, **Then** the confidence distribution groups them into the same proceed/ask/escalate
bands the AI support module's own confidence-policy service already classifies each diagnosis
into (005-ai-support), not a newly-invented scheme.
4. **Given** some AI sessions' knowledge-retrieval step found matching entries and others found
none, **When** the dashboard is requested, **Then** knowledge-match rate reflects the real
match/no-match mix.
---
### Edge Cases
- What happens when no `from`/`to` date range is given? Defaults to a reasonable trailing window
(see Assumptions) rather than scanning the entire history unbounded on every request.
- What happens when `from` is after `to`? Rejected as a validation error, not silently swapped or
silently returning empty data.
- What happens when a requested `productId` (Product dashboard) doesn't exist? Rejected with a
clear not-found error, not an empty-but-200 response that looks like "this product has zero
activity."
- What happens when an average would divide by zero (no tickets reached that milestone in the
range)? Reported as an explicit "no data" value, never `NaN`, `null` silently coerced to `0`,
or a thrown error.
- What happens when a ticket's SLA run was paused for part of the period? SLA-risk/compliance
figures use the run's own already-durable due dates (008-sla-escalation's pause/resume
already accounts for paused time) rather than this feature re-deriving elapsed time itself.
- Who can see these dashboards? Same admin-only gate as every other admin configuration/reporting
surface introduced since 010-identity-auth — no new role is introduced.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST provide a Management dashboard summarizing, for a given date range:
total cases created, cases resolved by AI, cases escalated to a human, total resolved
(either path), total still open, SLA compliance rate, SLA breach count, escalation count,
average first-response time, and average resolution time.
- **FR-002**: System MUST provide a Product dashboard summarizing, for a given date range and a
specific product: support volume, a breakdown by problem category, a ranked list of the most
recurring problem categories, AI resolution rate, human escalation rate, and a ranked list of
the most frequently looked-up error codes.
- **FR-003**: System MUST provide a Support dashboard summarizing, for a given date range:
current per-agent open-assignment workload, count of tickets at SLA risk (past a configurable
risk threshold of their resolution due date but not yet breached), count of tickets already
breached, escalation count, average response performance, and average resolution performance.
- **FR-004**: System MUST provide an AI dashboard summarizing, for a given date range: AI
resolution rate, rate of sessions that escalated after at least one failed troubleshooting
attempt, knowledge-match rate, a distribution of diagnosis confidence across the existing
proceed/ask/escalate bands, tool invocation success/failure counts, and human-handoff rate.
- **FR-005**: Every dashboard endpoint MUST accept an optional `from`/`to` date range; when
omitted, it MUST default to a documented trailing window rather than scanning unbounded
history.
- **FR-006**: The Product dashboard MUST require a valid `productId` and MUST reject an unknown
one with a clear not-found error rather than returning an empty-but-successful response.
- **FR-007**: Every rate/average figure MUST be computed only from tickets/sessions/runs that
actually reached the relevant milestone within the range; a metric with no qualifying data MUST
be reported as an explicit "no data" value, never a computed `0`, `null`, or `NaN`.
- **FR-008**: All four dashboard endpoints MUST be admin-gated, consistent with every other
admin-only reporting/configuration surface in this codebase.
- **FR-009**: This feature MUST NOT alter the meaning or shape of any existing endpoint, event, or
table — nearly every figure is derived read-only from data already durably recorded by the
modules that own it (003 ticketing, 005 AI support, 007 orchestration, 008 SLA/escalation, 009
problem resolution). The one exception is FR-011: a small new durable record needed only
because no existing table can answer "which error codes are looked up most" historically.
- **FR-011**: System MUST durably record each known-error-code lookup (product, error code,
timestamp) at the point it already happens (the existing error-code lookup call site) so the
Product dashboard's "top errors" ranking (FR-002) can be computed historically — the
equivalent live, in-process counter this project already exposes on `/metrics` (014-full-
observability) is process-lifetime and reset on every restart, unusable for a historical
dashboard.
- **FR-010**: This feature is backend-only; presenting these figures in a UI is a separate,
explicitly out-of-scope follow-on (see Assumptions).
### Key Entities
- **Dashboard response**: A read-only, computed JSON summary for one of the four dashboards over
a requested date range (and, for the Product dashboard, one product) — never itself persisted;
recomputed fresh on every request from existing durable records.
- **Date range**: An inclusive `from`/`to` pair (calendar dates or timestamps) scoping every
aggregation query; not a stored entity, a request parameter.
- **Error code lookup record** (new, FR-011): a durable, append-only audit row — which product,
which error code, when — written at the existing lookup call site; exists solely so "top
errors" can be computed over a historical range, never read or written anywhere else.
- **Confidence band**: The existing proceed/ask/escalate classification 005-ai-support already
applies to a diagnosis's confidence score — reused here for the AI dashboard's distribution, not
redefined.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: For any chosen date range, every figure on all four dashboards can be independently
verified against the underlying ticket/session/SLA-run/escalation-event records and matches
exactly — no discrepancy between what a dashboard reports and what actually happened.
- **SC-002**: An admin can answer "how is support doing right now" (Management), "how is this
specific product doing" (Product), "who's overloaded and what's at risk" (Support), and "is the
AI actually helping" (AI) each from a single request, with no manual database query needed.
- **SC-003**: A dashboard request for a period with no matching activity returns clean, explicit
"no data" results in well under a second — never an error, a stall, or a misleading zero.
## Assumptions
- **Presentation is out of scope for this feature.** The user's own explicit direction was to
build the backend aggregation capability first (the established pattern this project has
followed for every prior feature that touched both repos — identity/auth, the agent ticket
queue, and full observability were each built backend-first). A `supporthub-web` dashboard UI
consuming these endpoints is a natural, separate follow-on, not bundled into this spec.
- The default trailing window when no date range is given is the last 30 days, matching common
reporting-dashboard convention; CONFIGURABLE via the same admin-config env-driven pattern this
project already uses for every other business-policy value (Constitution Principle II), not
hardcoded as a magic number in application logic.
- "SLA risk" needs a threshold (how close to the due date counts as "at risk") that the business
has not specified — CONFIGURABLE, not invented as a hardcoded percentage, consistent with
`docs/10-implementation-roadmap.md`'s own "never hardcode a placeholder value and ship it as
final" instruction.
- These endpoints compute their figures synchronously, on request, directly from the existing
tables — no new pre-aggregation table, no scheduled batch job, and no use of the pre-scaffolded
`ANALYTICS` queue (`src/jobs/analytics`), which remains an inert stub outside this feature's
scope. Live query performance at current data volumes is assumed adequate; a future feature can
introduce pre-aggregation if and when it's actually needed (load/concurrency testing, a
separate not-yet-started Phase 11 sub-area, is where that question would be validated).
- "Top errors"/"recurring problems" rankings return a bounded top-N list (CONFIGURABLE limit,
defaulting to 10) rather than the full distribution, matching how a dashboard is actually
consumed.
- Dashboard responses are computed fresh per request (no caching layer) — acceptable given the
assumed data volumes and consistent with not prematurely optimizing ahead of the load-testing
phase.
+188
View File
@@ -0,0 +1,188 @@
---
description: 'Task list for 015-reporting-dashboards'
---
# Tasks: Reporting and Analytics Dashboards
**Input**: Design documents from `specs/015-reporting-dashboards/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/reports-api-contract.md](./contracts/reports-api-contract.md),
[quickstart.md](./quickstart.md)
**Organization**: Tasks are grouped by user story (US1 = P1 Management, US2 = P1 Product,
US3 = P2 Support, US4 = P2 AI). All four share the Foundational phase (schema, config, shared
helpers, module scaffolding) but are otherwise independent of each other.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 Add `REPORTING_DEFAULT_WINDOW_DAYS` (default `30`),
`REPORTING_SLA_RISK_THRESHOLD_MINUTES` (default `60`), and `REPORTING_TOP_N_LIMIT`
(default `10`) to `src/config/env.ts`, exposed via a new `reportingConfig` in
`src/config/reporting.ts` (or added to an existing config file, matching this codebase's
own per-feature config-file convention)
- [x] T002 Add the `ErrorCodeLookup` model to `prisma/schema.prisma` per data-model.md, generate
the migration via `prisma migrate diff --from-url <db-url> --to-schema-datamodel
./prisma/schema.prisma --script`, hand-write it into
`prisma/migrations/<timestamp>_add_error_code_lookup/migration.sql`, apply via `prisma
migrate deploy` against the throwaway test database (depends on T001 only in that both
are Foundational — no code dependency)
- [x] T003 Add `ai-support/knowledge/repository/error-code-lookup.repository.ts`
`create(errorCodeId, productId)`, exported from the knowledge module's repository index
(depends on T002)
- [x] T004 [P] Call the new repository's `create(...)` from
`ai-support/knowledge/service/error-codes.service.ts`'s existing
`findKnownIssuesByErrorCode`, alongside (not replacing) 014's own
`knownErrorLookupsCounter.inc(...)` call at that same call site (depends on T003)
- [x] T005 [P] Add `platform/reports/mapper/date-range.ts` — parses/validates `from`/`to` query
params, defaulting via T001's `reportingConfig.defaultWindowDays`, throwing
`ValidationError` when `from > to` (depends on T001)
- [x] T006 [P] Add `platform/reports/mapper/rate.ts` — a shared `computeRate(numerator,
denominator): number | null` and `computeAverageSeconds(durations: number[]): number |
null` pair, both returning `null` (never `NaN`/`0`) when there's no qualifying data
(research.md §3) — no dependency, pure functions
- [x] T007 Scaffold `platform/reports/schema/` (query-param zod schema using T005's date-range
parsing), `platform/reports/controller/reports.controller.ts` (empty methods to be filled
in per user story below), `platform/reports/routes/reports.routes.ts` registering all four
routes behind `requireRole('ADMIN')`, and update `platform/reports/index.ts` to export the
new public surface, replacing `generateSummaryReport`'s stub entirely (depends on T005,
T006)
**Checkpoint**: Config, schema, shared helpers, and module scaffolding in place. Each dashboard
can now be built independently.
---
## Phase 2: User Story 1 - Management sees organization-wide support health (Priority: P1)
**Goal**: `GET /admin/reports/management` returns real figures per data-model.md's
`ManagementDashboard` shape.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T008 [P] [US1] Unit tests for T006's `computeRate`/`computeAverageSeconds` (empty input ->
`null`; a real mix -> the correct value) in
`tests/unit/platform/reports/rate-helpers.test.ts`
### Implementation for User Story 1
- [x] T009 [US1] Add `platform/reports/repository/management.repository.ts` — one method per
research.md §2's Management table row (ticket counts by status, SLA-run outcome counts,
response/resolution duration row-fetches for T006 to average) (depends on T007)
- [x] T010 [US1] Add `ReportsService.getManagementDashboard(range)` composing T009's repository
calls into the `ManagementDashboard` shape, reusing the `Resolution.resolvedBy` convention
(research.md §4) for the AI-vs-human split (depends on T009)
- [x] T011 [US1] Wire `GET /admin/reports/management` to the controller/service (depends on T010)
- [x] T012 [US1] Integration test covering Quickstart Scenario 1 (real tickets in various
terminal states, a met and a breached SLA run, verified figure-by-figure; a no-activity
range returns all-zero counts and all-null rates) in
`tests/integration/platform-reports/management-dashboard.test.ts` (depends on T011)
**Checkpoint**: Quickstart Scenario 1 passes.
---
## Phase 3: User Story 2 - See support broken down by product (Priority: P1)
**Goal**: `GET /admin/reports/product/:externalProductId` returns real figures per
`ProductDashboard`.
**Independent Test**: Quickstart Scenario 2.
### Implementation for User Story 2
- [x] T013 [P] [US2] Add `platform/reports/repository/product.repository.ts` — ticket/problem
queries scoped by `productId`, plus a query against T003's `ErrorCodeLookup` table for
the top-N ranking (`reportingConfig.topNLimit`) (depends on T007)
- [x] T014 [US2] Add `ReportsService.getProductDashboard(externalProductId, range)`, 404-ing via
`NotFoundError` when the product doesn't resolve (FR-006) before running any aggregation
query (depends on T013)
- [x] T015 [US2] Wire `GET /admin/reports/product/:externalProductId` (depends on T014)
- [x] T016 [US2] Integration test covering Quickstart Scenario 2 (two products' data never
cross-contaminating each other's figures; an unknown product 404s) in
`tests/integration/platform-reports/product-dashboard.test.ts` (depends on T015)
**Checkpoint**: Quickstart Scenario 2 passes.
---
## Phase 4: User Story 3 - Support sees team workload and performance (Priority: P2)
**Goal**: `GET /admin/reports/support` returns real figures per `SupportDashboard`.
**Independent Test**: Quickstart Scenario 3.
### Implementation for User Story 3
- [x] T017 [P] [US3] Add `platform/reports/repository/support.repository.ts` — current
`Assignment` workload-by-agent query, `SLARun` at-risk/breached queries (`resolutionDueAt`
within `reportingConfig.slaRiskThresholdMinutes` of now, per research.md §2) (depends on
T007)
- [x] T018 [US3] Add `ReportsService.getSupportDashboard(range)` (depends on T017)
- [x] T019 [US3] Wire `GET /admin/reports/support` (depends on T018)
- [x] T020 [US3] Integration test covering Quickstart Scenario 3 (real per-agent assignment
counts; a near-due-but-not-breached run counted as at-risk, distinct from breached) in
`tests/integration/platform-reports/support-dashboard.test.ts` (depends on T019)
**Checkpoint**: Quickstart Scenario 3 passes.
---
## Phase 5: User Story 4 - See how well the AI is performing (Priority: P2)
**Goal**: `GET /admin/reports/ai` returns real figures per `AiDashboard`.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T021 [P] [US4] Unit test: the confidence-distribution bucketing reuses
`decideConfidenceBand` (005-ai-support) against `aiConfig` defaults, not a reimplemented
threshold check, in `tests/unit/platform/reports/confidence-distribution.test.ts`
### Implementation for User Story 4
- [x] T022 [US4] Add `platform/reports/repository/ai.repository.ts` — `AISupportSession` outcome
counts, `AIDiagnosis` confidence fetch, `AIKnowledgeReference` presence-per-session query,
`AIAction`/`AIActionResult` outcome counts (depends on T007)
- [x] T023 [US4] Add `ReportsService.getAiDashboard(range)`, bucketing confidence via
`decideConfidenceBand` + `aiConfig.defaultHighConfidence`/`defaultLowConfidence`
(research.md §7) (depends on T022, T021)
- [x] T024 [US4] Wire `GET /admin/reports/ai` (depends on T023)
- [x] T025 [US4] Integration test covering Quickstart Scenario 4 (real AI sessions to mixed
outcomes, mixed tool results, a spread of diagnosis confidence values) in
`tests/integration/platform-reports/ai-dashboard.test.ts` (depends on T024)
**Checkpoint**: Quickstart Scenario 4 passes. All four dashboards work independently and
together — this feature's full scope.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T026 [P] Update `specs/015-reporting-dashboards/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T027 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T028 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
(particularly `error-codes.service.ts`'s own existing tests, now touched by T004)
---
## Dependencies & Execution Order
- **Foundational (Phase 1)**: No dependencies — BLOCKS all four user stories
- **User Story 1 (Phase 2)**: Depends on Foundational — independent of US2/US3/US4
- **User Story 2 (Phase 3)**: Depends on Foundational — independent of US1/US3/US4
- **User Story 3 (Phase 4)**: Depends on Foundational — independent of US1/US2/US4
- **User Story 4 (Phase 5)**: Depends on Foundational — independent of US1/US2/US3
- **Polish (Phase 6)**: Depends on all four user stories
@@ -0,0 +1,80 @@
# Specification Quality Checklist: Load and Concurrency Testing
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This feature was scoped from a targeted codebase audit (not guesswork) confirming which
concurrency guarantees already exist untested (ticket optimistic concurrency) versus which
have no protection at all today (assignment double-assignment, SLA pause/resume, escalation
idempotency) — see spec.md's own Assumptions section.
- Per this project's own roadmap convention, exact load-test pass/fail thresholds are left as an
explicit `OPEN BUSINESS DECISION` (FR-009) rather than invented — this is intentional, not a
gap requiring [NEEDS CLARIFICATION].
- All items pass; no revision iterations were needed.
## Implementation-time findings
- **All three suspected real races were confirmed real, then fixed.** Before the fix, firing 20
genuinely concurrent assignment attempts at the same ticket reliably threw an unhandled
Postgres unique-constraint error once the new `assignments_one_current_per_ticket` partial
index was in place (proving the race existed even before the retry logic was added) — after
the fix (bounded retry with jitter in `AssignmentRepository.createAssignment`), it holds
consistently across 10 repeated runs. Escalation idempotency was proven the same way: the
database-level unique-violation is visibly caught and absorbed in the logs during the test,
confirming the fix actually engages under a genuine race rather than sitting untested.
- **The ticket-status optimistic-concurrency mechanism (User Story 4) needed no fix** — proven
correct on the first run, exactly as research.md's Assumptions predicted.
- **A real, pre-existing test-infrastructure issue was found and resolved along the way**: the
throwaway integration-test Postgres database had accumulated a very large number of tickets
over this project's long development history, and the ticket-code generator's own
documented "rare race between two concurrent creates" (a read-then-increment sequence number
scoped by code prefix) became a frequent occurrence at that accumulated volume — manifesting
as dozens of unrelated integration-test failures when the full suite ran, unrelated to any
change in this feature. Confirmed by direct reproduction (a debug run showing the literal
`Unique constraint failed on the fields: (code)` error) and by re-running the exact same
suite cleanly (122/124 passing, matching the project's known accepted baseline) after
dropping and recreating the throwaway database and replaying its full migration history
(`prisma migrate deploy`, 12 migrations including this feature's own). This is a test-
infrastructure hygiene finding, not a defect in this feature's own code.
- **Two additional integration-test failures seen only in the full-suite run (never in
isolation)** were confirmed to be pre-existing cross-file contamination inherent to this
suite's shared-database, non-fully-isolated hierarchy/agent scoping (already acknowledged in
comments elsewhere in the suite, e.g. sla-escalation-flow.test.ts's own note about a
wildcard SLA policy leaking across concurrently-running files) — re-running the two affected
files together in isolation passed cleanly (13/13), ruling out this feature's own changes as
the cause.
- The autocannon-based load-test tooling (User Story 5) surfaced a real, non-obvious cost
consideration: ticket creation asynchronously triggers a real, billed Anthropic API call for
that ticket's first AI diagnosis turn (005-ai-support) — this applies to both the
ticket-creation and AI-support-flow load scripts, not only the latter as initially assumed.
All three scripts were run once at a small, explicitly bounded scale (confirmed with the
project owner beforehand) rather than an open-ended duration, specifically to keep this real
cost small and predictable.
@@ -0,0 +1,63 @@
# Data Model: Load and Concurrency Testing
All changes below are additive to existing models — no existing column is removed or
retyped, and no existing consumer (012-admin-list-views, 015-reporting-dashboards) needs any
change, since none of them write to `Assignment`/`SLARun`/`EscalationEvent` directly (all writes
already go through the repositories being changed here).
## `SLARun` (existing model, one new field)
| Field | Type | Notes |
|---|---|---|
| `version` | `Int @default(0)` | NEW. Optimistic-concurrency counter, identical convention to `Ticket.version` (003-ticketing). Incremented on every successful `updateWithVersion` call. |
Migration: additive `ALTER TABLE sla_runs ADD COLUMN version INTEGER NOT NULL DEFAULT 0;`
every existing row defaults to `0`, which is exactly the version any in-flight or future
`updateWithVersion` call expects for a run nobody has updated since this migration ran.
## `Assignment` (existing model, no column change — one new index)
New raw partial unique index (Prisma schema DSL cannot express a partial predicate directly, so
this is added via a raw-SQL migration step, same approach already used elsewhere in this
project for Postgres-specific constraints):
```sql
CREATE UNIQUE INDEX assignments_one_current_per_ticket
ON assignments (ticket_id)
WHERE is_current = true;
```
Enforces at the database level: a ticket may have at most one `Assignment` row with
`isCurrent = true` at any moment, closing the race research.md §1 describes. The existing
non-unique `@@index([ticketId, isCurrent])` is unaffected and stays for the repository's own
`findCurrent` lookup.
## `EscalationEvent` (existing model, no column change — one new index)
```sql
CREATE UNIQUE INDEX escalation_events_ticket_rule_unique
ON escalation_events (ticket_id, rule_id)
WHERE rule_id IS NOT NULL;
```
Enforces at the database level: a given rule may fire at most once per ticket over that
ticket's lifetime (manual escalations, where `rule_id IS NULL`, are explicitly excluded and
remain repeatable). Closes the race research.md §3 describes.
## Repository contract changes
### `SlaRunRepository`
- `update(id, data)`**replaced** by `updateWithVersion(id, expectedVersion, data): Promise<SLARun | null>`, mirroring `TicketsRepository.updateStatus`'s exact shape: an atomic `updateMany({where:{id, version: expectedVersion}, data:{...data, version:{increment:1}}})`, returning the fresh row on success (count === 1) or `null` on a stale-version mismatch. Every existing call site (`pause`, `resume`, `complete`, `runBreachDetectionSweep`) is updated to pass its own last-read `version` and to retry (re-read + recompute + re-call) up to 3 times on a `null` result before giving up silently (matching the sweep's own existing no-throw, best-effort style — these are internal transitions with no HTTP caller waiting on a 409).
### `AssignmentRepository`
- `createAssignment(data)` — same signature and return type; internally catches a Prisma `P2002` on the new `assignments_one_current_per_ticket` index and retries the entire transaction (bounded to 3 attempts) before rethrowing.
### `EscalationEventRepository`
- `create(data)` — same signature; internally catches a Prisma `P2002` on the new `escalation_events_ticket_rule_unique` index and returns the pre-existing row for that `(ticketId, ruleId)` pair (a `findFirst({where:{ticketId, ruleId}})` fallback) instead of throwing, so `EscalationService.fire`'s caller sees a normal `EscalationEvent` either way — a duplicate trigger is invisible to the caller, not an error.
## Test-only entities (not persisted — in-memory test scaffolding)
- **Load test report** (`tests/load/`): `{ endpoint: string; connections: number; durationSec: number; requestsPerSec: number; latencyP50Ms: number; latencyP90Ms: number; latencyP99Ms: number; non2xxCount: number; rateLimitedCount: number }` — printed to console and written as JSON under `tests/load/reports/<endpoint>-<timestamp>.json` (gitignored) for each run, satisfying FR-008's separation of rate-limited responses from genuine failures.
+132
View File
@@ -0,0 +1,132 @@
# Implementation Plan: Load and Concurrency Testing
**Branch**: `016-load-concurrency-testing` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/016-load-concurrency-testing/spec.md`
## Summary
Prove — with real, genuinely-concurrent requests against real Postgres/Redis, never mocked
timers — three concurrency guarantees that a prior codebase audit found are NOT currently held
(assignment double-assignment, SLA pause/resume/sweep races, escalation duplicate-event risk),
fix each real race the tests reveal with a minimal, idiomatic DB-level guard consistent with
this codebase's existing patterns, add one new concurrency test proving the existing ticket
optimistic-concurrency guarantee holds under genuine concurrency, and add repeatable
`autocannon`-based HTTP load-test tooling for the three named critical endpoint groups.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js >=20
**Primary Dependencies**: Fastify 4.26, Prisma, ioredis/BullMQ, Vitest (existing stack — no new
runtime dependency for the concurrency tests); `autocannon` added as a new devDependency for the
load-test tooling (pure npm package, no external binary, scriptable in TS, matches this
project's existing Node-native toolchain rather than introducing a separate Go binary like k6)
**Storage**: PostgreSQL via Prisma (existing `Assignment`, `SLARun`, `EscalationEvent` models —
one additive schema change per race fix, see data-model.md), Redis (existing, unchanged)
**Testing**: Vitest, run against the existing throwaway Docker Postgres/Redis
(`supporthub-test-pg`/`supporthub-test-redis`) already used by `tests/concurrency/`; load tests
run with `autocannon` against a real running instance of the dev server
**Target Platform**: Linux/Windows server (existing deployment target, unchanged)
**Project Type**: Backend service (existing modular monolith, unchanged)
**Performance Goals**: NEEDS CLARIFICATION resolved in research.md — no business-specified
throughput/latency targets exist yet; FR-009 requires these be marked `OPEN BUSINESS DECISION`
rather than invented, so this feature ships tooling + a baseline report, not a numeric SLA
**Constraints**: Every fix must be additive/backward-compatible (no breaking change to existing
Assignment/SLARun/EscalationEvent consumers — 012-admin-list-views and 015-reporting-dashboards
both already query these tables); every concurrency claim must be proven against real
Docker-provisioned infrastructure per this project's standing verification discipline, never
asserted from code review alone
**Scale/Scope**: 3 real races to prove-and-fix (assignment, SLA, escalation), 1 race to prove
already-safe (ticket status), 3 endpoint groups to load-test (ticket creation, AI support flow,
admin reporting) — entirely within `supporthub-api`, no `supporthub-web` changes
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Check | Status |
|---|---|---|
| I. SaaS Is Sole Identity Authority | N/A — no identity/tenant/product-access logic touched | PASS |
| II. Configuration Over Hardcoding | Load-test pass/fail thresholds are NOT hardcoded — explicitly marked `OPEN BUSINESS DECISION` per FR-009, matching roadmap convention | PASS |
| III. Layered Architecture / Module Boundaries | All three fixes stay inside their owning module (`orchestration/assignments`, `orchestration/sla`, `orchestration/escalation`) — repository-layer changes only, no new cross-module imports | PASS |
| IV. AI Recommends, Policy Decides | N/A — no AI/tool-permission logic touched | PASS |
| V. Evidence-Based Verification | This entire feature IS evidence-based verification — every claimed guarantee must be proven by a real concurrency test against real infra before being considered fixed | PASS (this principle is the feature's own thesis) |
| VI. Durable Audit & History | No audit-log shape changes; EscalationEvent's idempotency fix preserves the existing audit row for the winning attempt, silently no-ops the loser rather than deleting anything | PASS |
| VII. Concurrency-Safe, Durable Job Handling (NON-NEGOTIABLE) | This feature directly implements this principle's own stated requirement ("Assignment and escalation logic MUST be tested under concurrency... job handlers MUST be idempotent") — it is the principle's own overdue test coverage | PASS — this feature exists to close this exact gap |
| VIII. Ticket/Problem Separation | N/A — no Ticket/Problem model changes | PASS |
No violations. No Complexity Tracking entries needed.
## Project Structure
### Documentation (this feature)
```text
specs/016-load-concurrency-testing/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not yet created)
```
No `contracts/` directory: this feature adds no new HTTP endpoints or request/response
contracts — it hardens existing internal behavior and adds test/tooling infrastructure only.
### Source Code (repository root)
```text
prisma/
└── schema.prisma # +1 field (SLARun.version), +2 raw partial
# unique indexes (migration SQL)
src/modules/orchestration/assignments/
├── repository/assignment.repository.ts # createAssignment: catch+retry on the new
# partial-unique-index conflict
└── ... # (engine/service unchanged)
src/modules/orchestration/sla/
├── repository/sla-run.repository.ts # update() becomes version-checked; add
│ updateWithVersion(id, expectedVersion, data)
└── service/sla.service.ts # pause/resume/complete: read-modify-retry
loop on version conflict (bounded attempts)
src/modules/orchestration/escalation/
├── repository/escalation-event.repository.ts # create(): catch the new partial-unique
│ -index conflict, return existing row
└── service/escalation.service.ts # fire(): treat a duplicate-conflict as a
no-op, not an error
tests/concurrency/
├── round-robin.test.ts # existing — untouched
├── queue.test.ts # existing — untouched
├── assignment-race.test.ts # NEW — User Story 1 / FR-001
├── sla-race.test.ts # NEW — User Story 2 / FR-002
├── escalation-idempotency.test.ts # NEW — User Story 3 / FR-003
└── ticket-status-race.test.ts # NEW — User Story 4 / FR-004
tests/load/
├── autocannon.config.ts # NEW — shared runner + report shape
├── ticket-creation.load.ts # NEW — User Story 5 / FR-007, FR-008
├── ai-support-flow.load.ts # NEW
└── admin-reporting.load.ts # NEW
```
**Structure Decision**: Single backend project (existing `supporthub-api` modular monolith).
Fixes live inside their owning module's existing `repository`/`service` files (Principle III);
new tests live in the existing `tests/concurrency/` directory (already established by
round-robin.test.ts) plus a new `tests/load/` directory for the load-test tooling, mirroring the
existing `tests/{unit,integration,e2e,concurrency}` layout with one new sibling rather than
overloading `tests/concurrency/` with non-correctness-proving load scripts.
## Complexity Tracking
*No violations — table omitted.*
@@ -0,0 +1,85 @@
# Quickstart: Load and Concurrency Testing
Manual + automated verification steps for each user story, against real Docker-provisioned
Postgres/Redis — this project's standing rule that a concurrency claim is never accepted from
code review alone.
## Prerequisites
- Throwaway test infra up: `supporthub-test-pg` (host port 5433), `supporthub-test-redis` (host
port 6380) — the same containers `tests/concurrency/round-robin.test.ts` already uses.
- For the load tests (User Story 5) only: a real running instance of the API against the real
dev infra (`postgres-development`/`redis-development`), reachable at
`http://localhost:4501`, plus an ADMIN session token for the reporting endpoints.
## Scenario 1 — Assignment double-assignment race (User Story 1)
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/assignment-race.test.ts`
2. The test creates one ticket, then fires >=20 concurrent `assignmentEngine.assignToSpecificNode`
(or the equivalent orchestration entry point) calls at it against real Postgres.
3. **Expected**: the test itself queries `assignments` directly afterward and asserts exactly
one row has `is_current = true` for that ticket — not just that one HTTP/service call
"won." Repeat the run at least 10 times (or use the test's own internal repeat loop) to
confirm SC-001's "zero exceptions across 10 repeated runs."
4. Before the fix (research.md §1), this test is expected to fail intermittently; after the
fix, it must pass every time.
## Scenario 2 — SLA pause/resume/sweep race (User Story 2)
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/sla-race.test.ts`
2. The test creates a ticket with an active SLA run, then fires concurrent `pause`/`resume`
calls and a `runBreachDetectionSweep()` pass against the same run.
3. **Expected**: the run's final DB state (`status`, `pausedAt`, `resumedAt`, `breachedAt`,
`firstResponseDueAt`, `resolutionDueAt`) is queried directly and asserted internally
consistent — e.g. never `status: 'paused'` with `pausedAt: null`, never a `breached` run
silently reverted to `running` by a racing `resume`. Repeat per SC-002.
## Scenario 3 — Escalation idempotency (User Story 3)
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/escalation-idempotency.test.ts`
2. The test creates a ticket eligible for a specific escalation rule, then calls
`escalationService.handleBreach` (or `fire` via its real trigger path) twice concurrently for
the identical trigger.
3. **Expected**: exactly one `EscalationEvent` row exists afterward for that `(ticketId,
ruleId)` pair, and exactly one `Assignment` row resulted from it (cross-checking Scenario 1's
own guarantee). Repeat per SC-003.
## Scenario 4 — Ticket status optimistic concurrency proof (User Story 4)
1. `REDIS_HOST=localhost REDIS_PORT=6380 npx vitest run tests/concurrency/ticket-status-race.test.ts`
2. The test creates a ticket at a known status/version, then fires >=20 concurrent
`ticketsRepository.updateStatus` calls all starting from that same version.
3. **Expected**: exactly one call returns the updated ticket; every other call returns `null`
(stale-version signal); the ticket's final DB status matches the one call that succeeded.
This is expected to pass on the very first run (spec.md Assumptions) — a failure here would
mean the existing mechanism has a real gap, not that this quickstart step is wrong.
## Scenario 5 — Load/throughput baseline (User Story 5)
1. Ensure the real dev API is running (`npm run dev` against `.env.development`) and reachable.
2. `npx tsx tests/load/ticket-creation.load.ts`
3. `npx tsx tests/load/ai-support-flow.load.ts`
4. `npx tsx tests/load/admin-reporting.load.ts` (needs an ADMIN token — the script signs in
itself using the same seeded admin credentials this project's E2E suite already uses)
5. **Expected**: each script prints a report (requests/sec, `p50`/`p90`/`p99` latency, non-2xx
count, rate-limited count) and writes it to `tests/load/reports/`. There is no pass/fail
assertion on the numbers themselves (FR-009, `OPEN BUSINESS DECISION`) — the check here is
that the tooling runs cleanly end-to-end and produces a comparable, re-runnable report, not
that any specific number is hit.
6. Run the same script twice in a row and confirm the two reports are comparable in shape
(same fields, plausible numbers) — proving SC-005's "consistent-shape output for comparison
across runs."
## What "done" looks like
- All four new `tests/concurrency/*.test.ts` files pass consistently (not flakily) against real
Postgres/Redis, each proving its own user story's guarantee with a direct database assertion,
not just an HTTP response check.
- Every race the audit found (assignment, SLA, escalation) is fixed in the actual repository
code per data-model.md, not merely detected and left alone.
- All three `tests/load/*.load.ts` scripts run cleanly against a real running dev server and
produce a report.
- Full existing quality gate (typecheck, lint, architecture check, full unit + integration
suite) stays green — these fixes touch shared repositories (`Assignment`, `SLARun`,
`EscalationEvent`) already exercised by 007-orchestration-assignment's, 008-sla-escalation's,
012-admin-list-views's, and 015-reporting-dashboards's own existing tests.
@@ -0,0 +1,153 @@
# Research: Load and Concurrency Testing
## 1. Assignment double-assignment race
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
assignments_one_current_per_ticket ON assignments (ticket_id) WHERE is_current = true;` — and
change `AssignmentRepository.createAssignment` to catch the resulting unique-violation (Prisma
`P2002`) and retry the whole supersede-then-create transaction (bounded to 3 attempts, matching
this codebase's existing small-bounded-retry convention), rather than surfacing a raw 500.
**Rationale**: `createAssignment`'s existing transaction (`updateMany({isCurrent:false}) +
create({isCurrent:true})`) is correct in isolation but Postgres's default `READ COMMITTED`
isolation lets two concurrent transactions each see "no current row to supersede" and both
successfully `create` their own `isCurrent:true` row — there is no read-modify-write cycle a
version field could guard here (unlike Ticket/SLARun below), because the operation is a
create, not an update, and a create can't be conditioned on "no matching row exists" atomically
without a DB-level constraint. A partial unique index is the standard, minimal Postgres pattern
for "at most one row matching a predicate" and requires no application-level locking. Retrying
on conflict (rather than failing the second caller outright) preserves current behavior for the
common, non-racing case and correctly resolves the race by making the loser's request apply
*after* the winner's, superseding it — exactly the same "last write wins, but exactly once"
semantics `createAssignment`'s own docstring already promises for the non-concurrent case.
**Alternatives considered**:
- *Explicit `SERIALIZABLE` transaction isolation*: would also detect the race (as a
serialization failure) but requires the exact same catch-and-retry handling as the unique
index approach, adds latency to every assignment (not just racing ones), and does nothing to
prevent the row from ever being duplicated if a future code path creates an Assignment outside
this transaction — a DB constraint is a stronger, more future-proof guarantee.
- *Row-level lock (`SELECT ... FOR UPDATE`) on a per-ticket lock row*: works, but requires
inventing a new lock-row concept for a case Postgres's own partial unique index already solves
natively.
## 2. SLA pause/resume/sweep race
**Decision**: Add `version Int @default(0)` to `SLARun`. Replace `SlaRunRepository.update(id,
data)` with `updateWithVersion(id, expectedVersion, data)`, mirroring
`TicketsRepository.updateStatus`'s existing atomic `updateMany({where:{id, version:
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly. `SlaService.pause`,
`resume`, `complete`, and `runBreachDetectionSweep` each move to a small
read-compute-write-retry loop (bounded to 3 attempts): re-read the run fresh on a version
conflict, recompute the operation's own delta (e.g. resume's `pausedMs` shift) against the fresh
state, and retry the versioned write.
**Rationale**: Every one of `pause`/`resume`/`complete`/the sweep does an unconditional
read-then-`update(run.id, {...})` with no guard — two of these racing (e.g. `resume` and the
sweep evaluating the same run at once) can silently clobber each other: the sweep's own
`update(run.id, {status:'breached', breachedAt: now})` could be overwritten moments later by a
`resume` that read the run *before* the sweep's write and still thinks it's `paused`, un-breaching
a run that was legitimately breached and permanently losing that breach from SLA-compliance
figures — a real, silent correctness bug, not a hypothetical one. `Ticket` already has exactly
this problem solved for its own status field with a `version` counter and an atomic
conditional-update; reusing that identical mechanism (rather than inventing a new one) keeps the
codebase's concurrency idiom singular and matches Principle III's spirit even though it isn't
a cross-module boundary concern.
**Alternatives considered**:
- *Wrap each operation in a Postgres advisory lock keyed by run ID*: works but adds a new
locking primitive to the codebase for a problem the existing version-counter idiom already
solves; rejected for consistency, not because it wouldn't work.
- *A single DB transaction spanning the sweep's read+write for all runs at once*: would only
protect the sweep against itself, not against `pause`/`resume` racing it from an unrelated
request path — doesn't close the actual gap.
## 3. Escalation idempotency
**Decision**: Add a PostgreSQL partial unique index — `CREATE UNIQUE INDEX
escalation_events_ticket_rule_unique ON escalation_events (ticket_id, rule_id) WHERE rule_id IS
NOT NULL;` — and change `EscalationEventRepository.create` (called from
`EscalationService.fire`) to catch the resulting `P2002` and return the already-existing event
for that `(ticketId, ruleId)` pair instead of creating a duplicate or throwing.
**Rationale**: `SLARun.ticketId` is `@unique` and "no reopen-cycle support" (existing schema
comment) means a given rule can only ever legitimately fire once per ticket's lifetime for a
rule-triggered breach (`handleBreach`'s `ruleId` is always a real rule ID scoped to one specific
`triggerType`; `resolution_breach` and `first_response_breach` runs are naturally different
rules, so this constraint doesn't conflate the two). Manual escalation
(`escalateManually`/`fire(ticketId, ruleId: null, ...)`) is deliberately excluded from the
constraint (`WHERE rule_id IS NOT NULL`) because an admin legitimately re-escalating the same
ticket manually more than once must keep working exactly as it does today. This directly closes
the gap the audit found: `runBreachDetectionSweep`'s `findRunningPastResolutionDueAt` can return
the same still-`running` row to two overlapping sweep passes (e.g. a slow sweep still finishing
when the next scheduled tick fires, or a duplicate BullMQ job delivery calling `handleBreach`
directly) before either pass's own `update(run.id, {status:'breached', ...})` commits — without
this constraint, both passes independently call `fire` and each successfully creates its own
`EscalationEvent` plus its own `assignToSpecificNode`.
**Alternatives considered**:
- *A dedicated idempotency-key column populated by the caller (e.g. a sweep-run ID)*: more
general, but overkill here — the natural, already-unique business key for a rule-triggered
escalation genuinely is `(ticketId, ruleId)` given the "no reopen-cycle" constraint already in
place; inventing a separate key would duplicate information the schema already expresses.
- *Making the sweep single-flight via a Redis lock around the whole sweep function*: would
prevent two sweep passes from overlapping, but does not protect against a duplicate BullMQ job
calling `handleBreach` directly for the same trigger outside the sweep's own loop — the DB
constraint protects the actual invariant regardless of caller, which is the correct place per
Principle VII ("job handlers MUST be idempotent").
## 4. Ticket optimistic-concurrency proof
**Decision**: No implementation change. Add `tests/concurrency/ticket-status-race.test.ts`
firing a batch of genuinely concurrent `TicketsRepository.updateStatus` calls at the same
ticket, all from the same starting version, against the real throwaway Postgres, and asserting
exactly one succeeds (returns the updated ticket) while every other call returns `null` (the
existing stale-version-mismatch signal) — proving FR-004/SC-004 against the mechanism that
already exists (see `tests/concurrency/round-robin.test.ts:12`'s own reference to "003-ticketing's
optimistic ticket-status concurrency" as prior art that was never itself concurrency-tested).
**Rationale**: The existing `updateMany({where:{id, version: expectedVersion}, ...})` is a
single atomic SQL statement — Postgres itself guarantees only one concurrent `UPDATE` matching
that `WHERE` clause can succeed before the row's `version` changes underneath the others. This
is sound by construction; the gap is purely "never proven under real concurrency," which this
research assumes will simply confirm the existing guarantee (per spec.md's own Assumptions) —
but the test is still written to fail loudly if that assumption turns out to be wrong.
## 5. Load-test tooling choice
**Decision**: `autocannon` (npm devDependency), invoked via small TypeScript runner scripts
under `tests/load/`, one per named endpoint group (ticket creation, AI support flow, admin
reporting), each producing a JSON report (`autocannon`'s own `Result` shape: requests/sec,
latency `p50`/`p90`/`p99`, non-2xx count) written to `tests/load/reports/` (gitignored — these
are run artifacts, not fixtures) plus a printed console summary.
**Rationale**: `autocannon` is a pure Node.js package (no separate binary to install, unlike
k6), is TypeScript-friendly, and its programmatic API (`autocannon({url, connections, duration,
requests: [...]}, callback)`) fits scripting multi-step flows (e.g. sign-in once, then hammer an
authenticated endpoint) far more naturally than k6's separate-runtime JS dialect — keeping this
feature's new tooling inside the same Node/TS toolchain as the rest of the project (Technical
Context), consistent with this project's existing minimal-new-tooling bias.
**Alternatives considered**:
- *k6*: the industry-standard load-testing tool with richer scripting and threshold
assertions, but ships as a separate Go binary requiring its own install/Docker image outside
npm — heavier footprint for a project whose stack is otherwise 100% npm-managed.
Reconsider if this project later needs distributed/cloud load generation, which `autocannon`
does not support and k6 does.
- *artillery*: also npm-native and closer to k6 in scripting richness, but pulls in a much
larger dependency tree for YAML-driven scenario files this feature doesn't need — `autocannon`
is a lighter fit for three hand-written TS scripts.
## 6. Load-test pass/fail thresholds
**Decision**: Per FR-009, no numeric throughput/latency/error-rate threshold is hardcoded as
pass/fail. Each load-test report prints its own measured numbers and the tooling exits `0`
regardless of the numbers observed (this is a measurement tool, not a gate) — a comment in each
script marks the threshold question as `OPEN BUSINESS DECISION` and links back to spec.md
Assumptions, so a future feature can wire an explicit pass/fail gate into CI once the business
sets a real target.
**Rationale**: Inventing an arbitrary "must handle 500 req/s at p99 < 200ms" number would
violate the roadmap's own explicit rule ("Never hardcode a placeholder value for any of the
[open business decisions] and ship it as if it were final") — throughput/latency targets are
exactly this kind of business-owned number, not an engineering default.
+240
View File
@@ -0,0 +1,240 @@
# Feature Specification: Load and Concurrency Testing
**Feature Branch**: `016-load-concurrency-testing`
**Created**: 2026-09-09
**Status**: Draft
**Input**: User description: "Load and concurrency testing (Phase 11): exercise the concurrency-safety guarantees docs/09-testing-observability-cicd.md's own testing strategy already calls for — assignment race conditions, SLA pause/resume durability, escalation idempotency, ticket optimistic concurrency — under genuinely concurrent requests against real infrastructure, fixing any real race a test reveals; and add real HTTP load/throughput testing against the API's own critical endpoints."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - A ticket is never assigned to two agents at once under concurrent escalation (Priority: P1)
An operator needs confidence that when a ticket is escalated to a human (or reassigned) from
more than one trigger at nearly the same moment — for example, a manual reassignment landing at
the same instant as an automatic escalation-rule firing — the ticket ends up with exactly one
current assignment, never two agents both believing they own the same case.
**Why this priority**: A double-assignment is a customer- and agent-facing correctness failure
(two agents work the same ticket, or the SLA/workload dashboards silently double-count it) and
undermines every dashboard and workload figure already shipped in this system. This is the most
severe class of bug this feature can find.
**Independent Test**: Can be fully tested by firing many genuinely concurrent assignment
requests at the same ticket against a real running instance of the API and a real Postgres
database, then confirming exactly one `Assignment` row is marked current for that ticket
afterward — no reliance on timing assumptions or sequential calls.
**Acceptance Scenarios**:
1. **Given** a ticket eligible for assignment, **When** many concurrent assignment attempts are
made against it at once, **Then** exactly one assignment ends up marked as the ticket's
current assignment, and the database itself (not just the last response received) confirms
this.
2. **Given** the race in Scenario 1 is exercised repeatedly, **When** the test is run multiple
times, **Then** the result is consistent every time — the protection does not depend on
lucky timing.
---
### User Story 2 - An SLA clock is never corrupted by overlapping pause/resume activity (Priority: P1)
An operator needs confidence that when a ticket's SLA clock is paused and resumed by more than
one concurrent trigger — for example, a customer-reply webhook resuming the clock at the same
moment the scheduled breach-detection sweep is evaluating that same ticket — the SLA run ends up
in one coherent, correct state, never a state where the clock is simultaneously "paused" and
"counting toward breach," and never a state that silently drops a pause/resume event.
**Why this priority**: SLA correctness is a contractual promise to customers and already backs
the Management and Support dashboards shipped in 015-reporting-dashboards; a corrupted SLA clock
produces wrong compliance figures and wrong breach alerts without any visible error.
**Independent Test**: Can be fully tested by firing concurrent pause and resume operations at
the same SLA run against a real running instance of the API and a real Postgres database, then
confirming the run's final stored state (paused/active, due-at timestamps) is internally
consistent and matches one coherent ordering of the operations — not a mix of both.
**Acceptance Scenarios**:
1. **Given** an active SLA run, **When** a pause and a resume are triggered concurrently,
**Then** the run's final state is exactly one of "paused" or "active" — never a state with
contradictory fields (e.g., marked paused with no pause timestamp recorded, or marked active
with a stale due-at that never accounted for the pause).
2. **Given** the breach-detection sweep is evaluating a run at the same moment a resume is
requested for it, **When** both complete, **Then** the run is not double-processed (no
duplicate breach event, no lost resume).
---
### User Story 3 - An escalation rule firing twice never creates two escalation events (Priority: P1)
An operator needs confidence that if the same escalation trigger is delivered more than once —
for example, a retried background job or a re-processed event — the ticket is escalated exactly
once, not reassigned and re-notified redundantly.
**Why this priority**: Duplicate escalations would double-notify agents, double-count in the
Support and Management dashboards, and could re-trigger reassignment away from an agent who has
already started work — a direct regression of work already done in this session.
**Independent Test**: Can be fully tested by firing the same escalation trigger concurrently
more than once for the same ticket against a real running instance of the API and a real
Postgres database, then confirming only one `EscalationEvent` row exists for that trigger
afterward.
**Acceptance Scenarios**:
1. **Given** a ticket eligible for escalation, **When** the same escalation trigger is delivered
twice at nearly the same moment, **Then** exactly one escalation event is recorded for it.
2. **Given** Scenario 1's duplicate delivery, **When** the escalation event is created,
**Then** the ticket is reassigned exactly once, not twice.
---
### User Story 4 - A ticket's status can never be corrupted by two simultaneous updates (Priority: P2)
An operator needs confidence that the ticket status-transition safeguard already built for this
system actually holds under real concurrent load, not just in isolated sequential tests — this
is existing protection, but has never been proven under genuine concurrency.
**Why this priority**: Lower priority than User Stories 1-3 because a real defensive mechanism
already exists here (see Assumptions); this story exists to convert an untested assumption into
a proven guarantee, and is valuable but lower-risk than the three unguarded races above.
**Independent Test**: Can be fully tested by firing multiple concurrent status-update attempts
at the same ticket, each based on the same starting version, against a real running API and
database, then confirming exactly one update succeeds and every other attempt receives a clear
conflict response rather than silently corrupting or skipping the ticket's state.
**Acceptance Scenarios**:
1. **Given** a ticket at a known status and version, **When** multiple concurrent status-update
requests are made from that same version, **Then** exactly one succeeds and the rest are
rejected with a conflict response, and the ticket's final status matches the one update that
succeeded.
---
### User Story 5 - The API's critical endpoints hold up under realistic concurrent traffic (Priority: P2)
An operator needs a documented, repeatable measurement of how the system's most important
endpoints — new support requests coming in, the AI support flow, and the admin reporting
dashboards — behave under sustained concurrent load, so that a future capacity or performance
regression can be caught by comparing against this baseline rather than guessed at.
**Why this priority**: This is about establishing a measurable baseline and repeatable tooling
rather than proving or fixing a specific correctness bug (unlike User Stories 1-4), so it is
valuable but not blocking for the correctness guarantees above.
**Independent Test**: Can be fully tested by running a load-test tool against a real running
instance of the API for each of the three named endpoint groups and producing a report of
throughput, latency percentiles, and error rate, independent of whether any other user story in
this feature has been completed.
**Acceptance Scenarios**:
1. **Given** the API is running against real infrastructure, **When** a defined concurrent load
is sent to the ticket-creation endpoint for a sustained period, **Then** a report is produced
showing throughput, latency percentiles, and error rate for that run.
2. **Given** the same setup, **When** the same load profile is sent to the AI support flow and
to the admin reporting endpoints, **Then** an equivalent report is produced for each,
allowing the three to be compared against each other and against future runs.
### Edge Cases
- What happens when a concurrent assignment race includes a ticket that is simultaneously being
closed or reopened? The assignment/reassignment safeguard must not be bypassable by a
status change racing the same window.
- What happens when a pause and a breach both become due at the exact same instant? The final
state must reflect one coherent, auditable outcome, not an unresolvable both-happened state.
- What happens when the load test itself pushes an endpoint into its own rate limiter (e.g. the
013-auth-hardening login rate limit, or the SaaS integration per-minute rate limits)? The
report must distinguish "rejected by design (rate limit)" from "failed under load" rather than
counting both as the same kind of failure.
- What happens when two of these races are exercised back-to-back against the same throwaway
database without cleanup? Each test must use its own uniquely-identified fixtures so repeated
runs (and CI re-runs) don't produce false positives or false negatives from leftover state.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST guarantee that a ticket never has more than one assignment marked
as current, even when multiple assignment operations are attempted concurrently against it.
- **FR-002**: The system MUST guarantee that an SLA run's paused/active state and its associated
timestamps remain internally consistent when pause, resume, and the breach-detection sweep are
triggered concurrently against the same run.
- **FR-003**: The system MUST guarantee that the same escalation trigger delivered more than
once for the same ticket produces exactly one escalation event and exactly one resulting
reassignment.
- **FR-004**: The system MUST reject a ticket status update whose expected starting version no
longer matches the ticket's actual current version, even when the conflicting updates are
concurrent, and MUST leave the ticket in the state produced by whichever single update
actually succeeded.
- **FR-005**: The system's automated test suite MUST include a dedicated concurrency test for
each of FR-001 through FR-004, each exercising genuinely concurrent requests against real,
live infrastructure (not mocked timers or sequential calls standing in for concurrency).
- **FR-006**: Where a concurrency test written for this feature reveals that a guarantee in
FR-001, FR-002, or FR-003 does not currently hold, the underlying race MUST be fixed as part
of this feature, not merely documented.
- **FR-007**: The system MUST provide repeatable load-test tooling covering, at minimum: new
support request submission, the AI support flow, and the admin reporting dashboard endpoints.
- **FR-008**: Each load test run MUST produce a report including throughput, latency
percentiles, and error rate, with rate-limited responses reported separately from failures.
- **FR-009**: Pass/fail thresholds for the load tests (target throughput, acceptable latency,
acceptable error rate) MUST be explicitly marked as `OPEN BUSINESS DECISION` wherever the
business has not already specified a number, per this project's own roadmap convention —
never hardcoded as if final.
### Key Entities
- **Assignment race scenario**: A reusable test setup representing "many concurrent attempts to
assign or reassign the same ticket," used to exercise FR-001.
- **SLA race scenario**: A reusable test setup representing "concurrent pause, resume, and sweep
activity against the same SLA run," used to exercise FR-002.
- **Escalation race scenario**: A reusable test setup representing "the same escalation trigger
delivered more than once for the same ticket," used to exercise FR-003.
- **Load test report**: The recorded output of a load-test run against one endpoint group —
throughput, latency percentiles, error rate, and rate-limited-response count — kept so a
future run can be compared against it.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A test run that fires at least 20 genuinely concurrent assignment attempts at the
same ticket always results in exactly one current assignment, with zero exceptions across at
least 10 repeated runs.
- **SC-002**: A test run that fires concurrent pause/resume/sweep activity against the same SLA
run always leaves that run in one internally-consistent, auditable state, with zero
contradictory-state outcomes across at least 10 repeated runs.
- **SC-003**: A test run that delivers the same escalation trigger twice for the same ticket
always results in exactly one escalation event and exactly one reassignment, with zero
duplicate outcomes across at least 10 repeated runs.
- **SC-004**: A test run that fires at least 20 genuinely concurrent status-update attempts from
the same starting version against the same ticket always results in exactly one success and
the ticket left in that one succeeding state.
- **SC-005**: A load-test report exists for each of the three named endpoint groups (ticket
creation, AI support flow, admin reporting), each independently re-runnable on demand and
producing consistent-shape output for comparison across runs.
## Assumptions
- Ticket status optimistic concurrency (User Story 4) already has a real defensive mechanism in
the codebase (a version-checked atomic update) — this feature's job for that story is to prove
it under genuine concurrency with a new test, not to build new protection, unless that test
surprises this assumption and reveals a real gap.
- Assignment double-assignment, SLA pause/resume races, and escalation duplicate-event risk (User
Stories 1-3) are NOT currently guarded against — this feature's job for those stories is both
to prove the gap with a real concurrency test and to implement the fix, per FR-006.
- "Genuinely concurrent" means real parallel requests issued against a real running instance of
the API backed by real Postgres/Redis (this project's standing verification discipline
throughout every prior feature), not fake-timer or mocked-clock simulations.
- Load testing (User Story 5) targets the existing dev/throwaway infrastructure already used for
this project's own manual verification, not a separate staging or production environment —
provisioning a dedicated load-test environment is out of scope.
- Specific throughput/latency/error-rate thresholds for "pass" are an `OPEN BUSINESS DECISION`
per FR-009; this feature delivers the tooling and a baseline report, not a final SLA number.
- Round-robin assignment-selection counter safety is already covered by an existing genuine
concurrency test and is explicitly out of scope for this feature.
+221
View File
@@ -0,0 +1,221 @@
---
description: "Task list for 016-load-concurrency-testing"
---
# Tasks: Load and Concurrency Testing
**Input**: Design documents from `specs/016-load-concurrency-testing/`
**Organization**: Tasks are grouped by user story (US1 = assignment race, US2 = SLA race, US3 =
escalation idempotency, US4 = ticket-status race proof, US5 = load-test tooling). US1-US4 share
one Foundational phase (the schema migration all four rely on); US5 has no schema dependency and
can proceed independently of it.
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Add `autocannon` as a devDependency (`package.json`) and add
`tests/load/reports/` to `.gitignore` (run artifacts, not fixtures)
---
## Phase 2: Foundational (Blocking Prerequisites for US1-US4)
**Purpose**: The one shared schema migration US1, US2, and US3's fixes each depend on. US4 (no
schema change, see research.md §4) and US5 (no schema dependency) do not need this phase and can
proceed in parallel with it.
- [x] T002 In `prisma/schema.prisma`, add `version Int @default(0)` to `SLARun`; generate one
migration (`npx prisma migrate dev --name concurrency_guards`) that also includes, as raw
SQL, `CREATE UNIQUE INDEX assignments_one_current_per_ticket ON assignments (ticket_id)
WHERE is_current = true;` and `CREATE UNIQUE INDEX escalation_events_ticket_rule_unique ON
escalation_events (ticket_id, rule_id) WHERE rule_id IS NOT NULL;` (data-model.md); apply
to the throwaway test Postgres (`supporthub-test-pg`, port 5433) and the real dev Postgres
(`postgres-development`, port 5434, via `prisma migrate diff` + direct `psql` per this
project's own established non-destructive dev-sync approach); regenerate the Prisma client
**Checkpoint**: Schema ready — US1, US2, US3 implementation can now begin.
---
## Phase 3: User Story 1 - Assignment never double-assigned under concurrency (Priority: P1)
**Goal**: Two concurrent assignment attempts on the same ticket always leave exactly one current
assignment.
**Independent Test**: Run `tests/concurrency/assignment-race.test.ts` alone against the
throwaway Postgres — it creates its own ticket and needs nothing from US2-US5.
- [x] T003 [US1] Write `tests/concurrency/assignment-race.test.ts`: create one ticket, fire
>=20 genuinely concurrent assignment attempts at it (via the real assignment
engine/service entry point, not the repository directly), then query `assignments`
directly and assert exactly one row has `is_current = true` for that ticket (depends on
T002)
- [x] T004 [US1] Fix `AssignmentRepository.createAssignment` in
`src/modules/orchestration/assignments/repository/assignment.repository.ts` to catch the
`assignments_one_current_per_ticket` unique-violation (Prisma `P2002`) and retry the whole
supersede-then-create transaction, bounded to 3 attempts, per research.md §1 (depends on
T002)
- [x] T005 [US1] Re-run `assignment-race.test.ts` at least 10 times in a row (or extend the test
with its own internal repeat loop) confirming zero failures — SC-001 (depends on T003, T004)
**Checkpoint**: Quickstart Scenario 1 passes against real infrastructure, consistently.
---
## Phase 4: User Story 2 - SLA clock never corrupted by overlapping pause/resume/sweep (Priority: P1)
**Goal**: Concurrent pause/resume/breach-sweep activity against the same SLA run always leaves
it in one internally-consistent state.
**Independent Test**: Run `tests/concurrency/sla-race.test.ts` alone against the throwaway
Postgres — it creates its own ticket + SLA run and needs nothing from US1/US3/US4/US5.
- [x] T006 [US2] Replace `SlaRunRepository.update` with `updateWithVersion(id, expectedVersion,
data)` in `src/modules/orchestration/sla/repository/sla-run.repository.ts`, mirroring
`TicketsRepository.updateStatus`'s atomic `updateMany({where:{id, version:
expectedVersion}, data:{...data, version:{increment:1}}})` pattern exactly (depends on T002)
- [x] T007 [US2] Update `pause`, `resume`, `complete`, and `runBreachDetectionSweep` in
`src/modules/orchestration/sla/service/sla.service.ts` to call `updateWithVersion` with
each run's last-read version, and to re-read + recompute + retry (bounded to 3 attempts)
on a version-conflict `null` result, per research.md §2 (depends on T006)
- [x] T008 [US2] Write `tests/concurrency/sla-race.test.ts`: create a ticket with an active SLA
run, fire concurrent `pause`/`resume` calls and a `runBreachDetectionSweep()` pass against
it, then query the run directly and assert its final state is internally consistent (never
`paused` with `pausedAt: null`, never a legitimately `breached` run silently reverted to
`running`) (depends on T007)
- [x] T009 [US2] Re-run `sla-race.test.ts` at least 10 times confirming zero
contradictory-state outcomes — SC-002 (depends on T008)
**Checkpoint**: Quickstart Scenario 2 passes against real infrastructure, consistently.
---
## Phase 5: User Story 3 - An escalation trigger fired twice never duplicates (Priority: P1)
**Goal**: The same escalation trigger delivered twice for the same ticket always results in
exactly one escalation event and one reassignment.
**Independent Test**: Run `tests/concurrency/escalation-idempotency.test.ts` alone against the
throwaway Postgres — it creates its own ticket + escalation rule and needs nothing from
US1/US2/US4/US5 (though it exercises the same `Assignment` table US1 protects, as a
cross-check).
- [x] T010 [US3] Fix `EscalationEventRepository.create` in
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` to catch
the `escalation_events_ticket_rule_unique` unique-violation (Prisma `P2002`) and return
the pre-existing row for that `(ticketId, ruleId)` pair via a `findFirst` fallback instead
of throwing, per research.md §3 (depends on T002)
- [x] T011 [US3] Confirm `EscalationService.fire` in
`src/modules/orchestration/escalation/service/escalation.service.ts` behaves correctly
when `create` returns a pre-existing event (it must not also re-run
`assignToSpecificNode` for a duplicate trigger) — adjust `fire` if needed so a
duplicate-conflict short-circuits before reassignment (depends on T010)
- [x] T012 [US3] Write `tests/concurrency/escalation-idempotency.test.ts`: create a ticket
eligible for a specific escalation rule, call the real trigger path (e.g.
`escalationService.handleBreach`) twice concurrently for the identical trigger, then query
`escalation_events` and `assignments` directly and assert exactly one of each resulted
(depends on T011)
- [x] T013 [US3] Re-run `escalation-idempotency.test.ts` at least 10 times confirming zero
duplicate outcomes — SC-003 (depends on T012)
**Checkpoint**: Quickstart Scenario 3 passes against real infrastructure, consistently.
---
## Phase 6: User Story 4 - Ticket status optimistic concurrency, proven (Priority: P2)
**Goal**: Prove the existing version-checked ticket-status update holds under genuine
concurrency.
**Independent Test**: Run `tests/concurrency/ticket-status-race.test.ts` alone against the
throwaway Postgres — no dependency on T002 or any other user story (research.md §4: no
implementation change expected).
- [x] T014 [US4] Write `tests/concurrency/ticket-status-race.test.ts`: create a ticket at a
known status/version, fire >=20 genuinely concurrent `ticketsRepository.updateStatus`
calls all starting from that same version, and assert exactly one returns the updated
ticket while every other call returns `null` — SC-004
**Checkpoint**: Quickstart Scenario 4 passes, confirming the existing mechanism (no fix
expected; a failure here would mean research.md's assumption was wrong and needs revisiting).
---
## Phase 7: User Story 5 - Repeatable load/throughput baseline (Priority: P2)
**Goal**: Repeatable `autocannon`-based load-test tooling and a baseline report for the three
named critical endpoint groups.
**Independent Test**: Run each `tests/load/*.load.ts` script alone against a real running dev
server — no dependency on T002 or any other user story.
- [x] T015 [P] [US5] Create `tests/load/autocannon.config.ts`: a shared runner helper wrapping
`autocannon`'s programmatic API, producing the report shape from data-model.md
(`requestsPerSec`, `latencyP50Ms`/`P90Ms`/`P99Ms`, `non2xxCount`, `rateLimitedCount`),
printing a console summary and writing JSON to `tests/load/reports/` (depends on T001)
- [x] T016 [P] [US5] Create `tests/load/ticket-creation.load.ts` using the T015 helper against
`POST /v1/support/requests` (depends on T015)
- [x] T017 [P] [US5] Create `tests/load/ai-support-flow.load.ts` using the T015 helper against
the AI support flow's own endpoints (depends on T015)
- [x] T018 [P] [US5] Create `tests/load/admin-reporting.load.ts` using the T015 helper, signing
in as the seeded admin first, against the 015-reporting-dashboards endpoints (depends on
T015)
- [x] T019 [US5] Run all three scripts against a real running dev server, confirm each produces
a report, and run each twice to confirm consistent-shape output for comparison — SC-005
(depends on T016, T017, T018)
**Checkpoint**: Quickstart Scenario 5 passes; a baseline report exists for each endpoint group.
---
## Phase 8: Polish & Cross-Cutting Concerns
- [x] T020 Update `specs/016-load-concurrency-testing/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T021 `npx tsc --noEmit` / `npm run lint` / `npx tsx scripts/check-architecture.ts` clean
- [x] T022 Full existing unit + integration + concurrency suite re-run (throwaway DB), confirming
no regression in 007-orchestration-assignment's, 008-sla-escalation's,
012-admin-list-views's, and 015-reporting-dashboards's own existing coverage of
`Assignment`/`SLARun`/`EscalationEvent`
- [x] T023 Mark all of this file's checkboxes complete once verified
---
## Dependencies & Execution Order
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: No dependencies — BLOCKS User Stories 1, 2, 3 only
- **User Story 4**: No dependency on Phase 2 or any other story — can start immediately
- **User Story 5**: No dependency on Phase 2 or any other story — can start immediately (only
needs Phase 1's `autocannon` devDependency)
- **User Stories 1, 2, 3**: Each depends only on Phase 2 — independent of each other and of
User Stories 4/5
- **Polish (Phase 8)**: Depends on all five user stories
## Parallel Example: Foundational-independent stories
```text
# Once Phase 1 completes, these can start immediately in parallel, without waiting on Phase 2:
Task: "Write tests/concurrency/ticket-status-race.test.ts" (US4, T014)
Task: "Create tests/load/autocannon.config.ts" (US5, T015)
```
## Implementation Strategy
### Suggested order
1. Phase 1 (Setup) and Phase 2 (Foundational) — Phase 2 unblocks the three highest-severity
real-bug fixes (US1, US2, US3)
2. User Stories 1, 2, 3 (all P1) — each is a real, currently-unguarded race; fix and prove each
in turn, or in parallel across files since they touch different modules
3. User Story 4 (P2) — quick to add, proves existing protection, can be done any time after
Phase 1
4. User Story 5 (P2) — independent tooling work, can be done any time after Phase 1, in parallel
with 1-4
5. Phase 8 (Polish) once all five stories are verified
+2
View File
@@ -24,6 +24,7 @@ import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
import { authRoutes } from '@/modules/identity/auth';
import { reportsRoutes } from '@/modules/platform/reports';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -49,5 +50,6 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(solutionsRoutes);
await app.register(verificationRoutes);
await app.register(resolutionsRoutes);
await app.register(reportsRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+4
View File
@@ -3,4 +3,8 @@ import { env } from './env';
export const authConfig = {
jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
passwordMinLength: env.PASSWORD_MIN_LENGTH,
passwordResetTokenLifetimeMinutes: env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
loginRateLimitMaxAttempts: env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
loginRateLimitWindowSeconds: env.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
};
+22
View File
@@ -65,6 +65,28 @@ const envSchema = z.object({
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
// until now) — see specs/010-identity-auth/research.md.
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
// Authentication Hardening (013) — password-strength policy, reset-token lifetime, and
// login rate-limiting, all CONFIGURABLE per docs/10-implementation-roadmap.md's own
// "never hardcode a placeholder value and ship it as final" instruction — see
// specs/013-auth-hardening/research.md.
PASSWORD_MIN_LENGTH: z.coerce.number().default(10),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().default(30),
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.coerce.number().default(5),
LOGIN_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().default(300),
// Full Observability (014) — the OpenTelemetry project's own standard env var name (not
// invented here) for the collector endpoint spans are exported to. Unset means "no collector
// configured" — tracing still runs, just exports to the console instead (never a startup
// requirement) — see specs/014-full-observability/research.md "Distributed tracing".
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
// Reporting and Analytics Dashboards (015) — CONFIGURABLE per docs/10-implementation-
// roadmap.md's own "never hardcode a placeholder value and ship it as final" instruction —
// see specs/015-reporting-dashboards/research.md §8.
REPORTING_DEFAULT_WINDOW_DAYS: z.coerce.number().default(30),
REPORTING_SLA_RISK_THRESHOLD_MINUTES: z.coerce.number().default(60),
REPORTING_TOP_N_LIMIT: z.coerce.number().default(10),
});
export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -7,3 +7,4 @@ export * from './ai';
export * from './orchestration';
export * from './problem-resolution';
export * from './auth';
export * from './reporting';
+7
View File
@@ -0,0 +1,7 @@
import { env } from './env';
export const reportingConfig = {
defaultWindowDays: env.REPORTING_DEFAULT_WINDOW_DAYS,
slaRiskThresholdMinutes: env.REPORTING_SLA_RISK_THRESHOLD_MINUTES,
topNLimit: env.REPORTING_TOP_N_LIMIT,
};
+65 -1
View File
@@ -1,9 +1,18 @@
import { SpanStatusCode } from '@opentelemetry/api';
import { eventBus } from '../event-bus';
import { DomainEventName } from '../domain-events';
import { BaseDomainEvent } from '../event-types';
import { sessionsService } from '@/modules/ai-support/sessions';
import { orchestrationService } from '@/modules/orchestration/orchestration';
import { slaService } from '@/modules/orchestration/sla';
import { ticketsService } from '@/modules/ticketing/tickets';
import { resolutionRepository } from '@/modules/problem-management/resolutions';
import {
getTracer,
ticketResolutionsCounter,
ticketResolutionDurationHistogram,
escalationsCounter,
} from '@/infrastructure/observability';
interface TicketUpdatedPayload {
ticketId: string;
@@ -19,6 +28,14 @@ interface TicketAssignedPayload {
actor: string;
}
interface EscalationTriggeredPayload {
ticketId: string;
ruleId: string;
targetNodeId: string;
actor: string;
reason: string;
}
let registered = false;
/**
@@ -50,7 +67,44 @@ export function registerDomainEventHandlers(): void {
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus !== 'HUMAN_ESCALATION') return;
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
// 014-full-observability data-model.md: nests under session.service.ts's `ai.escalation`
// span when this fired from that same await chain (an escalation triggered some other way
// — e.g. a direct admin action — still gets its own root span here, never left untraced).
await getTracer().startActiveSpan(
'orchestration.assignment',
{ attributes: { 'ticket.id': event.payload.ticketId } },
async (span) => {
try {
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
},
);
},
);
// 014-full-observability data-model.md #3/#4: human-vs-AI resolution and resolution-time,
// read off the Resolution row's own resolvedBy ("ai" | agentId — see prisma/schema.prisma)
// rather than duplicating that distinction here.
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus !== 'RESOLVED') return;
const [ticket, resolution] = await Promise.all([
ticketsService.getById(event.payload.ticketId),
resolutionRepository.findByTicketId(event.payload.ticketId),
]);
if (!resolution) return;
ticketResolutionsCounter.inc({
resolved_by: resolution.resolvedBy === 'ai' ? 'ai' : 'human',
});
ticketResolutionDurationHistogram.observe((Date.now() - ticket.createdAt.getTime()) / 1000);
},
);
@@ -87,4 +141,14 @@ export function registerDomainEventHandlers(): void {
await slaService.complete(event.payload.ticketId);
},
);
// 014-full-observability data-model.md #7: ESCALATION_TRIGGERED has been published
// unconditionally on every escalation since 008-sla-escalation ("for audit, not for logic" —
// escalation.service.ts's own comment) but had zero subscribers until now.
eventBus.subscribe(
DomainEventName.ESCALATION_TRIGGERED,
async (event: BaseDomainEvent<EscalationTriggeredPayload>) => {
escalationsCounter.inc({ reason: event.payload.reason });
},
);
}
@@ -2,3 +2,4 @@ export * from './logger';
export * from './metrics';
export * from './tracing';
export * from './health.service';
export * from './request-context.store';
@@ -1,11 +1,18 @@
import pino from 'pino';
import { env } from '@/config';
import { getRequestContextSnapshot } from './request-context.store';
const pinoOptions: pino.LoggerOptions = {
level: env.LOG_LEVEL,
base: {
env: env.NODE_ENV,
},
// 014-full-observability FR-002: merges the current request's requestId/correlationId (if
// any — a log call outside any request, e.g. at startup, gets neither) into every log line
// made through this logger, anywhere in the codebase, with no change to any existing call
// site. Pino applies these fields before the call's own object, so an explicit requestId a
// call site already passes manually still wins.
mixin: () => getRequestContextSnapshot() ?? {},
};
if (env.NODE_ENV === 'development') {
@@ -20,3 +27,7 @@ if (env.NODE_ENV === 'development') {
}
export const logger = pino(pinoOptions);
// Exported so tests can build a real pino instance (same mixin, a different destination) rather
// than mocking the logger itself — see tests/unit/observability/request-context-mixin.test.ts.
export const loggerOptions = pinoOptions;
@@ -9,4 +9,70 @@ export const httpRequestDurationHistogram = new client.Histogram({
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
});
// 014-full-observability data-model.md "Metrics (Prometheus, via prom-client)" — the eleven
// named business-health metrics docs/09-testing-observability-cicd.md calls for, each a raw
// counter/histogram for an external monitoring stack (FR-009 — no aggregation/dashboard logic
// here). "Recurring problems" and "most common errors" are deliberately read directly off
// problemsCreatedCounter/knownErrorLookupsCounter via a topk/rate query, not a separate metric.
export const aiSessionOutcomesCounter = new client.Counter({
name: 'supporthub_ai_session_outcomes_total',
help: 'Count of AI support sessions by terminal outcome',
labelNames: ['outcome'],
});
export const ticketResolutionsCounter = new client.Counter({
name: 'supporthub_ticket_resolutions_total',
help: 'Count of ticket resolutions by who resolved them',
labelNames: ['resolved_by'],
});
export const ticketResolutionDurationHistogram = new client.Histogram({
name: 'supporthub_ticket_resolution_duration_seconds',
help: 'Duration from ticket creation to resolution, in seconds',
buckets: [60, 300, 900, 3600, 14400, 86400, 259200, 604800],
});
export const ticketFirstResponseDurationHistogram = new client.Histogram({
name: 'supporthub_ticket_first_response_duration_seconds',
help: 'Duration from ticket creation to the first agent response, in seconds',
buckets: [60, 300, 900, 3600, 14400, 86400],
});
export const slaRunOutcomesCounter = new client.Counter({
name: 'supporthub_sla_run_outcomes_total',
help: 'Count of SLA runs by outcome',
labelNames: ['outcome'],
});
export const escalationsCounter = new client.Counter({
name: 'supporthub_escalations_total',
help: 'Count of escalation events by trigger reason',
labelNames: ['reason'],
});
export const problemsCreatedCounter = new client.Counter({
name: 'supporthub_problems_created_total',
help: 'Count of problems created, by category',
labelNames: ['category_id'],
});
export const knownErrorLookupsCounter = new client.Counter({
name: 'supporthub_known_error_lookups_total',
help: 'Count of known-issue lookups by error code',
labelNames: ['code'],
});
export const knowledgeRetrievalOutcomesCounter = new client.Counter({
name: 'supporthub_knowledge_retrieval_outcomes_total',
help: 'Count of AI knowledge-retrieval attempts by whether a match was found',
labelNames: ['matched'],
});
export const toolInvocationsCounter = new client.Counter({
name: 'supporthub_tool_invocations_total',
help: 'Count of AI tool invocations by tool and outcome',
labelNames: ['tool', 'outcome'],
});
export const metricsRegistry = client.register;
@@ -0,0 +1,18 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface RequestContextSnapshot {
requestId: string;
correlationId: string;
}
/**
* 014-full-observability research.md §2: lets every log line produced through the shared
* `logger` singleton — anywhere, any layer, no matter how deep the call stack — automatically
* carry the current request's requestId/correlationId (via logger.ts's Pino `mixin`), without
* threading `request`/`request.log` through every service and repository.
*/
export const requestContextStore = new AsyncLocalStorage<RequestContextSnapshot>();
export function getRequestContextSnapshot(): RequestContextSnapshot | undefined {
return requestContextStore.getStore();
}
+79 -1
View File
@@ -1,5 +1,83 @@
import { trace, Tracer } from '@opentelemetry/api';
import { trace, context, diag, DiagLogLevel, Tracer } from '@opentelemetry/api';
import {
BasicTracerProvider,
BatchSpanProcessor,
SimpleSpanProcessor,
ConsoleSpanExporter,
InMemorySpanExporter,
} from '@opentelemetry/sdk-trace-base';
import type { SpanProcessor } from '@opentelemetry/sdk-trace';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { env } from '@/config';
import { logger } from './logger';
/**
* Without a registered ContextManager, the OpenTelemetry API's `context.active()` is a no-op
* that does not propagate across async boundaries at all — `startActiveSpan` would only make a
* span "active" for the literal synchronous extent of its callback, so a child span created
* after an `await` (e.g. across this codebase's own event-bus `await eventBus.publish(...)`
* chain, data-model.md's whole reason FR-006's two paths work) would silently come out as its
* own unrelated root span instead of nesting. This is the tracing equivalent of the ALS-backed
* request-context store — same mechanism, different consumer.
*/
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
/**
* 014-full-observability research.md §4: routes the OpenTelemetry SDK's own internal
* diagnostics (span export failures included — FR-007) through this project's own log stream
* instead of stderr/nowhere, at WARN so routine SDK chatter isn't logged at every span.
*/
diag.setLogger(
{
error: (msg, ...args) => logger.error({ otel: args }, msg),
warn: (msg, ...args) => logger.warn({ otel: args }, msg),
info: (msg, ...args) => logger.info({ otel: args }, msg),
debug: (msg, ...args) => logger.debug({ otel: args }, msg),
verbose: (msg, ...args) => logger.trace({ otel: args }, msg),
},
DiagLogLevel.WARN,
);
let testSpanExporter: InMemorySpanExporter | undefined;
/**
* Real infra, substituted destination only (same pattern as pino-pretty in development, or the
* password-reset token's stub delivery) — never a mock of the tracer/provider itself:
* - test: `InMemorySpanExporter`, so integration tests can read back real exported spans.
* - `OTEL_EXPORTER_OTLP_ENDPOINT` set: real OTLP/HTTP export via `BatchSpanProcessor` (the
* exporter reads the same env var itself for the actual collector URL — no need to hand-build
* the `/v1/traces` path here).
* - otherwise (local dev, or any environment with no collector configured): `ConsoleSpanExporter`
* so spans are visible without standing one up.
*/
function buildSpanProcessor(): SpanProcessor {
if (env.NODE_ENV === 'test') {
testSpanExporter = new InMemorySpanExporter();
return new SimpleSpanProcessor(testSpanExporter);
}
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) {
return new BatchSpanProcessor(new OTLPTraceExporter());
}
return new SimpleSpanProcessor(new ConsoleSpanExporter());
}
const tracerProvider = new BasicTracerProvider({
resource: resourceFromAttributes({ 'service.name': 'supporthub-api' }),
spanProcessors: [buildSpanProcessor()],
});
trace.setGlobalTracerProvider(tracerProvider);
export function getTracer(name = 'supporthub-api'): Tracer {
return trace.getTracer(name);
}
/** Test environment only — throws otherwise. See tests/integration/observability/tracing.test.ts. */
export function getTestSpanExporter(): InMemorySpanExporter {
if (!testSpanExporter) {
throw new Error('getTestSpanExporter() is only available when NODE_ENV=test.');
}
return testSpanExporter;
}
@@ -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 ?? {});
@@ -0,0 +1,31 @@
import { prismaClient } from '@/infrastructure/database';
import { ErrorCodeLookup } from '@prisma/client';
/**
* 015-reporting-dashboards research.md §6: a durable, append-only audit row — no update/delete
* path, every lookup is its own row, duplicates over time are the point (frequency is what
* "top errors" measures).
*/
export class ErrorCodeLookupRepository {
constructor(private readonly prisma = prismaClient) {}
async create(errorCodeId: string, productId: string): Promise<ErrorCodeLookup> {
return this.prisma.errorCodeLookup.create({ data: { errorCodeId, productId } });
}
async countByCodeForProduct(
productId: string,
from: Date,
to: Date,
): Promise<Array<{ errorCodeId: string; count: number }>> {
const grouped = await this.prisma.errorCodeLookup.groupBy({
by: ['errorCodeId'],
where: { productId, createdAt: { gte: from, lte: to } },
_count: { errorCodeId: true },
orderBy: { _count: { errorCodeId: 'desc' } },
});
return grouped.map((g) => ({ errorCodeId: g.errorCodeId, count: g._count.errorCodeId }));
}
}
export const errorCodeLookupRepository = new ErrorCodeLookupRepository();
@@ -13,6 +13,10 @@ export class ErrorCodesRepository {
where: { productId_code: { productId, code } },
});
}
async findById(id: string): Promise<ErrorCode | null> {
return this.prisma.errorCode.findUnique({ where: { id } });
}
}
export const errorCodesRepository = new ErrorCodesRepository();
@@ -1,4 +1,5 @@
export * from './knowledge.repository';
export * from './error-codes.repository';
export * from './error-code-lookup.repository';
export * from './known-issues.repository';
export * from './runbooks.repository';
@@ -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[]> {
@@ -14,6 +14,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
{ 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, requireRole('ADMIN')] },
@@ -1,8 +1,11 @@
import { ErrorCode, KnownIssue } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { knownErrorLookupsCounter } from '@/infrastructure/observability';
import {
errorCodesRepository,
ErrorCodesRepository,
errorCodeLookupRepository,
ErrorCodeLookupRepository,
knownIssuesRepository,
KnownIssuesRepository,
CreateKnownIssueData,
@@ -12,6 +15,7 @@ export class ErrorCodesService {
constructor(
private readonly errorCodesRepo: ErrorCodesRepository = errorCodesRepository,
private readonly knownIssuesRepo: KnownIssuesRepository = knownIssuesRepository,
private readonly lookupsRepo: ErrorCodeLookupRepository = errorCodeLookupRepository,
) {}
async createErrorCode(productId: string, code: string, description: string): Promise<ErrorCode> {
@@ -26,8 +30,37 @@ export class ErrorCodesService {
async findKnownIssuesByErrorCode(productId: string, code: string): Promise<KnownIssue[]> {
const errorCode = await this.errorCodesRepo.findByCode(productId, code);
if (!errorCode) throw new NotFoundError('Error code not found.');
// 014-full-observability data-model.md #9: "most common errors" — a raw, process-lifetime
// counter for a live monitoring stack (FR-009 there), counted only once the code is
// confirmed real.
knownErrorLookupsCounter.inc({ code });
// 015-reporting-dashboards research.md §6: the durable counterpart — the live counter above
// resets on every restart, so a historical "top errors" report needs its own audit row.
await this.lookupsRepo.create(errorCode.id, productId);
return this.knownIssuesRepo.findByErrorCodeId(errorCode.id);
}
/** 015-reporting-dashboards: the Product dashboard's "top errors" ranking — encapsulated here
* (not exposed as raw repository access) since resolving a lookup count back to its error
* code's own `code` string is this module's own concern, not the reports module's. */
async getTopErrorCodesForProduct(
productId: string,
from: Date,
to: Date,
limit: number,
): Promise<Array<{ code: string; count: number }>> {
const ranked = await this.lookupsRepo.countByCodeForProduct(productId, from, to);
const top = ranked.slice(0, limit);
const rows = await Promise.all(
top.map(async (row) => {
const errorCode = await this.errorCodesRepo.findById(row.errorCodeId);
return { code: errorCode?.code ?? row.errorCodeId, count: row.count };
}),
);
return rows;
}
}
export const errorCodesService = new ErrorCodesService();
@@ -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();
+9
View File
@@ -3,11 +3,20 @@ export { SessionsService, sessionsService } from './service';
export type { SessionTurnResult } from './service';
export { ConfidencePolicyService, confidencePolicyService } from './service';
export type { ResolvedConfidencePolicy } from './service';
// 015-reporting-dashboards research.md §7: reused for the AI dashboard's confidence
// distribution, not reimplemented.
export { decideConfidenceBand } from './service';
export type { ConfidenceBand } from './service';
export {
sessionRepository,
SessionRepository,
diagnosisRepository,
DiagnosisRepository,
// 015-reporting-dashboards: test setup needs to record a session's knowledge references
// directly, the same "extend an existing module's public surface for a later feature"
// precedent as 004's productsRepository/009's problemsRepository.
knowledgeReferenceRepository,
KnowledgeReferenceRepository,
} from './repository';
export { ACTIVE_SESSION_STATUSES, TERMINAL_SESSION_STATUSES } from './mapper';
export type { SessionStatus } from './mapper';
@@ -1,5 +1,6 @@
import { AISupportSession } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
import { aiSessionOutcomesCounter } from '@/infrastructure/observability';
import { ACTIVE_SESSION_STATUSES } from '../mapper';
export class SessionRepository {
@@ -38,10 +39,19 @@ export class SessionRepository {
async updateStatus(sessionId: string, status: string): Promise<AISupportSession> {
const isTerminal =
status === 'resolved' || status === 'escalated' || status === 'ended_by_agent';
return this.prisma.aISupportSession.update({
const updated = await this.prisma.aISupportSession.update({
where: { id: sessionId },
data: { status, ...(isTerminal ? { endedAt: new Date() } : {}) },
});
// 014-full-observability data-model.md: the single choke point every escalation/resolution
// branch in session.service.ts funnels through (research.md §5's "why the repository layer"
// — observability calls are already a cross-cutting concern used from any layer here).
if (status === 'resolved' || status === 'escalated') {
aiSessionOutcomesCounter.inc({ outcome: status });
}
return updated;
}
async setActiveRunbook(sessionId: string, runbookKey: string, stepIndex: number): Promise<void> {
@@ -1,5 +1,7 @@
import { SpanStatusCode } from '@opentelemetry/api';
import { AISupportSession, KnowledgeEntry, AIInteraction, Ticket, Problem } from '@prisma/client';
import { AppError, NotFoundError } from '@/common/errors';
import { getTracer } from '@/infrastructure/observability';
import { ticketsService, problemsRepository } from '@/modules/ticketing/tickets';
import { messagesService } from '@/modules/ticketing/messages';
import { knowledgeService } from '@/modules/ai-support/knowledge';
@@ -125,17 +127,35 @@ export class SessionsService {
reason: string,
stepsAttempted: string[] = [],
) {
const diagnosis = await this.diagnoses.findLatestBySession(session.id);
const result = this.escalation.buildSummary(diagnosis, reason, stepsAttempted);
// 014-full-observability data-model.md — root span for the AI-escalation -> orchestration/
// assignment path (FR-006): syncTicketStatus below publishes TICKET_UPDATED synchronously,
// and the orchestration subscriber's own span (src/events/handlers/index.ts) nests under
// this one automatically via OTel's active-context propagation through that same await chain.
return getTracer().startActiveSpan(
'ai.escalation',
{ attributes: { 'ticket.id': ticketId, 'session.id': session.id } },
async (span) => {
try {
const diagnosis = await this.diagnoses.findLatestBySession(session.id);
const result = this.escalation.buildSummary(diagnosis, reason, stepsAttempted);
if (!(ACTIVE_SESSION_STATUSES as readonly string[]).includes(session.status)) {
return result;
}
if (!(ACTIVE_SESSION_STATUSES as readonly string[]).includes(session.status)) {
return result;
}
await this.sessions.updateStatus(session.id, 'escalated');
await syncTicketStatus(ticketId, 'escalated');
await this.sessions.updateStatus(session.id, 'escalated');
await syncTicketStatus(ticketId, 'escalated');
return result;
return result;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
},
);
}
private async runDiagnosisTurn(
@@ -1,4 +1,8 @@
import Anthropic from '@anthropic-ai/sdk';
import {
toolInvocationsCounter,
knowledgeRetrievalOutcomesCounter,
} from '@/infrastructure/observability';
import { actionRepository, ActionRepository } from '../repository';
import { evaluateToolProposal } from './policy-gate';
import { executeTool, ToolExecutionContext } from './tool-executor';
@@ -58,6 +62,15 @@ export class ToolsService {
const result = await executeTool(block.name, block.input, context);
await this.actions.createResult(action.id, result.output, result.status);
// 014-full-observability data-model.md #10/#11: the single choke point every tool
// invocation passes through — labeled by outcome, and (for the knowledge-search tool
// specifically) by whether it found anything.
toolInvocationsCounter.inc({ tool: block.name, outcome: result.status });
if (block.name === 'searchProductKnowledge') {
const matched = Array.isArray(result.output) && result.output.length > 0;
knowledgeRetrievalOutcomesCounter.inc({ matched: String(matched) });
}
if (result.status === 'failed') anyFailed = true;
if (block.name === 'escalateToHuman' && result.status === 'success') {
const output = result.output as { reason?: string };
@@ -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();
@@ -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,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),
);
}
@@ -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();
@@ -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: {
@@ -15,6 +15,10 @@ export class UsersRepository {
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 });
}
@@ -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,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,16 +1,19 @@
import { User } from '@prisma/client';
import { ConflictError } from '@/common/errors';
import { hashPassword } from '@/modules/identity/auth';
import { hashPassword, validatePasswordStrength } 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. */
/** FR-008: rejects a duplicate email — never a second account silently sharing one.
* 013-auth-hardening FR-005: the same password-strength policy every password-setting call
* site enforces. */
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.');
validatePasswordStrength(body.password);
const passwordHash = await hashPassword(body.password);
const user = await this.repo.create({
@@ -1,7 +1,7 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service';
import { loginSchema } from '../schema';
import { loginSchema, requestPasswordResetSchema, resetPasswordSchema } from '../schema';
function bearerToken(request: FastifyRequest): string {
const header = request.headers.authorization;
@@ -29,6 +29,26 @@ export class AuthController {
await this.service.logout(bearerToken(request));
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
}
/** 013-auth-hardening FR-001/SC-001: identical response regardless of account existence —
* the service itself is what decides whether a real token gets issued. */
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
const { email } = requestPasswordResetSchema.parse(request.body);
await this.service.requestPasswordReset(email);
return reply.status(200).send({
success: true,
data: { message: 'If that account exists, a reset link has been sent.' },
meta: null,
});
}
async resetPassword(request: FastifyRequest, reply: FastifyReply) {
const { token, newPassword } = resetPasswordSchema.parse(request.body);
await this.service.resetPassword(token, newPassword);
return reply
.status(200)
.send({ success: true, data: { message: 'Password updated.' }, meta: null });
}
}
export const authController = new AuthController();
+1
View File
@@ -4,4 +4,5 @@ export { requireRole } from './service';
export type { LoginBody } from './schema';
export type { LoginResult } from './service';
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
export { validatePasswordStrength } from './mapper';
export { AUTH_CONSTANTS } from './constants';
@@ -1 +1,3 @@
export * from './auth.mapper';
export * from './password-policy';
export * from './reset-token';
@@ -0,0 +1,13 @@
import { ValidationError } from '@/common/errors';
import { authConfig } from '@/config';
/** 013-auth-hardening FR-005: the one password-strength rule, enforced identically everywhere
* a password is ever set (010's own POST /admin/users and this feature's own password-reset
* consume endpoint) — never duplicated or allowed to drift between call sites. */
export function validatePasswordStrength(password: string): void {
if (password.length < authConfig.passwordMinLength) {
throw new ValidationError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
}
}
@@ -0,0 +1,18 @@
import { randomBytes, createHash } from 'crypto';
export interface GeneratedResetToken {
token: string;
tokenHash: string;
}
/** 013-auth-hardening: the raw token is what gets "delivered" (logged, per the stub decision,
* research.md); only its SHA-256 hash is ever persisted (data-model.md) — mirrors this
* codebase's own password-hashing discipline, never storing a usable secret at rest. */
export function generateResetToken(): GeneratedResetToken {
const token = randomBytes(32).toString('hex');
return { token, tokenHash: hashResetToken(token) };
}
export function hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@@ -14,6 +14,11 @@ export class AuthRepository {
if (!user || !user.active) return null;
return user;
}
/** 013-auth-hardening: applies a password-reset's new hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> {
await this.prisma.user.update({ where: { id }, data: { passwordHash } });
}
}
export const authRepository = new AuthRepository();
@@ -1 +1,2 @@
export * from './auth.repository';
export * from './reset-token.repository';
@@ -0,0 +1,30 @@
import { cacheService } from '@/infrastructure/cache';
const TOKEN_KEY_PREFIX = 'password-reset:token:';
const USER_KEY_PREFIX = 'password-reset:user:';
/** 013-auth-hardening data-model.md: two paired Redis keys per active reset token — the same
* Redis-key-with-TTL shape as 010's own revocation denylist. Only one active token exists per
* user at any time (FR-002): issuing a new one deletes the prior token's own key. */
export class ResetTokenRepository {
async issue(userId: string, tokenHash: string, ttlSeconds: number): Promise<void> {
const priorHash = await cacheService.get<string>(`${USER_KEY_PREFIX}${userId}`);
if (priorHash) {
await cacheService.del(`${TOKEN_KEY_PREFIX}${priorHash}`);
}
await cacheService.set(`${TOKEN_KEY_PREFIX}${tokenHash}`, userId, ttlSeconds);
await cacheService.set(`${USER_KEY_PREFIX}${userId}`, tokenHash, ttlSeconds);
}
async resolve(tokenHash: string): Promise<string | null> {
return cacheService.get<string>(`${TOKEN_KEY_PREFIX}${tokenHash}`);
}
/** Single-use (FR-002/SC-002): deletes both keys for this token/user pair. */
async consume(tokenHash: string, userId: string): Promise<void> {
await cacheService.del(`${TOKEN_KEY_PREFIX}${tokenHash}`);
await cacheService.del(`${USER_KEY_PREFIX}${userId}`);
}
}
export const resetTokenRepository = new ResetTokenRepository();
@@ -11,4 +11,12 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
authController.handleLogout(req, reply),
);
// 013-auth-hardening: ungated, like login itself — the caller has no session yet.
fastify.post('/auth/password-reset/request', (req, reply) =>
authController.requestPasswordReset(req, reply),
);
fastify.post('/auth/password-reset/consume', (req, reply) =>
authController.resetPassword(req, reply),
);
}
@@ -8,3 +8,21 @@ export const loginSchema = z
.strict();
export type LoginBody = z.infer<typeof loginSchema>;
/** 013-auth-hardening */
export const requestPasswordResetSchema = z
.object({
email: z.string().email(),
})
.strict();
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetSchema>;
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
newPassword: z.string().min(1),
})
.strict();
export type ResetPasswordBody = z.infer<typeof resetPasswordSchema>;
@@ -1,8 +1,23 @@
import { User } from '@prisma/client';
import { AuthenticationError } from '@/common/errors';
import { revokeToken } from '@/infrastructure/cache';
import { authRepository, AuthRepository } from '../repository';
import { verifyPassword, signToken, verifyToken } from '../mapper';
import { AppError, AuthenticationError, RateLimitError } from '@/common/errors';
import { checkRateLimit, revokeToken } from '@/infrastructure/cache';
import { logger } from '@/infrastructure/observability';
import { authConfig } from '@/config';
import {
authRepository,
AuthRepository,
resetTokenRepository,
ResetTokenRepository,
} from '../repository';
import {
verifyPassword,
signToken,
verifyToken,
hashPassword,
generateResetToken,
hashResetToken,
validatePasswordStrength,
} from '../mapper';
import { LoginBody } from '../schema';
export interface LoginResult {
@@ -15,14 +30,29 @@ function toPublicUser(user: User): LoginResult['user'] {
}
export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {}
constructor(
private readonly repo: AuthRepository = authRepository,
private readonly resetTokens: ResetTokenRepository = resetTokenRepository,
) {}
/**
* 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.
* 013-auth-hardening FR-006/FR-007: the rate-limit check runs first, before any credential
* work — a rate-limited attempt never reaches (and can't distinguish itself via timing from)
* the identical-failure-response path below.
*/
async login(body: LoginBody): Promise<LoginResult> {
const rateLimit = await checkRateLimit(
`login:${body.email}`,
authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds,
);
if (!rateLimit.allowed) {
throw new RateLimitError('Too many login attempts. Try again later.');
}
const user = await this.repo.findByEmail(body.email);
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
@@ -46,6 +76,59 @@ export class AuthService {
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
await revokeToken(payload.jti, remainingSeconds);
}
/**
* 013-auth-hardening FR-001/SC-001: always resolves the same way regardless of whether the
* email corresponds to a real, active account — only issues a real token when it does. The
* "delivery" step is a stubbed structured log line (research.md), not a real email.
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.repo.findByEmail(email);
if (user && user.active) {
const { token, tokenHash } = generateResetToken();
await this.resetTokens.issue(
user.id,
tokenHash,
authConfig.passwordResetTokenLifetimeMinutes * 60,
);
logger.info(
{
event: 'password_reset_requested',
userId: user.id,
resetUrl: `/reset-password?token=${token}`,
},
'Password reset requested — stubbed delivery (013-auth-hardening research.md): no real ' +
'email is sent yet, this log line is the only place the token is visible.',
);
}
// Same outcome either way (FR-001) — no branch here reveals which case fired.
}
/**
* 013-auth-hardening FR-004/FR-005: password strength is checked before the token is even
* looked up (data-model.md); the token itself is single-use (SC-002) — resolving and
* consuming it happen together so a second attempt with the same token always fails.
* Edge Cases: a token issued for an account later deactivated is rejected — reactivation is
* 010's own admin domain, not something this flow performs incidentally.
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
validatePasswordStrength(newPassword);
const tokenHash = hashResetToken(token);
const userId = await this.resetTokens.resolve(tokenHash);
if (!userId) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
await this.resetTokens.consume(tokenHash, userId);
const user = await this.repo.findActiveById(userId);
if (!user) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
const passwordHash = await hashPassword(newPassword);
await this.repo.updatePassword(userId, passwordHash);
}
}
export const authService = new AuthService();

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