# Flow — messaging & support tickets

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** customer · nurse · admin/staff · **Status:** partial
**Client:** partial (real seam, two fabricated summary fields) · **Server:** real
**Business source:** [product/business/12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md)
**Integration:** [docs/integration/domains/tickets.md](../integration/domains/tickets.md)

## What it does

Tickets are the **only** sanctioned post-booking channel — there is no live chat and no direct
nurse↔customer messaging, because every conversation must be admin-readable. A booking-coordination
thread is auto-created when a booking is paid for; users also open support threads themselves, and
refunds hang off a ticket. Staff read the same threads plus **internal notes** users can never see.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| Customer inbox | `/fa/support/tickets` | [`TicketInboxScreen`](../../client/src/components/messaging/TicketInboxScreen.tsx) `role="customer"`; its header CTA (`:63-70`) opens `ContactSupportDialog` with `defaultCategory="support"` |
| Customer thread | `/fa/support/tickets/[id]` | [`TicketThreadScreen`](../../client/src/components/messaging/TicketThreadScreen.tsx) — reference code + category + status header, bubble stream, sticky composer. **No internal-note affordance anywhere** |
| Nurse inbox / thread | `/fa/nurse/support/tickets`, `/…/[id]` | The **same two components**, `role="nurse"`, in the nurse shell |
| Open from a booking | `/fa/bookings/[id]`, `/fa/nurse/visits/[id]` | [`BookingSupportEntry`](../../client/src/components/messaging/BookingSupportEntry.tsx) — also renders the nurse's emergency banner (`tel:` + playbook) |
| Open from cancel | `/fa/bookings/[id]/cancel` | `ContactSupportDialog` (`cancel/page.tsx:219`) |
| Admin hub | `/fa/admin/support` | Group root, no data reads |
| Admin queue | `/fa/admin/tickets` | `useAdminTickets` + `useAdminListState` (draft-vs-applied filters, URL-synced) |
| Admin thread | `/fa/admin/tickets/[id]` | Full conversation **including internal notes**; composer toggles `isInternal` and goes amber; hosts `RefundPanel`; close/reopen/assign hidden behind `TICKET_LIFECYCLE_ENABLED` |

Thread polling: `TICKET_THREAD_REFETCH_INTERVAL = 15 s` while the screen is mounted
([`constants.ts:34`](../../client/src/services/tickets/constants.ts)); no background refetch.

## API

Shapes belong to [docs/integration/domains/tickets.md](../integration/domains/tickets.md) — not repeated here.

| Call | Endpoint | Notes |
| --- | --- | --- |
| Inbox | `GET /api/v1/tickets` | ✅ live. `clientApi.ts:181`. Participant-scoped at the query layer (`TicketRepository.ListMyTicketsAsync:114-118`) |
| Thread (user) | `GET /api/v1/tickets/{id}` | ✅ live. **Stamps `last_read_at`** (`GetTicketThreadQuery.Handler.cs:48-56`) — opening the thread is what clears the badge |
| Open | `POST /api/v1/tickets` | ✅ live. Only caller is `ContactSupportDialog` via `useOpenTicket` |
| Post message | `POST /api/v1/tickets/{id}/messages` | ✅ live, both user and staff composers (`clientApi.ts:212,253`) |
| Admin queue | `GET /api/v1/admin/tickets` | ⛔ **403 for every seeded admin** — see gaps |
| Admin thread | `GET /api/v1/admin/tickets/{id}` | ⛔ **403** (probed live) |
| Close / reopen | `POST /tickets/{id}/close`, `/reopen` | Server-live, **client-unreachable** (`TICKET_LIFECYCLE_ENABLED = false`, `constants.ts:52`) |
| Assign | `POST /tickets/{id}/assign` | ❌ **phantom** — no server route (REQ-063) |
| Emergency | `POST /api/v1/tickets/emergency` | ❌ **unwired** — zero client callers; `EmergencyBanner` only dials `tel:` |
| Participants | `POST`/`DELETE /tickets/{id}/participants…` | ❌ unwired — no UI |
| Unread total | *(none)* | `getUnreadTotal` is literally `async () => null` (`clientApi.ts:223`, REQ-059) |

