Background
The Customer Support Tools team at a large online retailer runs the ticket router: customers open support tickets, team leads register agents with the languages they speak, and each agent asks the queue for their next ticket. The service is a Node.js REST API (Express + MongoDB via Mongoose). The routing rule that picks an agent's next ticket was never finished, and a refactor left a few defects behind.
How the system works
Callers
- Every request needs an
x-user-idheader (the caller's id, already authenticated upstream). A missing or blank header is401with{ "error": "sign in required" }. - Team leads also send
x-role: lead(compared case-insensitively). - An agent is a user that a team lead has registered (see
PUT /agents/:agentId).
Errors
Every error response is { "error": "<message>" }:
400invalid input, or an:idthat isn't a valid id401nox-user-id403the caller isn't allowed (not a lead, not a registered agent, not the ticket's agent)404the ticket doesn't exist409the request breaks a rule (agent at capacity, ticket not in progress)
Tickets
A ticket is returned as:
{
"id": "665f…",
"subject": "Parcel arrived damaged",
"customerId": "cust-41",
"priority": 3,
"language": "en",
"status": "OPEN",
"assigneeId": null,
"createdAt": "2026-03-02T09:00:00.000Z",
"overdue": false
}
priorityis 1 (most urgent) to 4 (least urgent).statusgoesOPEN→IN_PROGRESS(when an agent takes it) →RESOLVED, and back toOPENif the customer reopens it.- A ticket is overdue when it was created 4 hours or more before now (exactly 4 hours counts).
The
overduefield is true only while the ticket isOPEN.
Routing
POST /queue/next gives the calling agent their next ticket. Among the tickets that are OPEN,
not assigned to anyone, and in one of the agent's languages, the agent gets the first one in
this order:
- overdue tickets before tickets that aren't overdue
- then the lower
prioritynumber (1 before 4) - then the older ticket (earlier
createdAt) - then the smaller id (compared as text)
Tickets that don't meet all three conditions are never picked. When no ticket fits, the agent gets
nothing (204).
Endpoints
POST /tickets (any signed-in user; they become customerId): body { "subject", "priority", "language" }.
subjectis trimmed and must be 3–120 characters.priorityis optional (default3) and must be a whole number from 1 to 4.languageis optional (default"en"); it is trimmed and lower-cased and must then be two letters.- Returns
201with the new ticket (OPEN, no assignee).
GET /tickets/mine: 200 with { "tickets": [...] }, the caller's own tickets (as customerId),
newest first. Optional ?status=OPEN|IN_PROGRESS|RESOLVED (exact, upper case) keeps only that status;
any other value is 400.
GET /tickets/:id: 200 with the ticket, or 404.
POST /tickets/:id/resolve: only the ticket's assigned agent may resolve it (else 403); the ticket
must be IN_PROGRESS (else 409). Returns 200 with the ticket, now RESOLVED.
POST /tickets/:id/reopen: only the customer who opened the ticket may reopen it (else 403). The
ticket must be RESOLVED, and now must be no more than 7 days (7 × 24 hours, inclusive) after it was
resolved (else 409). It goes back to OPEN with no assignee and keeps its original createdAt, so an
old ticket is overdue again straight away. Returns 200 with the ticket.
PUT /agents/:agentId (team leads only, else 403): body { "languages", "maxActive" }.
languages: a list of 1–5 language codes, each normalized like a ticket'slanguage; duplicates are dropped, keeping the first occurrence's position.maxActiveis optional (default3) and must be a whole number from 1 to 10.- Creates the agent, or replaces their languages and
maxActive. Returns200with{ "agentId", "languages", "maxActive" }.
POST /queue/next (registered agents only, else 403):
- An agent who already has
maxActiveticketsIN_PROGRESSgets409. - Otherwise the ticket chosen by the routing rules becomes
IN_PROGRESSwith the agent asassigneeId, and the response is200with the updated ticket. With nothing to pick:204. - Two agents never get the same ticket.
GET /queue/stats (team leads only, else 403): 200 with
{ "open": <tickets waiting>, "inProgress": <tickets being worked on>, "byPriority": { "1": n, "2": n, "3": n, "4": n } },
where open and byPriority count tickets that are OPEN and unassigned.
Project structure
src/
app.js createApp(): JSON parsing, routes, error handler
server.js connects to MongoDB and starts the server
routes/
tickets.js /tickets
agents.js /agents
queue.js /queue
controllers/
ticketController.js open, list, view, resolve and reopen tickets
agentController.js register agents
queueController.js claim the next ticket, queue stats
middleware/
auth.js x-user-id / x-role checks
errorHandler.js errors → status + { error }
models/
Ticket.js a support ticket
Agent.js an agent's languages and capacity
services/
routing.js which ticket an agent gets next
ticketService.js ticket queries and updates
agentService.js agent lookups and updates
utils/
AppError.js an error with an HTTP status
input.js reading numbers, text and language codes from request bodies
present.js documents → API JSON
tests/
routing.test.js the routing rules
tickets.test.js ticket endpoints
queue.test.js agent and queue endpoints
setup/ in-memory MongoDB for the tests
Your task
Implement pickNext in src/services/routing.js as specified in "Routing", and fix any defects that
stop the test suite from passing.
- Do not modify
tests/,package.jsonorjest.config.js. - Keep your changes minimal. Fix the defects; don't rewrite modules.
- Additional hidden tests check the behaviour described in this README.