11 KiB
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 Integration: docs/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 role="customer"; its header CTA (:63-70) opens ContactSupportDialog with defaultCategory="support" |
| Customer thread | /fa/support/tickets/[id] |
TicketThreadScreen — 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 — 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); no background refetch.
API
Shapes belong to docs/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) (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
- Log in as
09120000010(سارا محمدی, customer) — see testing-setup.md. - Go to
/fa/support/tickets(linked from the/fa/profileaccount hub). Expect: 9 tickets —TKT-DEMOSUP1(support, open, 1 unread),TKT-DEMORF01/TKT-DEMORF03(refund), and sixcoordinationrows for bookings 1, 2, 3, 4, 7, 8. - 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 stampedlast_read_at). Persian text renders correctly = decryption works. - Type a reply and send. Expect: the bubble appears instantly as
sending, then settles tosent. Send the sentinel/failonly under the mock — on the real path, kill the API to see the bubble gofailedwith your text preserved and a retry-in-place control (usePostMessage.ts:60-69). - Log in as
09120000001(زهرا عزیزی, nurse) →/fa/nurse/support/tickets(linked from/fa/nurse/more). Expect: 7 tickets — the same six coordination threads plusTKT-DEMOEMG1(emergency). Same components, nurse chrome. - Open any
coordinationticket. Expect: an empty thread —lastMessageAtisnullon all six; the auto-created ticket carries no opening message. - Admin half:
/fa/admin/ticketsas09120000020. Expect: it fails. Every/api/v1/admin/tickets*call returns403for 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/12with the super_admin token returns200(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/ticketsand/admin/tickets/{id}return 403 for09120000020and09120000021(DynamicPermissiongrants only on the literal roleadmin). 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.Allincludessuper_admin/finance, so the handlers would happily serve them — the controller attribute is what blocks. Two different definitions of "staff" in one request path. getUnreadTotalreturns a hardcodednull(tickets/apis/clientApi.ts:223) — the chrome support badge can never light up (REQ-059).mapSummaryhardcodeslastMessagePreview: nullandlastAuthorRole: null(clientApi.ts:77-78) — the inbox card shows no message preview.POST /api/v1/tickets/emergencyhas zero client callers. The nurse's emergency banner dialstel: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/reopenare live server-side but unreachable:TICKET_LIFECYCLE_ENABLED = false(constants.ts:52).tickets/constants.tsstill justifies the gate with "the backend has no close/reopen/assign routes yet" — onlyassignis actually missing.POST /tickets/{id}/assignis a phantom —clientApi.ts:273targets a route that does not exist (REQ-063).- Client
PostMessageResult(types.ts:141-145) omitsclientMessageId, which the server does echo (probed). The doc block attypes.ts:130-133claims "the server has no field for it today… the real client does not send it" —clientApi.ts:218sends it. Stale comment inside the shipping domain. - Client
TicketAuthorRoledeclaressystem; the server'sTicketParticipantRoledoes 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.messagesis unpaginated by contract — a long thread has no incremental read.- Attachments are designed but gated off (
TICKETS_ATTACHMENTS_ENABLED = false, REQ-060).