Coordination tickets are auto-created server-side, not by the client — `ConfirmPaymentAndPostLedger…Handler.cs:94`
and `SettleBnplOrderCommand.Handler.cs:128` both send `AutoCreateCoordinationTicketCommand`.

## Rules that must hold

| Rule | Where it lives | Verified |
| --- | --- | --- |
| **INV-5 — `is_internal` is a query-layer boundary.** A non-staff read never *receives* an internal row | `TicketRepository.GetMessagesAsync:99-101` (`if (!includeInternal) query.Where(m => !m.IsInternal)`); `includeInternal = request.AsAdmin && isStaff` (`GetTicketThreadQuery.Handler.cs:41`) | ✅ live — see below |
| …and a non-staff caller can never **set** one | `PostMessageCommand.Handler.cs` → `403 "Only staff can post an internal note."` | ✅ live `403` |
| …and `isInternal` is **absent from every user-facing client type** | `TicketMessage`/`TicketDetail` (`types.ts:82-106`) carry no such field; only `AdminTicketMessage:164` and `PostAdminMessageRequest:205` do | ✅ read |
| …UI filtering is defence-in-depth only, never the gate | `clientApi.ts:89` re-drops any flagged row and the comment names it a backend defect to file | ✅ read |
| Ticket-only, admin-readable communication; no live chat; the emergency playbook **never dials and never exposes a number** | [business/12 §(a)](../../product/business/12-messaging-and-emergencies.md) (INV-20) | partial (see gaps) |
| Ticket **bodies are encrypted at rest** (refinement-phase-9) | `TicketMessageConfig.cs:19-21`; `TicketMessageEncryptionTests.cs` | ✅ Persian bodies round-tripped correctly on every probe |
| `referenceCode` is the stable, unique human id (`TKT-…`) | `TicketReferenceCode.cs`; UNIQUE-indexed | ✅ |
| Body ≤ **4000** chars; subject ≤ **300** | `OpenTicketCommand.Validator.cs:15-16`, `PostMessageCommand.Validator.cs:10` | ✅ read |
| `clientMessageId` is optimistic-send idempotency — a retry returns the **original** message | `PostMessageCommand.Handler.cs`; UNIQUE `(TicketId, ClientMessageId)` | ✅ live |

**Live proof of the boundary (2026-08-02).** Seeded ticket `TKT-DEMOSUP1` (id 12) holds four messages,
one of them an admin internal note («یادداشت داخلی: …», `DemoLifecycleSeeder.Social.cs:197-203`).
`GET /tickets/12` as `09120000010` returned message ids **7, 9, 10** — id **8 is missing from the
sequence**, and that gap is the internal note. No `isInternal: true` row reached the wire.

**The one cross-side enum mismatch** the integration doc records: **`TicketAuthorRole`**. The server's
`TicketParticipantRole` (`TicketCodes.cs`) defines `customer` · `nurse` · `admin`; the client
(`types.ts:37`) adds a fourth member **`system`** that the server never emits. Widening on the reading
side is safe, but a reader of the client types would wrongly conclude `system` is a wire value.

## How to test

1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Go to `/fa/support/tickets` (linked from the `/fa/profile` account hub).
   **Expect:** **9 tickets** — `TKT-DEMOSUP1` (support, open, 1 unread), `TKT-DEMORF01`/`TKT-DEMORF03`
   (refund), and six `coordination` rows for bookings 1, 2, 3, 4, 7, 8.
3. Open `TKT-DEMOSUP1`.
   **Expect:** exactly **three** bubbles (customer → support → customer, ending «ممنون، حل شد. 🌸»).
   **The internal note must not appear.** Its unread badge clears on return to the inbox (the GET stamped
   `last_read_at`). Persian text renders correctly = decryption works.
