13 KiB
Frontend Phase 14 — Messaging (tickets) & notifications — report
Track: frontend · Depends on: frontend-phase-8-b9 (booking detail) · Consumes: b15 tickets (messaging-notifications-admin.md) + b1 notifications (config-reference.md) · Unlocks: frontend-phase-15-b15 (admin & partner consoles — reuses these services with the admin lens). Date: 2026-07-10.
The mission: give families and nurses the only sanctioned way to talk after a booking — the admin-readable ticket system — plus the in-app notification center + polled bell, and the emergency playbook banner on booking/support entry. No live chat by design; structured, auditable, anti-disintermediation.
1. What was built
Two new domain services (copied the auth/reviews skeleton exactly)
services/tickets—types.ts(client model, deliberately noisInternal),keys.ts(onedetail(id)= the whole thread; there is no message pagination in the contract — aselect-baseduseTicketThreadderives the messages, mirroring f8useBookingSessions = select over detail),constants.ts(USE_TICKETS_MOCK, stale times,MOCK_SEND_FAIL_SENTINEL, per-roleMOCK_VIEWER_USER_ID),apis/(clientApimaps b15 1:1 + drops any internal message defensively,mockApiPRIMARY, selectingindex), hooksuseMyTickets/useTicket/useTicketThread/useOpenTicket/usePostMessage(+ internaluseTicketViewer), barrel.services/notifications—types.ts(AppNotification+ discriminatedNotificationDataunion),parse.ts(parseNotificationData— snake/camel-tolerant, degrades to{kind:'none'}),deepLink.ts(notificationDeepLink(n, role)— role-aware,nullwhen nothing to open),keys.ts,constants.ts(USE_NOTIFICATIONS_MOCK, poll interval/staleTime),apis/(clientApimaps b1 1:1,mockApiPRIMARY), hooksuseNotifications/useUnreadCount(the poller) /useMarkNotificationRead/useMarkAllRead, barrel (+ re-exportsnotificationDeepLink).
Screens (shared by the customer and nurse apps — role decides chrome, not components)
- My Tickets inbox —
/support/tickets(customer) +/nurse/support/tickets(nurse), both thin wrappers over<TicketInboxScreen role=…>: prominentreferenceCode, status chip (reusedStatusChip), unread indicator, null-safe linked-entity hint, relative Shamsi time; empty/loading-skeleton/error→retry; a Contact support dialog (category → subject → message → submit → shows the newreferenceCode). - Ticket thread —
/…/tickets/[id]: role-aware bubbles (mine vs theirs, RTL-mirrored),referenceCodein the header, linked-booking chip, sticky optimistic composer; thread skeleton / empty / error states; never any internal-note content or affordance. - Notification center —
/notifications+/nurse/notifications(<NotificationCenter role=…>): unread-first list, each row marks read on open (optimistic) and deep-links vianotificationDeepLink; Mark all read; empty ("بهروز هستید") / loading / error→retry; a growing-limit "load more". - Notification bell —
<NotificationBell role=…>in the customer TopBar (+ a support icon) and the nurse shell (via a newheaderActionsslot onTopBarAndSideBarLayout; a Support item added to the nurse sidebar). It subscribes to the polling count so only the bell re-renders on a change, never the shell. - Emergency banner + support entry on the f8 booking detail —
<BookingSupportEntry bookingId role>(page-local glue, mounted belowBookingDetailViewon both the customer/bookings/[id]and nurse/nurse/visits/[id]pages). It reuses the cached booking query (same key/viewer — no refetch) and, for the nurse on a post-confirmation booking, the cached care-instructions read to surface the emergency banner'stel:contact. Pre-confirmation → nothing. Customer → the support CTA only (no clinical phone).
Shared composites (co-located *.test.tsx)
MessageBubble (mine/theirs, RTL, no internal styling), TicketListCard (prominent ref code + unread + null-safe
link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam), NotificationRow (unread emphasis +
server title/body), NotificationBellView (pure badge), ContactSupportDialog. Plus unit tests for
notificationDeepLink and parseNotificationData. +32 test cases; the full suite is 66 suites / 289 tests.
i18n + icons + routes
tickets + notifications namespaces added to both fa.json and en.json (in sync, RTL-first) + nav.support;
support + send icons; route constants + role-aware path helpers (ticketThreadPath, notificationsPath, …).
2. Critical rules honoured (phase §5)
is_internalnever reaches the user app. NoisInternalfield in the user-app types, no internal styling, no internal-note affordance anywhere. Both API mappers (ticketsClientApi.mapThreadand the mock'stoDetail) drop any message flagged internal — the mock even stores an internal admin note it never returns, so the no-leak behaviour is demonstrable.- No out-of-band channel except the post-confirmation emergency
tel:— drawn from the nurse-gated f8 care read; the customer never sees a phone; no VoIP/calling seam; no SLA timers. referenceCodeshown prominently in the inbox card + thread header.- Polite polling — only
useUnreadCountpolls (60srefetchInterval+ 45sstaleTime+ refetch-on-focus, auth-gated); the notification list is never polled. - Optimistic send is draft-preserving —
onMutateappends a pending bubble;onErrorrolls back to the snapshot; the composer keeps the draft (cleared only on server confirm) and retries; reconcile byclientMessageId(no double-render); submit disabled while sending. The composer is keyed onticketIdso a draft / in-flight send never crosses a thread→thread navigation. data_jsonis a typed contract — parsed into the discriminated union, deep-linked off that, degrades to no-deep-link for an unknown type / missing id, never trusts a blob.- Tenancy / null links — reads are server-scoped; ticket↔booking/refund chips render only when present.
3. How to test (what a human can verify) — the phase §7 steps
Run npm run dev (mocks are primary — USE_TICKETS_MOCK/USE_NOTIFICATIONS_MOCK default true):
- Open a ticket from a booking. Confirmed booking detail → "Get support / Open ticket" → a coordination
ticket opens (booking 5001 already has one → it jumps to the existing thread, else creates) and lands in
My Tickets with its
referenceCode. Expected: it appears at the top of the inbox with no manual refresh. - Post a message (optimistic). Open a thread, type, send → the bubble appears immediately ("sending") then
resolves to "sent". Send the dev sentinel body
/fail→ the bubble rolls back, the text stays in the composer, and the "send failed" hint shows; edit + resend succeeds. Expected: no duplicate bubble, no lost draft. - No internal notes leak. Ticket 1201 (coordination, booking 5001) has a seeded internal admin note — the user thread shows none of it, no styling, no affordance.
- Notification bell. From the console call
window-reachable dev helper (or import)__mockPushNotification('ticket_message','پیام جدید','{"ticket_id":1201}')→ the bell badge increments within ~60s. Open the center, open a notification → it marks read, the badge decrements, and it deep-links (booking →/bookings/{id}, ticket →/support/tickets/{id}, etc.). Mark all read clears the badge. Expected: the count is served from cache instantly and revalidates in the background; the endpoint isn't hit more often than the interval. - Emergency banner. On a confirmed booking in the nurse app, the banner shows with a
tel:link (booking 5001/5002 have a seeded contact) + the playbook copy; on an unconfirmed booking it is absent. The support entry (inbox) shows the playbook without a specific phone. - RTL + locales. Switch
fa/en: bubbles mirror, the badge sits correctly, every string is translated.npm run check+npm run test:cipass.
4. Mocks behind the two seams (swap = one flag)
Both domains are mock-primary — recorded in mocks-registry.md:
services/tickets(USE_TICKETS_MOCK = true) —ticketsClientApimaps every live b15 route 1:1 (POST/GET /tickets,GET /tickets/{id},POST /tickets/{id}/messages) and defensively drops any leaked internal message. The mock is primary because the linked bookings are themselves mock-primary and the wire summary lacksunreadCount/lastMessageAt(REQ-028). It seeds 3 tickets (incl. the no-leak internal note- a booking-linked coordination ticket with idempotent open), a clears-on-open unread count, an optimistic
append attributed to the current viewer (
openTicketnow takes the viewer id;postMessagetracks the last-viewed thread's viewer), a closed-ticket403, and the/failfailure trigger.
- a booking-linked coordination ticket with idempotent open), a clears-on-open unread count, an optimistic
append attributed to the current viewer (
services/notifications(USE_NOTIFICATIONS_MOCK = true) —notificationsClientApimaps every live b1 route 1:1. The mock is primary because nothing dispatches notifications client-side yet. It seeds an unread-first feed spanning every deep-link class (each mapped through the realparseNotificationData) and exposes__mockPushNotificationfor the bell-increment demo.
Flip either flag to false (one line in apis/index.ts selection via constants.ts) — no hook/component change.
5. Contract consumed + gaps filed
Consumed: messaging-notifications-admin.md (b15) + config-reference.md (b1) + openapi/swagger.v1.json
(TicketSummaryDto/TicketThreadDto/TicketMessageDto/OpenTicketCommand/OpenTicketResult/PostMessageCommand/
PostMessageResult; NotificationDto/UnreadCountResult/MarkNotificationReadCommand).
Gap filed — REQ-028 in for-backend.md: (1) unreadCount + lastMessageAt
on TicketSummaryDto; (2) a message author label / confirm masked-by-design (the client uses the participant
role label, never a raw name); (3) a user-facing by-booking ticket lookup (only the admin list filters by
bookingId) so "Get support" can jump to the existing coordination thread on the real path; (4) an optional
clientMessageId/idempotency field on POST …/messages. Business-rule drift check: the contract does not
expose is_internal to users (the user GET /tickets/{id} is server-stripped) — no drift; the client models it
accordingly.
6. Review + gate
- 6-dimension adversarial review (leak-and-tenancy, optimistic-send, notifications-cache-poll,
conventions-rtl-i18n, contract-fidelity, reuse-bugs) with per-finding adversarial verification. 1 finding
survived verification — the mock
openTicketmis-attributing a nurse-opened ticket's first message to the customer (module-globallastViewerUserIddefaulted to the customer). Fixed by threading the viewer id through theopenTicketseam (real path ignores it; server infers the sender) and adding the opener as a participant. A second flagged item (composer state crossing a thread→thread navigation) did not survive verification, but the composer was keyed onticketIdanyway as a correct-by-construction safeguard. - Gate:
npm run checkgreen;npm run test:cigreen (66 suites / 289 tests, +32); production build compiles + type-checks clean. A pre-existing "Missing .env variable" prerender guard fails SSG on/en/addresses+/en/nurse/verification/identityonly — unrelated to this phase (sibling pages under the same modified shells prerender fine); not introduced here.
7. Follow-ups for frontend-phase-15-b15 (the admin lens)
- The admin global ticket queue (
GET /admin/tickets+GET /admin/tickets/{id}with internal messages) and the internal-note composer layer on top ofservices/tickets— reuse the domain, add an admin-onlyisInternaltoggle only in the admin view (never in the user-app types). - The support-alert worklist (
support_alerts/*) + partner-center console + audit viewer are b15/b1 admin surfaces (DEFERRED here). - When REQ-028 lands, flip
USE_TICKETS_MOCK = false; when upstream domains dispatch real notifications, flipUSE_NOTIFICATIONS_MOCK = false.