Extends the existing PATCH /admin/agents/:agentId with an optional userId to finish wiring 010's Agent.userId link, and adds GET /agents/me/tickets + GET /admin/agents/:agentId/tickets sharing one ticketing/tickets service method, backed by a new Assignment @@index([agentId, isCurrent]). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.0 KiB
5.0 KiB
Research: Agent Ticket Queue
Decision: extend the existing PATCH /admin/agents/:agentId, don't add a new link endpoint
- Decision: Add an optional
userId: z.string().min(1).nullable().optional()toupdateAgentSchemaand handle it inAgentsService.update(proactively check the targetUser's role and any existing link before writing, same pre-check style asUsersService.create's duplicate-email check — see 010-identity-auth), rather than a dedicatedPATCH /admin/agents/:agentId/link-userroute. - Rationale:
PATCH /admin/agents/:agentIdalready exists as the one place an agent's mutable fields are updated (name,teamId,active) —userIdis exactly that kind of field, not a distinct workflow. A second endpoint would duplicate routing/auth wiring for no behavioral gain. - Alternatives considered: A dedicated
/link-userendpoint — rejected as an unnecessary extra surface once the existing update endpoint's shape was checked and found to already fit.
Decision: proactive existence/role checks, not a caught unique-constraint error
- Decision: Before writing
userId, look up the targetUser(404 if it doesn't exist, a clear rejection if its role isn'tAGENT) and look up any existingAgentalready linked to thatuserId(a clearConflictErrorif one exists and isn't this same agent) — the same patternUsersService.create(010-identity-auth) already established for its own duplicate- email check, rather than letting Postgres's@uniqueconstraint onAgent.userIdthrow and translating that error after the fact. - Rationale: Consistency with the one precedent this codebase already has for "reject a
would-be duplicate before writing," and a clearer error message than parsing a raw
PrismaClientKnownRequestErrorcode. - Alternatives considered: Catch
P2002(unique constraint violation) and translate it — workable, but the proactive-check style already used byUsersService.createwas preferred for consistency within the same codebase.
Decision: the ticket-summary query lives in ticketing/tickets, not orchestration/assignments
- Decision:
TicketsService(or a newTicketsRepositorymethod) owns the new "tickets currently assigned to agent X" query, readingAssignmentrows viaorchestration/assignments's own already-public repository/service surface (itsindex.ts), not by reaching intoorchestration's internals. - Rationale: The result is fundamentally a list of
Tickets (with a projection of product/customer/SLA data) —ticketing/ticketsalready ownsTicketand its existingfindById/findByCodemethods;orchestration/assignmentsowns the assignment decision and history, not ticket listing. This mirrors 009's own precedent ofproblem-managementreadingticketing's public surface rather than duplicating ticket state there. - Alternatives considered: A new cross-cutting
reporting/dashboardmodule — rejected as premature; this is one query, not a new bounded concern (spec.md Assumptions explicitly rule out a general-purpose list/search endpoint).
Decision: one new Prisma index, Assignment @@index([agentId, isCurrent])
- Decision: Add this composite index. The existing
@@index([ticketId, isCurrent])supports "is this ticket currently assigned, and to whom" (007's own original query shape); this feature's query is the mirror image — "which tickets is this agent currently assigned to" — and has no supporting index today. - Rationale: Without it, "all current assignments for agent X" is a sequential scan over the
whole
assignmentstable. Cheap, purely additive schema change; no data migration needed beyond the index build itself. - Alternatives considered: Rely on the existing
[ticketId, isCurrent]index (Postgres can't use a composite index efficiently for a query that doesn't lead with its first column) — rejected; a plain sequential scan is the actual alternative, not this index.
Decision: two routes sharing one service method, not one route with an optional param
- Decision:
GET /agents/me/tickets(fastify.authenticateonly — resolves the agent fromrequest.user.idvia the newAgent.userIdlink) andGET /admin/agents/:agentId/tickets(fastify.authenticate+requireRole('ADMIN')— resolves the agent directly from the URL param) both call the sameTicketsService.listAssignedTo(agentId). - Rationale: FR-004 requires an agent's own call can never accept a client-supplied
agentId— collapsing both into one route with an optional query param would make that invariant a runtimeifinstead of a routing-level guarantee. Two routes make "whose tickets" structurally unambiguous per caller type, matching 010's own precedent ofGET /auth/mevs. an admin-only equivalent being distinct routes rather than one parameterized one. - Alternatives considered:
GET /tickets?assignedAgentId=<id or 'me'>— rejected; makes FR-004's guarantee a body of validation logic rather than routing structure.