4. Type a reply and send. **Expect:** the bubble appears instantly as `sending`, then settles to `sent`.
   Send the sentinel `/fail` only under the mock — on the real path, kill the API to see the bubble go
   `failed` **with your text preserved** and a retry-in-place control (`usePostMessage.ts:60-69`).
5. Log in as `09120000001` (زهرا عزیزی, nurse) → `/fa/nurse/support/tickets` (linked from `/fa/nurse/more`).
   **Expect:** **7 tickets** — the same six coordination threads plus `TKT-DEMOEMG1` (emergency).
   Same components, nurse chrome.
6. Open any `coordination` ticket. **Expect:** an **empty** thread — `lastMessageAt` is `null` on all six;
   the auto-created ticket carries no opening message.
7. Admin half: `/fa/admin/tickets` as `09120000020`. **Expect: it fails.** Every `/api/v1/admin/tickets*`
   call returns `403` for the seeded admins. Unlike the other admin consoles this domain is **not**
   mock-covered (`USE_TICKETS_MOCK = false`), so the queue renders its error state, not fake data.
   **Workaround:** none in the browser. Read a thread out of band as staff —
   `GET /api/v1/tickets/12` with the super_admin token returns **`200`** (staff bypass the participation
   check, `GetTicketThreadQuery.Handler.cs:36-38`), still with internal notes stripped.

The seeded world supports this flow fully on the user side; unlike the booking flows, **nothing here has
aged out** (tickets carry no deadline).

## Known gaps

- `GET /api/v1/admin/tickets` and `/admin/tickets/{id}` return **403 for `09120000020` and `09120000021`** (`DynamicPermission` grants only on the literal role `admin`). The whole staff console — queue, internal notes, `RefundPanel` — is untestable end to end.
- The admin ticket console is the **only admin console with no mock fallback** (`USE_TICKETS_MOCK = false`), so the 403 surfaces as a broken screen rather than being hidden.
- Staff authorization is inconsistent by layer: `StaffRoles.All` **includes** `super_admin`/`finance`, so the handlers would happily serve them — the controller attribute is what blocks. Two different definitions of "staff" in one request path.
- `getUnreadTotal` returns a hardcoded `null` (`tickets/apis/clientApi.ts:223`) — the chrome support badge can never light up (REQ-059).
- `mapSummary` hardcodes `lastMessagePreview: null` and `lastAuthorRole: null` (`clientApi.ts:77-78`) — the inbox card shows no message preview.
- `POST /api/v1/tickets/emergency` has **zero client callers**. The nurse's emergency banner dials `tel:` but the "then opens a ticket" half of the business/12 playbook is manual.
- `POST`/`DELETE /tickets/{id}/participants…` are unwired — no UI can attach a third party to a thread.
- `close` / `reopen` are live server-side but unreachable: `TICKET_LIFECYCLE_ENABLED = false` (`constants.ts:52`). `tickets/constants.ts` still justifies the gate with "the backend has no close/reopen/assign routes yet" — only `assign` is actually missing.
- `POST /tickets/{id}/assign` is a **phantom** — `clientApi.ts:273` targets a route that does not exist (REQ-063).
- Client `PostMessageResult` (`types.ts:141-145`) omits `clientMessageId`, which the server **does** echo (probed). The doc block at `types.ts:130-133` claims "the server has no field for it today… the real client does not send it" — `clientApi.ts:218` sends it. Stale comment inside the shipping domain.
- Client `TicketAuthorRole` declares `system`; the server's `TicketParticipantRole` does not. One side must move.
- Auto-created coordination tickets are opened with **no first message**, so both actors see an empty thread with nothing explaining what it is for.
- `TicketDetail.messages` is unpaginated by contract — a long thread has no incremental read.
- Attachments are designed but gated off (`TICKETS_ATTACHMENTS_ENABLED = false`, REQ-060).
