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>
This commit is contained in:
saqib mir
2026-09-07 13:19:13 +05:30
co-authored by Claude Sonnet 5
parent d574af087a
commit fb9606b6aa
17 changed files with 503 additions and 17 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")
}
@@ -46,3 +46,13 @@
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.
+15 -15
View File
@@ -22,7 +22,7 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Foundational (Blocking Prerequisites)
- [ ] T001 Add `Assignment @@index([agentId, isCurrent])` to `prisma/schema.prisma`; generate
- [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`
@@ -40,24 +40,24 @@ rejection rules FR-001 requires.
### Tests for User Story 1
- [ ] T002 [P] [US1] Integration test covering Quickstart Scenario 1 (link succeeds; non-AGENT
- [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
- [ ] T003 [US1] Add `AgentsRepository.findByUserId(userId)` in
- [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)
- [ ] T004 [US1] Add `userId: z.string().min(1).nullable().optional()` to `updateAgentSchema` in
- [x] T004 [US1] Add `userId: z.string().min(1).nullable().optional()` to `updateAgentSchema` in
`src/modules/identity/agents/schema/agents.schema.ts`
- [ ] T005 [US1] In `AgentsService.update` (`src/modules/identity/agents/service/agents.service.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)
- [ ] T006 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
- [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.
@@ -72,9 +72,9 @@ caller.
### Tests for User Story 2
- [ ] T007 [P] [US2] Unit test: given a `User` id with no linked `Agent`, the service throws the
- [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`
- [ ] T008 [US2] Integration test covering Quickstart Scenarios 2-3 (agent sees exactly their
- [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
@@ -82,19 +82,19 @@ caller.
### Implementation for User Story 2
- [ ] T009 [US2] Add `TicketsRepository.findAssignedToAgent(agentId)` in
- [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)
- [ ] T010 [US2] Add `TicketsService.listAssignedTo(agentId)` mapping each row to the
- [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)
- [ ] T011 [US2] Add `GET /agents/me/tickets` (`fastify.authenticate` only; resolves `agentId`
- [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)
- [ ] T012 [US2] Run Quickstart Scenarios 2-3 locally and confirm all steps pass
- [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.
@@ -102,10 +102,10 @@ caller.
## Phase 4: Polish & Cross-Cutting Concerns
- [ ] T013 [P] Update `specs/011-agent-ticket-queue/checklists/requirements.md` Notes with any
- [x] T013 [P] Update `specs/011-agent-ticket-queue/checklists/requirements.md` Notes with any
implementation-time findings
- [ ] T014 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T015 Full regression: `npm run test:unit` then the full integration suite against real
- [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
---
@@ -10,6 +10,7 @@ export interface UpdateAgentData {
name?: string | undefined;
teamId?: string | undefined;
active?: boolean | undefined;
userId?: string | null | undefined;
}
export interface FindAgentsFilter {
@@ -44,6 +45,11 @@ export class AgentsRepository {
});
}
/** 011-agent-ticket-queue: resolves a logged-in session to its agent roster row. */
async findByUserId(userId: string): Promise<Agent | null> {
return this.prisma.agent.findUnique({ where: { userId } });
}
async findAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.prisma.agent.findMany({
where: {
@@ -15,6 +15,10 @@ export class UsersRepository {
return this.prisma.user.findUnique({ where: { email } });
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async create(data: CreateUserData): Promise<User> {
return this.prisma.user.create({ data });
}
@@ -11,6 +11,9 @@ export const updateAgentSchema = z
name: z.string().min(1).optional(),
teamId: z.string().min(1).optional(),
active: z.boolean().optional(),
// 011-agent-ticket-queue: links this agent to the User account it authenticates as.
// null explicitly unlinks; omitting the field leaves the existing link unchanged.
userId: z.string().min(1).nullable().optional(),
})
.strict();
@@ -1,5 +1,5 @@
import { Agent } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { ConflictError, NotFoundError, ValidationError } from '@/common/errors';
import { teamsRepository } from '@/modules/identity/teams';
import {
agentsRepository,
@@ -7,6 +7,7 @@ import {
CreateAgentData,
UpdateAgentData,
FindAgentsFilter,
usersRepository,
} from '../repository';
export class AgentsService {
@@ -26,6 +27,20 @@ export class AgentsService {
const team = await teamsRepository.findById(data.teamId);
if (!team) throw new NotFoundError('Team not found.');
}
// 011-agent-ticket-queue FR-001: proactive existence/role/duplicate-link checks, mirroring
// UsersService.create's own pre-check style, rather than translating a raw unique-
// constraint error after the fact.
if (data.userId !== undefined && data.userId !== null) {
const user = await usersRepository.findById(data.userId);
if (!user) throw new NotFoundError('User not found.');
if (user.role !== 'AGENT') {
throw new ValidationError('Only a User with role AGENT can be linked to an agent.');
}
const existingLink = await this.repo.findByUserId(data.userId);
if (existingLink && existingLink.id !== agentId) {
throw new ConflictError('This account is already linked to a different agent.');
}
}
const updated = await this.repo.update(agentId, data);
if (!updated) throw new NotFoundError('Agent not found.');
return updated;
@@ -37,6 +52,15 @@ export class AgentsService {
return agent;
}
/** 011-agent-ticket-queue FR-006: resolves a logged-in session to its own agent roster row,
* throwing a specific, distinguishable error rather than letting a caller mistake "no linked
* agent" for "an agent with zero results." */
async requireAgentForUser(userId: string): Promise<Agent> {
const agent = await this.repo.findByUserId(userId);
if (!agent) throw new NotFoundError('No agent profile is linked to this account.');
return agent;
}
async listAll(filter: FindAgentsFilter): Promise<Agent[]> {
return this.repo.findAll(filter);
}
@@ -1,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;
}
@@ -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.',
});
});
});