Files
baya-monorepo/dev/shared-working-context/reports/frontend-phase-14-report.md
T
2026-07-10 18:46:16 +03:30

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/ticketstypes.ts (client model, deliberately no isInternal), keys.ts (one detail(id) = the whole thread; there is no message pagination in the contract — a select-based useTicketThread derives the messages, mirroring f8 useBookingSessions = select over detail), constants.ts (USE_TICKETS_MOCK, stale times, MOCK_SEND_FAIL_SENTINEL, per-role MOCK_VIEWER_USER_ID), apis/ (clientApi maps b15 1:1 + drops any internal message defensively, mockApi PRIMARY, selecting index), hooks useMyTickets / useTicket / useTicketThread / useOpenTicket / usePostMessage (+ internal useTicketViewer), barrel.
  • services/notificationstypes.ts (AppNotification + discriminated NotificationData union), parse.ts (parseNotificationData — snake/camel-tolerant, degrades to {kind:'none'}), deepLink.ts (notificationDeepLink(n, role) — role-aware, null when nothing to open), keys.ts, constants.ts (USE_NOTIFICATIONS_MOCK, poll interval/staleTime), apis/ (clientApi maps b1 1:1, mockApi PRIMARY), hooks useNotifications / useUnreadCount (the poller) / useMarkNotificationRead / useMarkAllRead, barrel (+ re-exports notificationDeepLink).

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=…>: prominent referenceCode, status chip (reused StatusChip), 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 new referenceCode).
  • Ticket thread/…/tickets/[id]: role-aware bubbles (mine vs theirs, RTL-mirrored), referenceCode in 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 via notificationDeepLink; 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 new headerActions slot on TopBarAndSideBarLayout; 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 below BookingDetailView on 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's tel: 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_internal never reaches the user app. No isInternal field in the user-app types, no internal styling, no internal-note affordance anywhere. Both API mappers (ticketsClientApi.mapThread and the mock's toDetail) 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.
  • referenceCode shown prominently in the inbox card + thread header.
  • Polite polling — only useUnreadCount polls (60s refetchInterval + 45s staleTime + refetch-on-focus, auth-gated); the notification list is never polled.
  • Optimistic send is draft-preservingonMutate appends a pending bubble; onError rolls back to the snapshot; the composer keeps the draft (cleared only on server confirm) and retries; reconcile by clientMessageId (no double-render); submit disabled while sending. The composer is keyed on ticketId so a draft / in-flight send never crosses a thread→thread navigation.
  • data_json is 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):

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. RTL + locales. Switch fa/en: bubbles mirror, the badge sits correctly, every string is translated. npm run check + npm run test:ci pass.

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)ticketsClientApi maps 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 lacks unreadCount/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 (openTicket now takes the viewer id; postMessage tracks the last-viewed thread's viewer), a closed-ticket 403, and the /fail failure trigger.
  • services/notifications (USE_NOTIFICATIONS_MOCK = true)notificationsClientApi maps 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 real parseNotificationData) and exposes __mockPushNotification for 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 openTicket mis-attributing a nurse-opened ticket's first message to the customer (module-global lastViewerUserId defaulted to the customer). Fixed by threading the viewer id through the openTicket seam (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 on ticketId anyway as a correct-by-construction safeguard.
  • Gate: npm run check green; npm run test:ci green (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/identity only — 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 of services/tickets — reuse the domain, add an admin-only isInternal toggle 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, flip USE_NOTIFICATIONS_MOCK = false.