Files
baya-monorepo/archive/docs/flows/messaging-tickets.md
T
2026-08-02 20:01:31 +03:30

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.cs403 "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

  1. Log in as 09120000010 (سارا محمدی, customer) — see testing-setup.md.
  2. Go to /fa/support/tickets (linked from the /fa/profile account hub). Expect: 9 ticketsTKT-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 phantomclientApi.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).