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:
co-authored by
Claude Sonnet 5
parent
d574af087a
commit
fb9606b6aa
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
|
||||
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user