Work inbox API
Handler: handleWork() in src/lib/api.ts. Every route is a thin wrapper over a
SECURITY DEFINER database function — the requirePermission() calls here shape
the screen, and the database is what refuses (ACC-001). Rules:
18-work-and-intake.md.
/work/queues and /work/queues/members are matched before /work/:id and
/work/:id/:action, or "queues" would be read as an item id.
Routes
| Method | Path | Permission | Database function |
|---|---|---|---|
| GET | /work |
work.view |
work_inbox_page; with options=1, work_inbox_screen (below) |
| GET | /work/queues |
work.view |
reads WorkQueue, WorkType, WorkChannelTarget |
| GET | /work/:id |
work.view |
work_item_get |
| GET | /work/:id/suggest |
work.assign |
suggest_work_assignee |
| POST | /work |
work.create |
work_item_create |
| POST | /work/:id/claim |
work.claim |
work_item_claim |
| POST | /work/:id/assign |
work.assign, or work.claim when you hold the job (checked in the database) |
work_item_assign; item is null in the answer when a holder handed it on and can no longer see it |
| GET | /work/routing |
work.view |
work_routing_list: { canEdit, types[], roles[] } |
| PUT | /work/routing/:typeKey |
work.assign |
work_routing_set: body { mode: pool\|person\|role, userId?, roleName?, reason } |
| GET | /work/queues/members |
work.view |
work_queue_members_list: { canEdit, queues[{ id, key, name, members[{ id, userId, name, email, role, weeklyCapacityPoints, effectiveFrom, active, userActive }] }], people[] }; people (who may be added) is empty without work.queues.manage |
| PUT | /work/queues/:queueId/members/:userId |
work.queues.manage |
work_queue_member_save: body { role: member\|lead, weeklyCapacityPoints: number\|null }. Adds the person or changes them; one taken off comes back from today. Refuses an inactive user and one who cannot see the work inbox (WRK-013). Answers the list |
| DELETE | /work/queues/:queueId/members/:userId |
work.queues.manage |
work_queue_member_remove: sets the member inactive (the row is kept); a second call changes nothing. Answers the list |
| POST | /work/:id/release |
work.claim |
work_item_release |
| POST | /work/:id/wait |
work.claim |
work_item_wait |
| POST | /work/:id/resume |
work.claim |
work_item_resume |
| POST | /work/:id/response |
work.claim |
work_item_record_response |
| POST | /work/:id/close |
work.close, or work.assign when status=cancelled |
work_item_close; unless alreadyClosed, then asks the communications-dispatcher to send the note to the person who gave the job (below) |
| POST | /work/:id/reopen |
work.reopen |
work_item_reopen |
| GET | /performance?user=&from=&to= |
perf.view.own; another user needs perf.view.all and is audited |
perf_scorecard |
| GET | /performance/rows?user=&metric=&from=&to= |
as above | perf_scorecard_rows; metric is one of answered, missed, closed, converted, late, uncovered, reopened, open, overdue |
| GET | /performance/people |
perf.view.all |
perf_people |
GET /work
Query: scope (mine default, pool, queue, all), queue, assignee,
status (comma-separated), channel (comma-separated), overdue=1, q,
cursor, limit (default 50, max 200), withTotal=1.
Answers the keyset contract of paging:
{
"data": [
{
"work_key": "work:8f3c…",
"source": "work_item",
"authoritative": true,
"claimable": false,
"type_key": "enquiry_whatsapp",
"queue_key": "sales_enquiries",
"title": "Mr Khan asked about Ramadan Umrah",
"subtitle": "WhatsApp enquiry · whatsapp · Mr Khan",
"channel": "whatsapp",
"assignee_id": "…", "assignee_name": "Handler One", "assignee_kind": "staff",
"status": "in_progress",
"due_at": "2026-09-28T05:30:00.000Z",
"sort_at": "2026-09-28T05:30:00.000Z",
"weight": 1.0,
"overdue": false,
"href": "/work?item=8f3c…",
"why": { "rule": "WRK-002", "origin": "intake" }
}
],
"nextCursor": "1790000000000\u001fwork:8f3c…",
"total": 42,
"totalIsEstimate": false
}
work_key is the identity across the whole union: work:<id> for a row this
module owns, otherwise <source>:<id> (booking_correction:…, incident:…,
customer_request:…, visa_intake:…, hold:…, group_lead:…). The cursor is
<epoch milliseconds><US><work_key> where <US> is chr(31); a malformed cursor
is refused with invalid_parameter_value, never guessed at. total is counted up
to 5,000, above which totalIsEstimate is true.
Asking for a scope or another person's list without work.view.queue /
work.view.all is refused with insufficient_privilege.
options=1 — the filter bar in the same call
The inbox asks for its first page with options=1. The route then calls
work_inbox_screen instead of work_inbox_page: one round trip that checks
work.view (403 without it), returns work_inbox_page's answer unchanged, and
adds options in the shape of GET /work/queues:
{ "data": [], "nextCursor": null, "total": 0, "totalIsEstimate": false,
"options": { "queues": [{ "key": "sales_enquiries", "name": "Sales enquiries" }],
"types": [{ "key": "enquiry_whatsapp", "label": "WhatsApp enquiry", "queueKey": "sales_enquiries", "targetMinutes": 30 }],
"channels": ["whatsapp"] } }
options is only sent with the first page (no cursor). GET /work/queues
still exists for the "new task" dialog and for a database without
work_inbox_screen, where the route falls back to the two reads. See
PRF-010.
POST /work
{ "typeKey": "enquiry_phone", "title": "Mr Khan asked about Ramadan Umrah",
"channel": "phone", "requesterName": "Mr Khan", "requesterPhone": "99999 00000" }
Returns { "existing": false, "item": { … } }. existing is true when an
equivalent item is already open for the same subject and type, or the same
idempotencyKey — the open item comes back and no second row is created
(WRK-016). Callers should treat that as success, not as a conflict.
The target and due time are derived from the channel, or from the work type when
no channel applies. A job typed by hand with neither gets the internal target
(480 working minutes).
Optional fields (WRK-018):
| Field | Meaning |
|---|---|
dueAt |
ISO time, in the future and within 400 days. It is kept as the due time, and the target becomes the working minutes from now until then, so the scorecard measures the job against it |
priority |
1 Urgent, 2 High, 3 Normal (default), 4 Low |
subjectType, subjectId |
Booking, Customer, TravelGroup or Agent and the record's id. The record must exist. Several typed tasks (task_general) may point at one record; every other type keeps one open item per subject (WRK-016) |
assigneeId |
Yourself: needs work.claim (you hold it from the start). Anyone else: needs work.assign, and they must be active |
When the job goes to someone other than the caller, the database writes a note in
their notifications (AppNotification, kind work, url /work?item=<id>, marked
"pushWanted") and queues its e-mail copy (category work). The route then asks the
communications-dispatcher to send them now ({ kick: true }, fire-and-forget); the
dispatcher rings the phone through push-send (COMM-041).
The route no longer calls push-send itself. POST /work/:id/assign does the same for a
hand-over (not when alreadyAssigned), and POST /work/:id/close for the note to the person
who gave the job (WRK-018).
GET /work/:id
Returns the item, why (the rule and the facts behind it — INT-004), and
events: the full ledger, oldest first. firstResponseMinutes and
waitingMinutes are working minutes (WRK-004/WRK-005), not wall clock.
Opening an item held by somebody else is refused unless you hold
work.view.queue or work.view.all.
Errors
| Code | Meaning |
|---|---|
insufficient_privilege |
the permission is missing, or the item is not yours to see |
no_data_found |
no such item — or, on claim, somebody else already took it (WRK-006) |
check_violation |
a required reason is missing, or the item is already closed |
invalid_parameter_value |
unknown work type, unknown scope, or a malformed cursor |
Not built yet
No route accepts inbound email or sends an acknowledgement. There is no reminder or
escalation endpoint: the reminders are the scheduled database function
work_reminders_run() (service role only, WRK-009;
runbook). The phone calls the database functions directly and adds two
the web does not use: work_people() (who a job can go to, with open and late
counts; work.create, work.claim or work.assign) and work_given_by_me() (open
jobs I typed that someone else holds or that wait in the pool; work.view). See
18-work-and-intake.md for what each phase adds.