Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9606b6aa | ||
|
|
d574af087a | ||
|
|
23fadebb5c | ||
|
|
d58bc98c7f |
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
|
||||||
@@ -507,6 +507,7 @@ model Assignment {
|
|||||||
unassignedAt DateTime?
|
unassignedAt DateTime?
|
||||||
|
|
||||||
@@index([ticketId, isCurrent])
|
@@index([ticketId, isCurrent])
|
||||||
|
@@index([agentId, isCurrent])
|
||||||
@@map("assignments")
|
@@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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -10,6 +10,7 @@ export interface UpdateAgentData {
|
|||||||
name?: string | undefined;
|
name?: string | undefined;
|
||||||
teamId?: string | undefined;
|
teamId?: string | undefined;
|
||||||
active?: boolean | undefined;
|
active?: boolean | undefined;
|
||||||
|
userId?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindAgentsFilter {
|
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[]> {
|
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||||
return this.prisma.agent.findMany({
|
return this.prisma.agent.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export class UsersRepository {
|
|||||||
return this.prisma.user.findUnique({ where: { email } });
|
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> {
|
async create(data: CreateUserData): Promise<User> {
|
||||||
return this.prisma.user.create({ data });
|
return this.prisma.user.create({ data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const updateAgentSchema = z
|
|||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
teamId: z.string().min(1).optional(),
|
teamId: z.string().min(1).optional(),
|
||||||
active: z.boolean().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();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Agent } from '@prisma/client';
|
import { Agent } from '@prisma/client';
|
||||||
import { NotFoundError } from '@/common/errors';
|
import { ConflictError, NotFoundError, ValidationError } from '@/common/errors';
|
||||||
import { teamsRepository } from '@/modules/identity/teams';
|
import { teamsRepository } from '@/modules/identity/teams';
|
||||||
import {
|
import {
|
||||||
agentsRepository,
|
agentsRepository,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CreateAgentData,
|
CreateAgentData,
|
||||||
UpdateAgentData,
|
UpdateAgentData,
|
||||||
FindAgentsFilter,
|
FindAgentsFilter,
|
||||||
|
usersRepository,
|
||||||
} from '../repository';
|
} from '../repository';
|
||||||
|
|
||||||
export class AgentsService {
|
export class AgentsService {
|
||||||
@@ -26,6 +27,20 @@ export class AgentsService {
|
|||||||
const team = await teamsRepository.findById(data.teamId);
|
const team = await teamsRepository.findById(data.teamId);
|
||||||
if (!team) throw new NotFoundError('Team not found.');
|
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);
|
const updated = await this.repo.update(agentId, data);
|
||||||
if (!updated) throw new NotFoundError('Agent not found.');
|
if (!updated) throw new NotFoundError('Agent not found.');
|
||||||
return updated;
|
return updated;
|
||||||
@@ -37,6 +52,15 @@ export class AgentsService {
|
|||||||
return agent;
|
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[]> {
|
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
|
||||||
return this.repo.findAll(filter);
|
return this.repo.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
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 { ticketsService, TicketsService } from '../service';
|
||||||
import { updateTicketStatusSchema } from '../schema';
|
import { updateTicketStatusSchema } from '../schema';
|
||||||
|
|
||||||
@@ -49,6 +50,24 @@ export class TicketsController {
|
|||||||
const reopened = await this.service.reopen(ticketId, 'customer');
|
const reopened = await this.service.reopen(ticketId, 'customer');
|
||||||
return reply.status(200).send({ success: true, data: reopened, meta: null });
|
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();
|
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-state-machine';
|
||||||
export * from './ticket-code';
|
export * from './ticket-code';
|
||||||
|
export * from './assigned-ticket-summary';
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ export class TicketsRepository {
|
|||||||
where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } },
|
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();
|
export const ticketsRepository = new TicketsRepository();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { requireRole } from '@/modules/identity/auth';
|
||||||
import { ticketsController } from '../controller';
|
import { ticketsController } from '../controller';
|
||||||
|
|
||||||
export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
@@ -6,6 +7,17 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
|||||||
ticketsController.getById(req, reply),
|
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) =>
|
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||||
ticketsController.updateStatus(req, reply),
|
ticketsController.updateStatus(req, reply),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
isValidTransition,
|
isValidTransition,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
} from '../mapper/ticket-state-machine';
|
} from '../mapper/ticket-state-machine';
|
||||||
|
import { toAssignedTicketSummary } from '../mapper/assigned-ticket-summary';
|
||||||
|
import { AssignedTicketSummary } from '../types';
|
||||||
import { messagesService, MessagesService } from '@/modules/ticketing/messages';
|
import { messagesService, MessagesService } from '@/modules/ticketing/messages';
|
||||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
import { eventBus, DomainEventName } from '@/events';
|
import { eventBus, DomainEventName } from '@/events';
|
||||||
@@ -191,6 +193,13 @@ export class TicketsService {
|
|||||||
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
|
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
|
||||||
return this.updateStatus(ticketId, 'IN_PROGRESS', reopened.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();
|
export const ticketsService = new TicketsService();
|
||||||
|
|||||||
@@ -8,3 +8,21 @@ export interface TicketDTO {
|
|||||||
severity: string;
|
severity: string;
|
||||||
version: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user