Files
baya-monorepo/dev/phases/frontend/frontend-phase-14-b15.md
T
2026-06-28 21:59:59 +03:30

26 KiB

Frontend Phase 14 — Messaging (tickets) & notifications

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 that pulls them back in and deep-links them to the right place. There is no live chat by design: communication is structured, auditable, and anti-disintermediation. This phase ships the customer/nurse "My Tickets" inbox + thread, the notification bell with a polled unread count, and the prominent emergency playbook banner on booking/support entry. It must never leak an internal admin note into a user's view.

Track: frontend · Depends on: frontend-phase-8-b9 (booking detail — the ticket/support entry points hang off it) · backend b15 contract (messaging-notifications-admin) + the b1 notifications endpoints · Unlocks: frontend-phase-15-b15 (admin & partner consoles — reuses the same ticket/notification services with the admin lens) · Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

We are at the social/communication layer of the customer & nurse apps. Bookings, sessions, EVV, payments, refunds, and reviews already exist; what is missing is the channel that ties them together for humans. Balinyaar deliberately has no direct nurse↔customer messaging and no live chat — all post-booking communication runs through tickets that admin can read in full (anti-disintermediation and patient-safety; see product/business/12-messaging-and-emergencies.md). A booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets. In parallel, the notification center is the app's pull mechanism — in-app only (no push at launch), polled, with a typed data_json payload that tells the front-end where to deep-link.

This phase builds two new domain services (services/tickets, services/notifications) and the screens on top of them, for the customer and nurse apps only. The admin lens over the very same data — the global ticket queue with the internal-note composer, the support-alert worklist — is (DEFERRED to frontend-phase-15-b15); build the services so f15 reuses them without rewrite.

What already exists (do not rebuild) — confirmed from prior phases:

  • The app shells, role-aware nav, the 5-tab customer bottom nav and the nurse shell, the services/{domain} + TanStack Query caching pattern, the contracts→types pattern, the money/format utils, and the shared composites (status chip, stepper, cards) — frontend-phase-0.
  • AuthContext with roles, the OTP login/role router — frontend-phase-1-b2.
  • The booking detail & sessions screen, the status timeline, and the nurse EVV check-in/out — these are where the "Get support / Open ticket" entry point and the nurse emergency banner attach — frontend-phase-8-b9. Reuse its booking-detail layout and booking query; do not rebuild booking fetching here.
  • Reviews & patient-record screens (the prior phase) — frontend-phase-13-b14; unrelated to this phase except that both consume the b14/b15 contract bundle.
  • clientFetch/serverFetch + ApiError, the toast bridge (already toasts 401/403/5xx — do not re-toast those in your hooks), the cookie manager, APP_THEME_LTR/RTL, tokens.css.

Backend readiness note. The contract you consume, messaging-notifications-admin.md, is produced by backend-phase-b15 (tickets) and the notification endpoints by b1. If a shape you need is absent or wrong when you start, do not guess and do not block — append a request to ../../shared-working-context/frontend/requests/for-backend.md and mock behind the services/tickets / services/notifications seam meanwhile (operating-rules §6). Record every mock in your report so it swaps cleanly.

2. Required reading (do this first)

Operating rules & checklists

Product / business truth (read before designing any screen)

Contracts & types (the source of truth for shapes — do not guess)

