Skip to content

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.