diff --git a/prisma/migrations/20260907130000_add_assignment_agent_index/migration.sql b/prisma/migrations/20260907130000_add_assignment_agent_index/migration.sql new file mode 100644 index 0000000..0e9f8a2 --- /dev/null +++ b/prisma/migrations/20260907130000_add_assignment_agent_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5d2755..24a9294 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -507,6 +507,7 @@ model Assignment { unassignedAt DateTime? @@index([ticketId, isCurrent]) + @@index([agentId, isCurrent]) @@map("assignments") } diff --git a/specs/011-agent-ticket-queue/checklists/requirements.md b/specs/011-agent-ticket-queue/checklists/requirements.md index 461d3d9..c5542a2 100644 --- a/specs/011-agent-ticket-queue/checklists/requirements.md +++ b/specs/011-agent-ticket-queue/checklists/requirements.md @@ -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. diff --git a/specs/011-agent-ticket-queue/tasks.md b/specs/011-agent-ticket-queue/tasks.md index 8d269ed..c7d32e6 100644 --- a/specs/011-agent-ticket-queue/tasks.md +++ b/specs/011-agent-ticket-queue/tasks.md @@ -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 --- diff --git a/src/modules/identity/agents/repository/agents.repository.ts b/src/modules/identity/agents/repository/agents.repository.ts index df26760..bc34b40 100644 --- a/src/modules/identity/agents/repository/agents.repository.ts +++ b/src/modules/identity/agents/repository/agents.repository.ts @@ -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 { + return this.prisma.agent.findUnique({ where: { userId } }); + } + async findAll(filter: FindAgentsFilter): Promise { return this.prisma.agent.findMany({ where: { diff --git a/src/modules/identity/agents/repository/users.repository.ts b/src/modules/identity/agents/repository/users.repository.ts index 6c21074..d8f88dd 100644 --- a/src/modules/identity/agents/repository/users.repository.ts +++ b/src/modules/identity/agents/repository/users.repository.ts @@ -15,6 +15,10 @@ export class UsersRepository { return this.prisma.user.findUnique({ where: { email } }); } + async findById(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + async create(data: CreateUserData): Promise { return this.prisma.user.create({ data }); } diff --git a/src/modules/identity/agents/schema/agents.schema.ts b/src/modules/identity/agents/schema/agents.schema.ts index 4c7f5cf..c96743b 100644 --- a/src/modules/identity/agents/schema/agents.schema.ts +++ b/src/modules/identity/agents/schema/agents.schema.ts @@ -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(); diff --git a/src/modules/identity/agents/service/agents.service.ts b/src/modules/identity/agents/service/agents.service.ts index cec751a..641dc3b 100644 --- a/src/modules/identity/agents/service/agents.service.ts +++ b/src/modules/identity/agents/service/agents.service.ts @@ -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 { + 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 { return this.repo.findAll(filter); } diff --git a/src/modules/ticketing/tickets/controller/tickets.controller.ts b/src/modules/ticketing/tickets/controller/tickets.controller.ts index fd92e15..183fcdb 100644 --- a/src/modules/ticketing/tickets/controller/tickets.controller.ts +++ b/src/modules/ticketing/tickets/controller/tickets.controller.ts @@ -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(); diff --git a/src/modules/ticketing/tickets/mapper/assigned-ticket-summary.ts b/src/modules/ticketing/tickets/mapper/assigned-ticket-summary.ts new file mode 100644 index 0000000..5db5ecd --- /dev/null +++ b/src/modules/ticketing/tickets/mapper/assigned-ticket-summary.ts @@ -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, + }; +} diff --git a/src/modules/ticketing/tickets/mapper/index.ts b/src/modules/ticketing/tickets/mapper/index.ts index 4907405..0d1de91 100644 --- a/src/modules/ticketing/tickets/mapper/index.ts +++ b/src/modules/ticketing/tickets/mapper/index.ts @@ -6,3 +6,4 @@ export class TicketMapper { export * from './ticket-state-machine'; export * from './ticket-code'; +export * from './assigned-ticket-summary'; diff --git a/src/modules/ticketing/tickets/repository/tickets.repository.ts b/src/modules/ticketing/tickets/repository/tickets.repository.ts index 8348919..b5aeeb1 100644 --- a/src/modules/ticketing/tickets/repository/tickets.repository.ts +++ b/src/modules/ticketing/tickets/repository/tickets.repository.ts @@ -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(); diff --git a/src/modules/ticketing/tickets/routes/tickets.routes.ts b/src/modules/ticketing/tickets/routes/tickets.routes.ts index c82f7a7..c9cabcb 100644 --- a/src/modules/ticketing/tickets/routes/tickets.routes.ts +++ b/src/modules/ticketing/tickets/routes/tickets.routes.ts @@ -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 { @@ -6,6 +7,17 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise { 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), ); diff --git a/src/modules/ticketing/tickets/service/tickets.service.ts b/src/modules/ticketing/tickets/service/tickets.service.ts index 3848c81..36eacd4 100644 --- a/src/modules/ticketing/tickets/service/tickets.service.ts +++ b/src/modules/ticketing/tickets/service/tickets.service.ts @@ -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 { + const tickets = await this.ticketsRepo.findAssignedToAgent(agentId); + return tickets.map(toAssignedTicketSummary); + } } export const ticketsService = new TicketsService(); diff --git a/src/modules/ticketing/tickets/types/tickets.types.ts b/src/modules/ticketing/tickets/types/tickets.types.ts index 5f73527..a0514c2 100644 --- a/src/modules/ticketing/tickets/types/tickets.types.ts +++ b/src/modules/ticketing/tickets/types/tickets.types.ts @@ -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; +} diff --git a/tests/integration/agent-ticket-queue.test.ts b/tests/integration/agent-ticket-queue.test.ts new file mode 100644 index 0000000..d0ca70b --- /dev/null +++ b/tests/integration/agent-ticket-queue.test.ts @@ -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 { + 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 { + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email, password }, + }); + return res.json().data.token as string; + } + + async function createTicket(): Promise { + 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 { + 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); + }); +}); diff --git a/tests/unit/identity/agent-ticket-queue-guard.test.ts b/tests/unit/identity/agent-ticket-queue-guard.test.ts new file mode 100644 index 0000000..1f6cdf3 --- /dev/null +++ b/tests/unit/identity/agent-ticket-queue-guard.test.ts @@ -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.', + }); + }); +});