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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d58bc98c7f
commit
23fadebb5c
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user