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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:55:41 +05:30
56 changed files with 2274 additions and 8 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
@@ -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);
}
@@ -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;
}
+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,294 @@
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 });
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,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.',
});
});
});