Code to mirror (existing patterns — copy, don't invent)

  • client/src/services/auth/* (types.ts / keys.ts / apis/clientApi.ts / hooks/use*.ts / index.ts) — the exact shape every new domain service copies.
  • The booking-detail screen + its booking query from frontend-phase-8-b9 — where the support/emergency entry points mount.
  • client/CLAUDE.md — RSC boundary, layouts, i18n, theme, fetch services, anti-patterns.

Design

  • Invoke the frontend-designer skill before building any screen. All visual work (the inbox list, the thread bubbles, the bell + badge, the emergency banner, message-send states) goes through it — brand palette, tokens, typography, the App* library, RTL rules. Do not hand-roll colours or spacing.

3. Scope — build this

Two new domain services + the customer/nurse screens that consume them. Customer and nurse apps share these screens (role decides chrome, not the components). The admin global queue is (DEFERRED to frontend-phase-15-b15).

3.1 services/tickets (new domain seam)

Copy the auth service skeleton exactly.

  • types.ts — derive from the b15 contract: Ticket (id, referenceCode, subject/category, status enum e.g. open|pending|closed, optional bookingId/refundId, lastMessageAt, unreadCount), TicketMessage (id, ticketId, body, authorRole e.g. customer|nurse|admin, authorName, createdAt, isMine). Do not model an isInternal field in the user-app types — the server strips internal messages from the user view; modelling it invites a leak (§5).
  • keys.ts — query-key factory: tickets.list(params), tickets.detail(id), tickets.thread(id, page). Deliberate staleTime (thread is short-lived; list moderate).
  • apis/clientApi.ts wrapping clientFetchlistMyTickets(params), getTicket(id), getThread(id, page), openTicket(body), postMessage(ticketId, body, clientMessageId).
  • hooks/ (one hook per file): useMyTickets, useTicket, useTicketThread, useOpenTicket, usePostMessage. usePostMessage is optimistic (§3.5). Mutations invalidate tickets.list/tickets.thread on settle so cached data isn't refetched needlessly.
  • index.ts barrel.

3.2 services/notifications (new domain seam)

Copy the same skeleton.

  • types.ts — from the b1 contract: AppNotification (id, type enum, title/body, isRead, createdAt, dataJson), and a discriminated-union NotificationData typed off type (e.g. booking_confirmed → { bookingId }, payment_captured → { bookingId }, payout_paid → { batchId }, review_published → { reviewId }, ticket_message → { ticketId }). data_json is a typed contract — parse it into the union; never trust an arbitrary blob (§5).
  • keys.tsnotifications.list(params), notifications.unreadCount().
  • apis/clientApi.tslistNotifications(params) (paged, unread-first), getUnreadCount(), markRead(id), markAllRead().
  • hooks/: useNotifications, useUnreadCount (the polling query — §3.4), useMarkNotificationRead, useMarkAllRead. Mark-read mutations setQueryData to flip isRead and decrement the cached count optimistically, then invalidate on settle — no full refetch of the list.
  • index.ts barrel.
  • A small notificationDeepLink(n: AppNotification): string util that maps the parsed dataJson to an in-app route (e.g. bookingId → /bookings/{id}, ticketId → /support/tickets/{id}). Centralise it so the bell and the center both deep-link identically.

3.3 Ticket screens (customer + nurse apps)

  • "My Tickets" inbox (/support/tickets in the customer shell; the equivalent under the nurse shell) — a paginated list of Ticket cards built from MUI/App* primitives: reference_code shown prominently, subject/category, status chip (reuse the f0 status chip), linked-booking/refund hint (handle the null link gracefully), relative Shamsi time, and an unread indicator. States: empty ("هنوز گفتگویی ندارید" / "No conversations yet"), loading skeleton, error→retry. A "Contact support" CTA opens a new ticket (category select → submit → confirmation showing the new reference_code).
  • Ticket thread view (/support/tickets/[id]) — role-aware message bubbles (mine vs theirs, mirrored for RTL), the reference_code in the header, linked-booking chip, a sticky composer at the bottom. States: thread skeleton, empty ("هنوز پیامی نیست، هماهنگی را شروع کنید" / "No messages yet — start coordinating"), per-message send states (sending / sent / failed→retry, draft preserved), not-a-participant / ticket-closed errors. The user thread NEVER renders internal-note content or styling — there is no internal-note affordance anywhere in the user app (§5).
  • Open-from-booking entry point — on the booking-detail screen from frontend-phase-8-b9, add a "Get support / Open ticket" action that opens a new ticket pre-linked to that bookingId (or jumps to the existing coordination ticket if one exists). Wire it through services/tickets; don't fetch the booking again — reuse its query.

3.4 Notification center + bell

  • Notification bell — a shared component in the app chrome (customer bottom-nav/top-bar and nurse shell) showing the unread count badge. The count comes from useUnreadCount, which polls with stale-while-revalidate: set a sensible refetchInterval (e.g. 60s), refetchOnWindowFocus, and a matching staleTime so you serve the cached count instantly and revalidate in the background — do not hammer the endpoint (§5).
  • Notification center (/notifications, or a drawer/sheet off the bell) — a paged, unread-first list; each row deep-links via notificationDeepLink and marks itself read on open (optimistic setQueryData flips isRead and decrements the cached unread count, invalidate on settle). A "Mark all read" action. States: empty ("به‌روز هستید" / "You're all caught up"), loading, error→retry, unread badge styling. Reuse the f0 status chip / list primitives; build the row as a shared composite with a co-located test.

3.5 Optimistic message send (the interaction that must feel instant)

usePostMessage is useMutation with onMutate: generate a clientMessageId, cancelQueries on the thread key, snapshot, and setQueryData to append a pending bubble; on error roll back to the snapshot but keep the typed text in the composer draft so the user can retry without retyping; on success replace the pending bubble with the server message; onSettled invalidate the thread key. Reconcile by clientMessageId so you never double-render a message. The composer disables submit while sending but never clears the draft until the server confirms.

3.6 Emergency banner (the operational playbook)

A shared emergency banner composite shown on the booking detail (esp. nurse app) and on the support entry: prominent, branded, copy = "For emergencies, call the emergency contact, then open a ticket" (both locales). It surfaces the emergency-contact number as a tel: click-to-call link drawn from the booking's (post-confirmation, decrypted) care instructions exposed by frontend-phase-8-b9/the booking contract — the platform never exposes a nurse's or family's general phone number; only this controlled, post-confirmation emergency surface (§5). Pre-confirmation, render nothing (no placeholder). If the contact can't be loaded, still show the banner with a path to open a support ticket. This is tel: only — do not build any calling/VoIP seam (telephony is out-of-platform by design).

3.7 i18n + types housekeeping

  • Add a messaging/tickets and a notifications namespace to both messages/en.json and messages/fa.json, in sync, RTL-first (fa default). Every user-visible string is a key.
  • Types come from the published contract; any gap → append to for-backend.md and mock behind the seam.

(DEFERRED) — admin global ticket queue + internal-note composer, support-alert worklist (frontend-phase-15-b15); push/real-time message delivery, SignalR/SSE replacing polling, file attachments on messages, a first-class incidents/SLA UI (product-doc DEFERRED). Build the services so f15 layers the admin lens on top; do not stub admin screens here.

4. Mocks & seams in this phase

This is a frontend phase — its only "seams" are the two domain services behind which a mock clientApi lives until the backend is merged (operating-rules §6, frontend-checklist last bullet).

  • services/tickets seam — if b15 isn't merged, ship a mock clientApi (same method signatures as the real one) returning realistic tickets/threads (with reference_code, no internal messages — the mock must mimic the server's user-view filtering), and an in-memory append for optimistic-send testing.
  • services/notifications seam — likewise: a mock returning a paged unread-first list, a decrementing unread count, and a data_json payload per type so notificationDeepLink can be exercised.

Record both mocks in your frontend report and (since they're client-side mocks behind a seam) note them so f15/real-endpoint swap is a one-file change. Do not introduce backend seams (IFieldEncryptor, INotificationDispatcher, etc.) — those are b1/b15's; reuse the booking-detail care-instruction disclosure from frontend-phase-8-b9 for the emergency contact, do not re-implement decryption client-side.

5. Critical rules you must not get wrong

  • is_internal never reaches the user app. Internal admin notes are server-filtered, but do not leak the concept: don't model an isInternal field in the user-app types, don't render internal styling, and never add an internal-note affordance in the customer/nurse thread. Treat any internal message in a user-view payload as a backend defect — file it via for-backend.md, don't render it.
  • No direct out-of-band channel. There is no live chat and no nurse↔customer phone exchange. The only sanctioned bypass is the emergency-contact tel: surface, and only post-confirmation. Never expose a nurse's or family's general phone number; never turn the emergency surface into a contact directory. The emergency path is an operational playbook, not a real-time feature — no SLA timers, no VoIP/calling seam, just tel: + "then open a ticket".
  • Show reference_code prominently in the inbox and thread header — it is what a user quotes to support and must be stable and visible.
  • Poll the unread count politely. useUnreadCount uses stale-while-revalidate (sensible refetchInterval + staleTime, refetch-on-focus) — serve the cached count, revalidate in the background, and do not hammer the endpoint. Don't poll the full notification list on an interval; only the count.
  • Optimistic send is draft-preserving. On failure, roll the thread back to its snapshot but keep the user's text in the composer with a retry; reconcile by clientMessageId to avoid double-render. Never clear the draft until the server confirms.
  • data_json is a typed contract. Parse it into the discriminated NotificationData union and deep-link off that; never eval/trust an arbitrary blob, and degrade gracefully (no deep-link) for an unknown type.
  • Tenancy / null links. A user sees only their own tickets and notifications (server-enforced — don't fetch by raw id you don't own). Ticket↔booking/refund links are optional — render the linked-entity chip only when present; handle null gracefully.
  • Frontend conventions (non-negotiable): fetch only through clientFetch in services/{domain}; TanStack Query caching with deliberate keys + invalidation/setQueryData (no needless refetch); minimise re-renders (select, stable refs, colocated state — the bell's fast-changing count must not re-render the whole shell); MUI primitives stay MUI, shared composites (notification row, emergency banner, message bubble) live at src/components/… with co-located tests; colours from tokens.css; both locales in sync; RTL-correct (mirror message bubbles); no layout above [locale]; respect the RSC/client boundary.

6. Definition of Done

The shared definition-of-done.md, plus:

  • services/tickets and services/notifications exist following the auth service shape (types from the b15/b1 contract, keys factory, clientApi, one hook per file, barrel), with mutations invalidating / setQueryData-ing cache.
  • My Tickets inbox (customer + nurse) lists tickets with prominent reference_code, status chip, unread indicator, null-safe linked-entity hint, and empty/loading/error states; Contact support opens a ticket and shows the new reference_code.
  • Ticket thread renders role-aware bubbles, never any internal-note content/styling, with empty/skeleton/error and per-message send states; optimistic send appends instantly and, on failure, rolls back while preserving the composer draft and offering retry.
  • Open-from-booking entry on the f8 booking detail opens/links a ticket without re-fetching the booking.
  • Notification bell shows a polled unread count (stale-while-revalidate, not hammering); the center lists unread-first, marks read on open (optimistic), has Mark all read, and deep-links via data_json through notificationDeepLink.
  • Emergency banner appears on booking detail (nurse) + support entry only post-confirmation, with a tel: click-to-call and the "call the emergency contact, then open a ticket" copy; hidden pre-confirmation; degrades to a support path if the contact can't load. No calling/VoIP seam built.
  • messaging/tickets + notifications i18n namespaces added to en.json and fa.json in sync; RTL verified (mirrored bubbles, badge placement).
  • Shared composites (notification row, message bubble, emergency banner) live at the shared level each with a co-located *.test.tsx; npm run check green; npm run test:ci green.
  • Any contract gap is in for-backend.md and the corresponding client-side mock is behind the seam and recorded in the report.
  • client/CLAUDE.md Project Structure updated for the new services/tickets, services/notifications, the new route segments (/support/tickets, /notifications), and the shared bell/banner components.

7. How to test (what a human can verify after this phase)

Run npm run dev (with the b15/b1 endpoints live, or the seam mocks if not yet merged):

  1. Open a ticket from a booking. Go to a confirmed booking's detail → tap "Get support / Open ticket" → a ticket opens pre-linked to that booking and lands in My Tickets with its reference_code shown. Expected: the new ticket appears at the top of the inbox without a manual refresh.
  2. Post a message (optimistic). Open the thread, type, send → the bubble appears immediately with a "sending" state, then resolves to "sent". Kill the network (or trigger the mock's error path) and send again → the bubble shows failed→retry and the text stays in the composer; retry succeeds when the network returns. Expected: no duplicate bubble, no lost draft.
  3. No internal notes leak. With a ticket that has an admin internal note (mock or seeded), confirm the user thread shows none of it — no hidden styling, no affordance. Expected: user view is identical whether or not internal notes exist.
  4. Notification bell unread count. Trigger a notification (e.g. a new ticket message) → the bell badge increments within the poll interval. Open the center, open a notification → it marks read, the badge decrements, and it deep-links to the right screen (booking/ticket/etc.) via data_json. Mark all read clears the badge. Expected: count is served from cache instantly, revalidates in the background, and the endpoint is not hit more often than the interval (check the network tab).
  5. Emergency banner. On a confirmed booking (nurse app), the emergency banner is visible with a tel: link and the playbook copy; on an unconfirmed booking it is absent (not a placeholder). Expected: tapping the link initiates a phone call; "open a ticket" routes to the ticket flow.
  6. RTL + locales. Switch fa/en: bubbles mirror, the badge sits correctly, every string is translated. npm run check and npm run test:ci pass.

8. Hand off & document (close the phase)

  • Docs to update: client/CLAUDE.md Project Structure — add services/tickets, services/notifications, the /support/tickets + /notifications route segments, the shared notification-bell / message-bubble / emergency-banner components, and a one-line note on the polling unread-count pattern and the optimistic-send/draft-preserve pattern so f15 reuses them. If you discover a business-rule drift (e.g. the contract exposes is_internal to users), record it and file the request — do not invent rules.
  • Contract to consume: ../../contracts/domains/messaging-notifications-admin.md (b15 tickets) + the b1 notification endpoints — derive all types from it; never guess shapes. Any missing field/filter/endpoint → append a REQ-NNN entry to ../../shared-working-context/frontend/requests/for-backend.md (e.g. a clientMessageId/idempotency field for optimistic send, an unreadCount on the ticket list, the emergency-contact field on the booking payload) and mock behind the seam meanwhile.
  • Handoff & report: append your phase summary to ../../shared-working-context/frontend/STATUS.md; write ../../shared-working-context/reports/frontend-phase-14-report.md (operating-rules §7) — what was built, what is now testable and exactly how (the 6 steps above), which client-side mocks sit behind the two seams and how f15/the real endpoint swaps them, the contracts consumed, and the follow-ups left for frontend-phase-15-b15 (the admin lens over these services). Update the mock registry ../../shared-working-context/reports/mocks-registry.md for the two client-side service mocks.
  • Memory: save a project memory note (with a MEMORY.md pointer) for the non-obvious decisions this phase locks in — the user-app types deliberately omit isInternal, the polled-unread-count stale-while-revalidate pattern, the optimistic draft-preserving send + clientMessageId reconciliation, the data_jsonnotificationDeepLink typed-union mapping, and the post-confirmation tel:-only emergency surface (no VoIP seam).