Author SHA1 Message Date
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
86 changed files with 3690 additions and 25 deletions
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
+1
View File
@@ -507,6 +507,7 @@ model Assignment {
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@index([agentId, isCurrent])
@@map("assignments")
}
@@ -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,43 @@
# 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).
+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.
+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,
};
+9
View File
@@ -65,6 +65,15 @@ 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),
});
export type EnvConfig = z.infer<typeof envSchema>;
@@ -20,6 +20,15 @@ export class KnowledgeController {
return reply.status(201).send({ success: true, data: entry, meta: null });
}
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
* unlike GET /knowledge/retrieve which is published-only). */
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
const { externalProductId } = request.params as { externalProductId: string };
const productId = await resolveProductId(externalProductId);
const entries = await this.service.listForGovernance(productId);
return reply.status(200).send({ success: true, data: entries, meta: null });
}
async publish(request: FastifyRequest, reply: FastifyReply) {
const { code } = request.params as { code: string };
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
});
}
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
* governance screen (which must see drafts to publish them) needs its own query. */
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
return this.prisma.knowledgeEntry.findMany({
where: { productId, isCurrentVersion: true },
orderBy: { createdAt: 'desc' },
});
}
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
* is validated-first, then most-recently-effective. */
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
@@ -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')] },
@@ -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();
@@ -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();
@@ -5,6 +5,7 @@ import {
createEscalationRuleSchema,
updateEscalationRuleSchema,
manualEscalationSchema,
listRecentEventsQuerySchema,
} from '../schema';
function actorFrom(request: FastifyRequest): string {
@@ -45,6 +46,23 @@ export class EscalationController {
return reply.status(204).send();
}
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
* view. */
async listRecentEvents(request: FastifyRequest, reply: FastifyReply) {
const { limit } = listRecentEventsQuerySchema.parse(request.query);
const events = await this.service.listRecentEvents(limit);
const data = events.map((event) => ({
ticketId: event.ticketId,
ticketCode: event.ticket.code,
reason: event.reason,
ruleId: event.ruleId,
triggeredBy: event.triggeredBy,
toNodeId: event.toNodeId,
createdAt: event.createdAt,
}));
return reply.status(200).send({ success: true, data, meta: null });
}
async escalateManually(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const body = manualEscalationSchema.parse(request.body);
@@ -25,6 +25,18 @@ export class EscalationEventRepository {
orderBy: { createdAt: 'asc' },
});
}
/** 012-admin-list-views: recent escalation events across every ticket, most-recent-first —
* the monitoring view's own data source. */
async findRecent(
limit: number,
): Promise<(EscalationEvent & { ticket: { id: string; code: string } })[]> {
return this.prisma.escalationEvent.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
include: { ticket: { select: { id: true, code: true } } },
});
}
}
export const escalationEventRepository = new EscalationEventRepository();
@@ -1,4 +1,4 @@
import { EscalationPolicy, Prisma } from '@prisma/client';
import { EscalationPolicy, EscalationRule, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class EscalationPolicyRepository {
@@ -17,8 +17,11 @@ export class EscalationPolicyRepository {
return this.prisma.escalationPolicy.findUnique({ where: { id } });
}
async findAll(): Promise<EscalationPolicy[]> {
return this.prisma.escalationPolicy.findMany();
/** 012-admin-list-views follow-up: includes each policy's own rules — GET
* /admin/escalation-policies previously returned bare policies with no way to read back
* which rules (trigger type + target node) already existed under one. */
async findAll(): Promise<(EscalationPolicy & { rules: EscalationRule[] })[]> {
return this.prisma.escalationPolicy.findMany({ include: { rules: true } });
}
/** research.md "Escalation policy resolution": prefer a product-specific active policy, fall
@@ -32,4 +32,10 @@ export async function escalationRoutes(fastify: FastifyInstance): Promise<void>
fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) =>
escalationController.escalateManually(req, reply),
);
// 012-admin-list-views: recent escalation events across every ticket, for the monitoring
// view — agent-usable, not admin-only, per that feature's own spec.md.
fastify.get('/admin/escalation-events', { preHandler: fastify.authenticate }, (req, reply) =>
escalationController.listRecentEvents(req, reply),
);
}
@@ -42,6 +42,12 @@ export const manualEscalationSchema = z
})
.strict();
/** 012-admin-list-views: caps the "recent escalation events" monitoring list — see that
* feature's own research.md. */
export const listRecentEventsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(200).default(50),
});
export type CreateEscalationPolicyBody = z.infer<typeof createEscalationPolicySchema>;
export type CreateEscalationRuleBody = z.infer<typeof createEscalationRuleSchema>;
export type UpdateEscalationRuleBody = z.infer<typeof updateEscalationRuleSchema>;
@@ -108,6 +108,12 @@ export class EscalationService {
return this.events.findAllForTicket(ticketId);
}
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
* view. */
async listRecentEvents(limit: number) {
return this.events.findRecent(limit);
}
async createPolicy(data: CreateEscalationPolicyBody): Promise<EscalationPolicy> {
if (data.productId) {
const product = await productsRepository.findById(data.productId);
@@ -41,6 +41,22 @@ export class SlaController {
const run = await this.service.getRunByTicketId(ticketId);
return reply.status(200).send({ success: true, data: run, meta: null });
}
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
async listRuns(request: FastifyRequest, reply: FastifyReply) {
const { status } = request.query as { status?: string };
const runs = await this.service.listRuns(status);
const data = runs.map((run) => ({
ticketId: run.ticketId,
ticketCode: run.ticket.code,
status: run.status,
firstResponseDueAt: run.firstResponseDueAt,
resolutionDueAt: run.resolutionDueAt,
breachedAt: run.breachedAt,
firstResponseBreachedAt: run.firstResponseBreachedAt,
}));
return reply.status(200).send({ success: true, data, meta: null });
}
}
export const slaController = new SlaController();
@@ -1 +1 @@
export {};
export * from './sla-run-status';
@@ -0,0 +1,6 @@
/** 008-sla-escalation's own SLARun.status vocabulary — centralized here since
* 012-admin-list-views is the first caller needing to validate against it (previously used only
* as free strings written by the pause/resume/breach-detection code paths). */
export const SLA_RUN_STATUSES = ['running', 'paused', 'warning', 'breached', 'completed'] as const;
export type SlaRunStatus = (typeof SLA_RUN_STATUSES)[number];
@@ -44,6 +44,15 @@ export class SlaRunRepository {
},
});
}
/** 012-admin-list-views: every SLA run across every ticket, optionally filtered by status —
* the monitoring view's own data source. */
async findAll(status?: string): Promise<(SLARun & { ticket: { id: string; code: string } })[]> {
return this.prisma.sLARun.findMany({
...(status ? { where: { status } } : {}),
include: { ticket: { select: { id: true, code: true } } },
});
}
}
export const slaRunRepository = new SlaRunRepository();
@@ -28,4 +28,10 @@ export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
(req, reply) => slaController.deactivatePolicy(req, reply),
);
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
// 012-admin-list-views: every SLA run across every ticket, for the monitoring view —
// agent-usable (fastify.authenticate only), not admin-only, per that feature's own spec.md.
fastify.get('/admin/sla-runs', { preHandler: fastify.authenticate }, (req, reply) =>
slaController.listRuns(req, reply),
);
}
@@ -1,5 +1,5 @@
import { SLAPolicy, SLARun } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { NotFoundError, ValidationError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { messagesService } from '@/modules/ticketing/messages';
import { escalationService, EscalationService } from '@/modules/orchestration/escalation';
@@ -12,6 +12,7 @@ import {
import { slaPolicyResolverService, SlaPolicyResolverService } from './sla-policy-resolver.service';
import { slaDueDateCalculator, SlaDueDateCalculator } from '../calculators/sla-due-date.calculator';
import { CreateSlaPolicyBody, UpdateSlaPolicyBody } from '../schema';
import { SLA_RUN_STATUSES, SlaRunStatus } from '../mapper';
export class SlaService {
constructor(
@@ -56,6 +57,14 @@ export class SlaService {
return run;
}
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
async listRuns(status?: string) {
if (status !== undefined && !SLA_RUN_STATUSES.includes(status as SlaRunStatus)) {
throw new ValidationError(`Invalid status. Must be one of: ${SLA_RUN_STATUSES.join(', ')}.`);
}
return this.runs.findAll(status);
}
/**
* research.md "SLA-run lifecycle is wired entirely through the existing domain-event bus":
* subscribed to TICKET_ASSIGNED. No-ops if the ticket already has a run (SLARun.ticketId
@@ -22,7 +22,7 @@ export class BusinessCalendarsController {
async getById(request: FastifyRequest, reply: FastifyReply) {
const { id } = request.params as { id: string };
const calendar = await this.service.getById(id);
const calendar = await this.service.getByIdWithHolidays(id);
return reply.status(200).send({ success: true, data: calendar, meta: null });
}
@@ -34,6 +34,14 @@ export class BusinessCalendarsService {
return calendar;
}
/** 012-admin-list-views follow-up: GET /admin/business-calendars/:id's own response — a
* calendar's holidays had no way to be read back at all before this (only added/removed). */
async getByIdWithHolidays(id: string): Promise<BusinessCalendar & { holidays: Holiday[] }> {
const calendar = await this.calendars.findByIdWithHolidays(id);
if (!calendar) throw new NotFoundError('Business calendar not found.');
return calendar;
}
async list(): Promise<BusinessCalendar[]> {
return this.calendars.findAll();
}
@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
import { AuthorizationError, NotFoundError } from '@/common/errors';
import { agentsRepository, agentsService } from '@/modules/identity/agents';
import { ticketsService, TicketsService } from '../service';
import { updateTicketStatusSchema } from '../schema';
@@ -49,6 +50,24 @@ export class TicketsController {
const reopened = await this.service.reopen(ticketId, 'customer');
return reply.status(200).send({ success: true, data: reopened, meta: null });
}
/** 011-agent-ticket-queue FR-004: agentId is always resolved from the caller's own session,
* never from request input. */
async listMyAssignedTickets(request: FastifyRequest, reply: FastifyReply) {
if (!request.user) throw new AuthorizationError('Session has no identity.');
const agent = await agentsService.requireAgentForUser(request.user.id);
const tickets = await this.service.listAssignedTo(agent.id);
return reply.status(200).send({ success: true, data: tickets, meta: null });
}
/** 011-agent-ticket-queue FR-005: admin-only, explicit-agentId equivalent. */
async listAssignedTicketsForAgent(request: FastifyRequest, reply: FastifyReply) {
const { agentId } = request.params as { agentId: string };
const agent = await agentsRepository.findById(agentId);
if (!agent) throw new NotFoundError('Agent not found.');
const tickets = await this.service.listAssignedTo(agentId);
return reply.status(200).send({ success: true, data: tickets, meta: null });
}
}
export const ticketsController = new TicketsController();
@@ -0,0 +1,42 @@
import { Assignment, CustomerReference, Product, SLARun, Ticket } from '@prisma/client';
import { AssignedTicketSummary } from '../types';
type TicketWithAssignmentRelations = Ticket & {
product: Product;
customer: CustomerReference;
slaRun: SLARun | null;
assignments: Assignment[];
};
/** 011-agent-ticket-queue data-model.md: the dashboard-ready projection returned by both
* GET /agents/me/tickets and GET /admin/agents/:agentId/tickets. */
export function toAssignedTicketSummary(
ticket: TicketWithAssignmentRelations,
): AssignedTicketSummary {
const [currentAssignment] = ticket.assignments;
return {
id: ticket.id,
code: ticket.code,
status: ticket.status,
priority: ticket.priority,
severity: ticket.severity,
product: {
id: ticket.product.id,
externalProductId: ticket.product.externalProductId,
name: ticket.product.name,
},
customer: {
externalUserId: ticket.customer.externalUserId,
externalTenantId: ticket.customer.externalTenantId,
},
assignedAt: currentAssignment ? currentAssignment.assignedAt.toISOString() : null,
sla: ticket.slaRun
? {
status: ticket.slaRun.status,
firstResponseDueAt: ticket.slaRun.firstResponseDueAt?.toISOString() ?? null,
resolutionDueAt: ticket.slaRun.resolutionDueAt?.toISOString() ?? null,
breachedAt: ticket.slaRun.breachedAt?.toISOString() ?? null,
}
: null,
};
}
@@ -6,3 +6,4 @@ export class TicketMapper {
export * from './ticket-state-machine';
export * from './ticket-code';
export * from './assigned-ticket-summary';
@@ -107,6 +107,23 @@ export class TicketsRepository {
where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } },
});
}
/** 011-agent-ticket-queue: every ticket this agent is CURRENTLY assigned to, with the
* product/customer/SLA-run relations a dashboard-style summary needs, in one query — no
* further per-ticket request required (FR-003/SC-001). Backed by
* Assignment @@index([agentId, isCurrent]). */
async findAssignedToAgent(agentId: string) {
return this.prisma.ticket.findMany({
where: { assignments: { some: { agentId, isCurrent: true } } },
include: {
product: true,
customer: true,
slaRun: true,
assignments: { where: { agentId, isCurrent: true }, take: 1 },
},
orderBy: { updatedAt: 'desc' },
});
}
}
export const ticketsRepository = new TicketsRepository();
@@ -1,4 +1,5 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { ticketsController } from '../controller';
export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
@@ -6,6 +7,17 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
ticketsController.getById(req, reply),
);
// 011-agent-ticket-queue: agent's-own-session query and the admin explicit-agent equivalent
// are deliberately two routes, not one with an optional param — see research.md.
fastify.get('/agents/me/tickets', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.listMyAssignedTickets(req, reply),
);
fastify.get(
'/admin/agents/:agentId/tickets',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => ticketsController.listAssignedTicketsForAgent(req, reply),
);
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.updateStatus(req, reply),
);
@@ -14,6 +14,8 @@ import {
isValidTransition,
TicketStatus,
} from '../mapper/ticket-state-machine';
import { toAssignedTicketSummary } from '../mapper/assigned-ticket-summary';
import { AssignedTicketSummary } from '../types';
import { messagesService, MessagesService } from '@/modules/ticketing/messages';
import { queueManager, QueueName } from '@/infrastructure/queue';
import { eventBus, DomainEventName } from '@/events';
@@ -191,6 +193,13 @@ export class TicketsService {
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
return this.updateStatus(ticketId, 'IN_PROGRESS', reopened.version, actor);
}
/** 011-agent-ticket-queue FR-003: every ticket currently assigned to this agent, summarized
* for a dashboard in one call. */
async listAssignedTo(agentId: string): Promise<AssignedTicketSummary[]> {
const tickets = await this.ticketsRepo.findAssignedToAgent(agentId);
return tickets.map(toAssignedTicketSummary);
}
}
export const ticketsService = new TicketsService();
@@ -8,3 +8,21 @@ export interface TicketDTO {
severity: string;
version: number;
}
/** 011-agent-ticket-queue data-model.md: dashboard-ready summary of a currently-assigned ticket. */
export interface AssignedTicketSummary {
id: string;
code: string;
status: string;
priority: string;
severity: string;
product: { id: string; externalProductId: string; name: string };
customer: { externalUserId: string; externalTenantId: string };
assignedAt: string | null;
sla: {
status: string;
firstResponseDueAt: string | null;
resolutionDueAt: string | null;
breachedAt: string | null;
} | null;
}
+12 -8
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { prismaClient } from '@/infrastructure/database';
@@ -6,20 +7,23 @@ const TEST_PASSWORD = 'Test-Password-123!';
/**
* 010-identity-auth made fastify.authenticate real — every test file calling a route already
* gated by it (across 002-009's own suites) needs a real session now. Rather than depend on
* gated by it (across 002-009's own suites) needs a real session now. This creates its own
* throwaway admin/agent account directly and logs in as it, so callers don't depend on
* prisma/seed/roles.seed.ts having already been run against whatever database the suite
* connects to, this upserts its own throwaway admin/agent account directly (idempotent — safe
* to call from many test files' own beforeAll against the same database) and logs in as it.
* connects to.
*
* 013-auth-hardening: the email is unique per call (not a fixed `test-admin@...` shared across
* every integration test file) because login is now rate-limited per email — dozens of files
* each calling this once in their own beforeAll would otherwise share one rate-limit bucket and
* trip it well before any file's own tests get to run.
*/
export async function loginAs(
app: FastifyInstance,
role: 'ADMIN' | 'AGENT' = 'ADMIN',
): Promise<string> {
const email = `test-${role.toLowerCase()}@supporthub.test`;
await prismaClient.user.upsert({
where: { email },
update: {},
create: {
const email = `test-${role.toLowerCase()}-${randomUUID()}@supporthub.test`;
await prismaClient.user.create({
data: {
email,
name: `Test ${role}`,
role,
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/012-admin-list-views/quickstart.md Scenarios 1-3 against a real Postgres/Redis
* — SLA runs, escalation events, and the product catalog, all listed across multiple
* tickets/products in one request.
*/
describe('Admin list views (User Stories 1-3)', () => {
let app: FastifyInstance;
let adminToken: string;
let agentToken: string;
const suffix = Date.now();
const externalProductId = `TEST_ALV_PROD_${suffix}`;
const skillTag = `alv_skill_${suffix}`;
let productId: string;
let teamId: string;
let agentAId: string;
let secret: string;
let nodeAId: string;
let nodeBId: string;
let globalPolicyId: string;
const createdTicketIds: string[] = [];
async function createTicket(): Promise<string> {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: `Needs a human ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
return ticketId;
}
async function escalate(ticketId: string): Promise<void> {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
beforeAll(async () => {
app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
agentToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Admin List Views Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(adminToken),
payload: { name: `ALV Team ${suffix}` },
});
teamId = team.json().data.id;
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'ALV Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
const nodeA = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: {
name: 'ALV Node A',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
nodeAId = nodeA.json().data.id;
const nodeB = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: {
name: 'ALV Node B',
order: 1,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
nodeBId = nodeB.json().data.id;
const policy = await app.inject({
method: 'POST',
url: '/admin/sla-policies',
headers: authHeader(adminToken),
payload: {
name: `ALV Policy ${suffix}`,
productId,
firstResponseMinutes: 30,
resolutionMinutes: 60,
},
});
globalPolicyId = policy.json().data.id;
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.sLARun.deleteMany({ where: { policyId: globalPolicyId } });
await prismaClient.sLAPolicy.deleteMany({ where: { id: globalPolicyId } });
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } });
await prismaClient.agentSkill.deleteMany({ where: { agentId: agentAId } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('US1: SLA runs are listed across tickets, filterable by status, and reject an invalid status', async () => {
const ticket1 = await createTicket();
await escalate(ticket1);
const unfiltered = await app.inject({
method: 'GET',
url: '/admin/sla-runs',
headers: authHeader(agentToken),
});
expect(unfiltered.statusCode).toBe(200);
const ours = unfiltered.json().data.filter((r: { ticketId: string }) => r.ticketId === ticket1);
expect(ours).toHaveLength(1);
expect(ours[0].ticketCode).toBeTruthy();
const filtered = await app.inject({
method: 'GET',
url: '/admin/sla-runs?status=running',
headers: authHeader(agentToken),
});
expect(filtered.statusCode).toBe(200);
expect(filtered.json().data.every((r: { status: string }) => r.status === 'running')).toBe(
true,
);
const invalid = await app.inject({
method: 'GET',
url: '/admin/sla-runs?status=not-a-real-status',
headers: authHeader(agentToken),
});
expect(invalid.statusCode).toBe(400);
});
it('US2: recent escalation events are listed across tickets, most-recent-first', async () => {
const ticket2 = await createTicket();
await escalate(ticket2);
await new Promise((resolve) => setTimeout(resolve, 10));
const manual = await app.inject({
method: 'POST',
url: `/tickets/${ticket2}/escalate`,
headers: authHeader(agentToken),
payload: { targetNodeId: nodeBId, reason: 'test manual escalation' },
});
expect(manual.statusCode).toBe(201);
const events = await app.inject({
method: 'GET',
url: '/admin/escalation-events',
headers: authHeader(agentToken),
});
expect(events.statusCode).toBe(200);
const ours = events.json().data.filter((e: { ticketId: string }) => e.ticketId === ticket2);
expect(ours.length).toBeGreaterThanOrEqual(1);
const manualEvent = ours.find((e: { ruleId: string | null }) => e.ruleId === null);
expect(manualEvent).toBeDefined();
expect(manualEvent.triggeredBy).toBeTruthy();
expect(manualEvent.toNodeId).toBe(nodeBId);
});
it('US3: the product catalog shows integration status, and is admin-only', async () => {
const asAdmin = await app.inject({
method: 'GET',
url: '/admin/products',
headers: authHeader(adminToken),
});
expect(asAdmin.statusCode).toBe(200);
const ours = asAdmin.json().data.find((p: { id: string }) => p.id === productId);
expect(ours.integrationStatus).toBe('active');
expect(ours.credentialRef).toBeUndefined();
const asAgent = await app.inject({
method: 'GET',
url: '/admin/products',
headers: authHeader(agentToken),
});
expect(asAgent.statusCode).toBe(403);
});
});
@@ -0,0 +1,298 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/011-agent-ticket-queue/quickstart.md Scenarios 1-3 against a real Postgres/
* Redis — linking a User to an Agent roster row (and its rejection rules), an agent listing
* their own currently-assigned tickets, and the admin equivalent for an explicit agent.
*/
describe('Agent ticket queue (User Stories 1-2)', () => {
let app: FastifyInstance;
let adminToken: string;
const suffix = Date.now();
const externalProductId = `TEST_ATQ_PROD_${suffix}`;
const skillTag = `atq_skill_${suffix}`;
const password = 'Agent-Queue-Test-1!';
let productId: string;
let teamId: string;
let agentXId: string;
let agentYId: string;
let secret: string;
const createdUserIds: string[] = [];
const createdTicketIds: string[] = [];
async function createUser(email: string, role: 'ADMIN' | 'AGENT'): Promise<string> {
const user = await prismaClient.user.create({
data: { email, name: email, role, passwordHash: await bcrypt.hash(password, 10) },
});
createdUserIds.push(user.id);
return user.id;
}
async function loginAsUser(email: string): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password },
});
return res.json().data.token as string;
}
async function createTicket(): Promise<string> {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: `Needs a human ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
return ticketId;
}
async function assignTo(ticketId: string, agentId: string): Promise<void> {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
if (ticket.status === 'NEW') {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId, reason: 'test setup', strategy: 'MANUAL' },
});
}
beforeAll(async () => {
app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Agent Ticket Queue Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(adminToken),
payload: { name: `ATQ Team ${suffix}` },
});
teamId = team.json().data.id;
const agentX = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'ATQ Agent X' },
});
agentXId = agentX.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentXId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
const agentY = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'ATQ Agent Y' },
});
agentYId = agentY.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentYId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: {
name: 'ATQ Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'ATQ Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentXId, agentYId] } } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
// A wildcard (non-product-scoped) SLA policy from another concurrently-running suite (e.g.
// sla-escalation-flow.test.ts's own "Global policy") can match these tickets too, leaving a
// real sla_run row that would otherwise RESTRICT this delete.
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await prismaClient.user.deleteMany({ where: { id: { in: createdUserIds } } });
await app.close();
});
it('User Story 1: linking succeeds, rejects a non-AGENT role, and rejects a duplicate link', async () => {
const userXId = await createUser(`atq-agent-x-${suffix}@supporthub.test`, 'AGENT');
const userYId = await createUser(`atq-agent-y-${suffix}@supporthub.test`, 'AGENT');
const adminRoleUserId = await createUser(`atq-admin-role-${suffix}@supporthub.test`, 'ADMIN');
const link = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentXId}`,
headers: authHeader(adminToken),
payload: { userId: userXId },
});
expect(link.statusCode).toBe(200);
expect(link.json().data.userId).toBe(userXId);
const nonAgentRole = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: adminRoleUserId },
});
expect(nonAgentRole.statusCode).toBe(400);
const duplicateLink = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: userXId },
});
expect(duplicateLink.statusCode).toBe(409);
const linkY = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: userYId },
});
expect(linkY.statusCode).toBe(200);
});
it('User Story 2: an agent sees exactly their own current assignments, live', async () => {
const agentXToken = await loginAsUser(`atq-agent-x-${suffix}@supporthub.test`);
const agentYToken = await loginAsUser(`atq-agent-y-${suffix}@supporthub.test`);
const ticket1 = await createTicket();
const ticket2 = await createTicket();
const ticket3 = await createTicket();
await assignTo(ticket1, agentXId);
await assignTo(ticket2, agentXId);
await assignTo(ticket3, agentYId);
const xList = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentXToken),
});
expect(xList.statusCode).toBe(200);
const xIds = xList.json().data.map((t: { id: string }) => t.id);
expect(xIds.sort()).toEqual([ticket1, ticket2].sort());
const firstEntry = xList.json().data[0];
expect(firstEntry).toHaveProperty('code');
expect(firstEntry).toHaveProperty('product.externalProductId', externalProductId);
expect(firstEntry).toHaveProperty('customer.externalUserId', 'user-1');
// Reassign ticket1 away from X — the list must reflect live state, not a snapshot.
await assignTo(ticket1, agentYId);
const xListAfter = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentXToken),
});
expect(xListAfter.json().data.map((t: { id: string }) => t.id)).toEqual([ticket2]);
const yList = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentYToken),
});
expect(
yList
.json()
.data.map((t: { id: string }) => t.id)
.sort(),
).toEqual([ticket1, ticket3].sort());
});
it('User Story 2: a session with no linked agent is rejected distinctly from an empty list', async () => {
await createUser(`atq-unlinked-${suffix}@supporthub.test`, 'AGENT');
const unlinkedToken = await loginAsUser(`atq-unlinked-${suffix}@supporthub.test`);
const res = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(unlinkedToken),
});
expect(res.statusCode).toBe(404);
});
it('User Story 2: the admin route returns the same shape for an explicit agent, and rejects a non-admin', async () => {
const agentYToken = await loginAsUser(`atq-agent-y-${suffix}@supporthub.test`);
const asAdmin = await app.inject({
method: 'GET',
url: `/admin/agents/${agentYId}/tickets`,
headers: authHeader(adminToken),
});
expect(asAdmin.statusCode).toBe(200);
expect(Array.isArray(asAdmin.json().data)).toBe(true);
const asNonAdmin = await app.inject({
method: 'GET',
url: `/admin/agents/${agentYId}/tickets`,
headers: authHeader(agentYToken),
});
expect(asNonAdmin.statusCode).toBe(403);
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/**
* Covers specs/008-sla-escalation's business-calendar create/holiday flow, plus
* 012-admin-list-views' own follow-up: GET /admin/business-calendars/:id now includes holidays
* (previously only addable/removable, never readable back).
*/
describe('Business calendars — create, add a holiday, and read both back', () => {
let app: FastifyInstance;
let token: string;
let calendarId: string;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
});
afterAll(async () => {
await prismaClient.holiday.deleteMany({ where: { calendarId } });
await prismaClient.businessCalendar.deleteMany({ where: { id: calendarId } });
await app.close();
});
it('a created calendar and an added holiday are both displayed back exactly as entered', async () => {
const created = await app.inject({
method: 'POST',
url: '/admin/business-calendars',
headers: authHeader(token),
payload: {
name: `Test Calendar ${Date.now()}`,
timezone: 'America/New_York',
workingHours: { mon: { start: '09:00', end: '17:00' } },
},
});
expect(created.statusCode).toBe(201);
calendarId = created.json().data.id;
const holiday = await app.inject({
method: 'POST',
url: `/admin/business-calendars/${calendarId}/holidays`,
headers: authHeader(token),
payload: { date: '2026-12-25', description: 'Christmas' },
});
expect(holiday.statusCode).toBe(201);
const fetched = await app.inject({
method: 'GET',
url: `/admin/business-calendars/${calendarId}`,
headers: authHeader(token),
});
expect(fetched.statusCode).toBe(200);
expect(fetched.json().data.workingHours.mon).toEqual({ start: '09:00', end: '17:00' });
expect(fetched.json().data.holidays).toHaveLength(1);
expect(fetched.json().data.holidays[0].description).toBe('Christmas');
});
});
@@ -58,6 +58,38 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
expect(afterPublish.json().data.find((e: { code: string }) => e.code === code)).toBeDefined();
});
it('012-admin-list-views follow-up: the governance list shows a draft entry, unlike retrieve', async () => {
const draftCode = `KB-TEST-DRAFT-${Date.now()}`;
await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code: draftCode, type: 'faq', problem: 'Still a draft' },
});
const governanceList = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
});
expect(governanceList.statusCode).toBe(200);
const draftEntry = governanceList
.json()
.data.find((e: { code: string }) => e.code === draftCode);
expect(draftEntry).toBeDefined();
expect(draftEntry.status).toBe('draft');
const retrieveResult = await app.inject({
method: 'GET',
url: `/knowledge/retrieve?productId=${externalProductId}`,
});
expect(
retrieveResult.json().data.find((e: { code: string }) => e.code === draftCode),
).toBeUndefined();
await prismaClient.knowledgeEntry.deleteMany({ where: { code: draftCode } });
});
it('Scenario 2: editing creates a new version and preserves the prior one', async () => {
const editResponse = await app.inject({
method: 'PUT',
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { authConfig } from '@/config';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 3 against a real Postgres/Redis.
*/
describe('Login rate limiting (User Story 3)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `rate-limit-test-${suffix}@supporthub.test`;
const otherEmail = `rate-limit-other-${suffix}@supporthub.test`;
const correctPassword = 'Correct-Password-1!';
let userId: string;
let otherUserId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Rate Limit Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
userId = user.id;
const otherUser = await prismaClient.user.create({
data: {
email: otherEmail,
name: 'Rate Limit Other User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
otherUserId = otherUser.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } });
await app.close();
});
it('blocks the same email after its attempt budget is exhausted, without affecting other emails', async () => {
for (let i = 0; i < authConfig.loginRateLimitMaxAttempts; i++) {
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: 'definitely-wrong' },
});
expect(res.statusCode).toBe(401);
}
// One more attempt for the same email, this time with the CORRECT password — still 429.
const blockedRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: correctPassword },
});
expect(blockedRes.statusCode).toBe(429);
// A different email in the same window is unaffected.
const otherRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: otherEmail, password: correctPassword },
});
expect(otherRes.statusCode).toBe(200);
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { logger } from '@/infrastructure/observability';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 1 against a real Postgres/Redis — the
* full request -> (read the token from the stub's own log line) -> consume -> login-with-new-
* password flow, and the identical-response-regardless-of-existing-account behavior.
*/
describe('Password reset flow (User Story 1)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `reset-test-${suffix}@supporthub.test`;
const originalPassword = 'Original-Password-1!';
const newPassword = 'Brand-New-Password-2!';
let userId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Reset Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(originalPassword, 10),
},
});
userId = user.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: userId } });
await app.close();
});
function extractLoggedToken(): string {
const infoSpy = vi.mocked(logger.info);
const call = infoSpy.mock.calls.find(
([data]) => (data as { event?: string }).event === 'password_reset_requested',
);
if (!call) throw new Error('Expected a password_reset_requested log line, but none was found.');
const resetUrl = (call[0] as unknown as { resetUrl: string }).resetUrl;
const token = new URL(resetUrl, 'http://localhost').searchParams.get('token');
if (!token) throw new Error('Expected the logged resetUrl to carry a token query param.');
return token;
}
it('Scenario 1: request -> stub-logged token -> consume -> login with the new password', async () => {
const infoSpy = vi.spyOn(logger, 'info');
const requestRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email },
});
expect(requestRes.statusCode).toBe(200);
expect(requestRes.json().data.message).not.toMatch(/token|[a-f0-9]{64}/i);
const nonexistentRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email: `nobody-${suffix}@supporthub.test` },
});
expect(nonexistentRes.statusCode).toBe(200);
expect(nonexistentRes.json()).toEqual(requestRes.json());
const token = extractLoggedToken();
const consumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword },
});
expect(consumeRes.statusCode).toBe(200);
// Single-use — the same token fails a second time.
const secondConsumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword: 'Another-Password-3!' },
});
expect(secondConsumeRes.statusCode).toBe(400);
const loginWithNew = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: newPassword },
});
expect(loginWithNew.statusCode).toBe(200);
const loginWithOld = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: originalPassword },
});
expect(loginWithOld.statusCode).toBe(401);
infoSpy.mockRestore();
});
it('rejects an invalid token outright', async () => {
const res = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token: 'not-a-real-token', newPassword: 'Whatever-Password-1!' },
});
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { Agent } from '@prisma/client';
import { AgentsService } from '@/modules/identity/agents/service/agents.service';
import { AgentsRepository } from '@/modules/identity/agents/repository/agents.repository';
function fakeRepo(agent: Agent | null): AgentsRepository {
return { findByUserId: async () => agent } as unknown as AgentsRepository;
}
describe('AgentsService.requireAgentForUser', () => {
it('resolves the linked Agent when one exists', async () => {
const agent = { id: 'agent-1', userId: 'user-1' } as Agent;
const service = new AgentsService(fakeRepo(agent));
await expect(service.requireAgentForUser('user-1')).resolves.toBe(agent);
});
it('throws a specific NotFoundError when no Agent is linked to this User', async () => {
const service = new AgentsService(fakeRepo(null));
await expect(service.requireAgentForUser('user-2')).rejects.toMatchObject({
statusCode: 404,
message: 'No agent profile is linked to this account.',
});
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import bcrypt from 'bcryptjs';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10);
@@ -19,6 +20,10 @@ function fakeUser(overrides: Partial<Record<string, unknown>> = {}) {
}
describe('AuthService.login failure parity', () => {
beforeEach(() => {
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
});
it('throws the identical error for a nonexistent email and a wrong password', async () => {
const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never;
const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never;
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
describe('AuthService.login rate-limit ordering (User Story 3)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('checks the rate limit before ever looking up the account', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: false, count: 6 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 429 });
expect(findByEmail).not.toHaveBeenCalled();
});
it('proceeds to credential checks once the rate limit allows the attempt', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 401 });
expect(findByEmail).toHaveBeenCalledWith('agent@example.com');
});
});
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { validatePasswordStrength } from '@/modules/identity/auth/mapper/password-policy';
import { authConfig } from '@/config';
describe('validatePasswordStrength', () => {
it('rejects a password shorter than the configured minimum, naming the actual requirement', () => {
const tooShort = 'a'.repeat(authConfig.passwordMinLength - 1);
expect(() => validatePasswordStrength(tooShort)).toThrowError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
});
it('accepts a password meeting the configured minimum', () => {
const meetsPolicy = 'a'.repeat(authConfig.passwordMinLength);
expect(() => validatePasswordStrength(meetsPolicy)).not.toThrow();
});
});