# 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 |
