# tickets — coordination, support, emergency > Client seam `client/src/services/tickets/` · `USE_TICKETS_MOCK = false` (**real**) · 11 server ops > Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29). Threaded messaging between customer, nurse and staff. Also the emergency channel. The in-app notification feed is a separate domain — [notifications.md](notifications.md). ## Endpoints | Method | Path | Verdict | | --- | --- | --- | | GET | `/api/v1/tickets` | wired · paginated · `bookingId` filter | | POST | `/api/v1/tickets` | wired | | GET | `/api/v1/tickets/{id}` | wired — **stamps `last_read_at`** | | POST | `/api/v1/tickets/{id}/messages` | wired · optional `clientMessageId` | | POST | `/api/v1/tickets/{id}/close` | wired | | POST | `/api/v1/tickets/{id}/reopen` | wired | | POST | `/api/v1/tickets/emergency` | **unwired** — no emergency entry point in the UI yet | | POST | `/api/v1/tickets/{id}/participants` | **unwired** | | DELETE | `/api/v1/tickets/{id}/participants/{userId}` | **unwired** | | GET | `/api/v1/admin/tickets` | wired · paginated — the staff queue | | GET | `/api/v1/admin/tickets/{id}` | wired — the staff thread, **internal notes visible** | All `[Authorize]`; the two `admin/tickets` routes are `DynamicPermission`. Note the hardcoded nested `admin/` route segment — see [index.md](index.md#route-shape-exceptions). ### Phantom — 1 | Client call | REQ | Live? | | --- | --- | --- | | `POST /api/v1/tickets/{id}/assign` | REQ-063 | **Yes — this domain's mock is off.** Gated behind `TICKET_LIFECYCLE_ENABLED`, default **off**, so nothing calls it today | > **`tickets/constants.ts` is stale on this.** It says the gate is off because "the backend has no > close/reopen/assign routes yet (REQ-063)". `close` and `reopen` **exist and are wired.** Only `assign` > is missing. The gate could be turned on for close/reopen alone. ## `isInternal` is a query-layer boundary The hardest rule in this domain, and the easiest to get wrong in a UI: - `TicketMessageDto` carries `isInternal`, so the field **is** on the wire shape. - **The filtering is not.** A non-staff caller's thread query never returns an internal row, and a non-staff caller can never *set* one. Both are enforced at the query layer, in the handler — **never in the UI**. - Consequence for the client: it must not model internal notes as "rows to hide". They do not arrive. A client-side filter would be a second, weaker gate that hides a leak rather than preventing one. - Consequence for the server: any new ticket read must repeat the filter. There is no global interceptor doing it. ## Shape rules the JSON does not express - **Unread is computed server-side against `last_read_at`**, which is stamped **when the participant fetches the user-facing thread** (`GET /tickets/{id}`) — not by a separate mark-read call. So opening a thread is what clears its badge. `unreadCount` counts non-internal messages from *others* after that stamp, and is **0 on the admin queue** by definition. - **`clientMessageId` is optimistic-send idempotency** (REQ-028): a retried send with the same key returns the original message and echoes the key back on `PostMessageResult`. This is what makes the client's retry-in-place send safe. It is **not** the `Idempotency-Key` header — it is a body field, and it is the only place in the API that works this way. - **The message author is a role label, not a name** — confirmed intentional, for privacy. `TicketMessageDto` carries `senderId` only; the client derives the author label from the participant role. No raw identity is exposed, and none should be added. - **`referenceCode` is the human-facing id** shown to users and quoted in support. Treat it as opaque. - `bookingId` and `refundId` on the summary link a ticket to what it is about; the `bookingId` query filter is how the client jumps from a booking to its coordination thread. - Ticket bodies are **encrypted at rest** (refinement-phase-9). ## Enums | Vocabulary | Values | Note | | --- | --- | --- | | `TicketStatus` | `open` `closed` | matches `Entities/Messaging/TicketCodes.cs` | | `TicketCategory` | `coordination` `support` `refund` `emergency` | matches | | `TicketAuthorRole` | `customer` `nurse` `admin` **`system`** | **the one cross-side mismatch in the API** | | `MessageSendStatus` *(client-only)* | `sent` `sending` `failed` | optimistic-send UI state, never on the wire | > **`system` is client-only.** `TicketCodes` defines `customer`, `nurse`, `admin`. The client's > `TicketAuthorRole` adds `system` for platform-generated messages. Widening a union on the *reading* side > is safe — the client can render a value the server never sends — but it means a reader of the client > types would wrongly conclude the server emits `system`. Either the server should define it or the client > should drop it; today it is a documented asymmetry. ## Open REQs | REQ | Status | Effect | | --- | --- | --- | | REQ-028 | delivered | `unreadCount` + `lastMessageAt` on the summary, `bookingId` filter, `clientMessageId` dedupe, role-label authors — all present | | REQ-059 | open | No last-message preview and no author role on the summary, and no unread-*total* read. The real inbox shows subject + status + time with no preview | | REQ-060 | deferred | No message photo attachments. The affordance is designed and gated off | | REQ-063 | open, **narrower than filed** | `close` and `reopen` are delivered and wired. Only `assign` is missing → 1 phantom |