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. AuthContextwith 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.mdand mock behind theservices/tickets/services/notificationsseam meanwhile (operating-rules §6). Record every mock in your report so it swaps cleanly.
2. Required reading (do this first)
Operating rules & checklists
../_shared/agent-operating-rules.mdand../_shared/frontend-conventions-checklist.md— how you work and the tick-list.../_shared/definition-of-done.md— the bar this phase adds to (§6).
Product / business truth (read before designing any screen)
product/business/12-messaging-and-emergencies.md— the core rules: no live chat / no direct channel, ticket-only,is_internaladmin notes, the emergency playbook ("call the surfaced emergency contact, then open a ticket"), why the family's phone is never exposed beyond the controlled emergency surface.product/business/14-notifications-and-admin.md— in-app, polled notifications (no push), 90-day retention, deep-link via the typed payload; the admin tooling spine is (DEFERRED to f15).product/data-model/09-messaging.md—tickets/ticket_participants/ticket_messages,is_internal,reference_code, optional booking/refund links.product/data-model/11-notifications.md—notifications(data_jsontyped payload, polled, 90-day read retention);support_alertsare internal-only (not in this phase's user app).
Contracts & types (the source of truth for shapes — do not guess)
../../contracts/domains/messaging-notifications-admin.md— the b15 ticket endpoints + the b1 notification endpoints, request/response shapes, enums, status codes, the user-vs-admin filtering note foris_internal.../../contracts/conventions/api-conventions.mdand../../contracts/conventions/money-and-types.md— the envelope, enums-as-codes, UTC timestamps (render Shamsi), pagination.
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-designerskill 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, theApp*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,statusenum e.g.open|pending|closed, optionalbookingId/refundId,lastMessageAt,unreadCount),TicketMessage(id,ticketId,body,authorRolee.g.customer|nurse|admin,authorName,createdAt,isMine). Do not model anisInternalfield 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). DeliberatestaleTime(thread is short-lived; list moderate).apis/clientApi.tswrappingclientFetch—listMyTickets(params),getTicket(id),getThread(id, page),openTicket(body),postMessage(ticketId, body, clientMessageId).hooks/(one hook per file):useMyTickets,useTicket,useTicketThread,useOpenTicket,usePostMessage.usePostMessageis optimistic (§3.5). Mutations invalidatetickets.list/tickets.threadon settle so cached data isn't refetched needlessly.index.tsbarrel.
3.2 services/notifications (new domain seam)
Copy the same skeleton.
types.ts— from the b1 contract:AppNotification(id,typeenum,title/body,isRead,createdAt,dataJson), and a discriminated-unionNotificationDatatyped offtype(e.g.booking_confirmed → { bookingId },payment_captured → { bookingId },payout_paid → { batchId },review_published → { reviewId },ticket_message → { ticketId }).data_jsonis a typed contract — parse it into the union; never trust an arbitrary blob (§5).keys.ts—notifications.list(params),notifications.unreadCount().apis/clientApi.ts—listNotifications(params)(paged, unread-first),getUnreadCount(),markRead(id),markAllRead().hooks/:useNotifications,useUnreadCount(the polling query — §3.4),useMarkNotificationRead,useMarkAllRead. Mark-read mutationssetQueryDatato flipisReadand decrement the cached count optimistically, then invalidate on settle — no full refetch of the list.index.tsbarrel.- A small
notificationDeepLink(n: AppNotification): stringutil that maps the parseddataJsonto 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/ticketsin the customer shell; the equivalent under the nurse shell) — a paginated list ofTicketcards built from MUI/App*primitives:reference_codeshown 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 newreference_code). - Ticket thread view (
/support/tickets/[id]) — role-aware message bubbles (mine vs theirs, mirrored for RTL), thereference_codein 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 thatbookingId(or jumps to the existing coordination ticket if one exists). Wire it throughservices/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 sensiblerefetchInterval(e.g. 60s),refetchOnWindowFocus, and a matchingstaleTimeso 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 vianotificationDeepLinkand marks itself read on open (optimisticsetQueryDataflipsisReadand 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/ticketsand anotificationsnamespace to bothmessages/en.jsonandmessages/fa.json, in sync, RTL-first (fadefault). Every user-visible string is a key. - Types come from the published contract; any gap → append to
for-backend.mdand 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/ticketsseam — if b15 isn't merged, ship a mockclientApi(same method signatures as the real one) returning realistic tickets/threads (withreference_code, no internal messages — the mock must mimic the server's user-view filtering), and an in-memory append for optimistic-send testing.services/notificationsseam — likewise: a mock returning a paged unread-first list, a decrementing unread count, and adata_jsonpayload per type sonotificationDeepLinkcan 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_internalnever reaches the user app. Internal admin notes are server-filtered, but do not leak the concept: don't model anisInternalfield 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 viafor-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, justtel:+ "then open a ticket". - Show
reference_codeprominently 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.
useUnreadCountuses stale-while-revalidate (sensiblerefetchInterval+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
clientMessageIdto avoid double-render. Never clear the draft until the server confirms. data_jsonis a typed contract. Parse it into the discriminatedNotificationDataunion and deep-link off that; nevereval/trust an arbitrary blob, and degrade gracefully (no deep-link) for an unknowntype.- 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
nullgracefully. - Frontend conventions (non-negotiable): fetch only through
clientFetchinservices/{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 atsrc/components/…with co-located tests; colours fromtokens.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/ticketsandservices/notificationsexist following theauthservice 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 newreference_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_jsonthroughnotificationDeepLink. - 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+notificationsi18n namespaces added toen.jsonandfa.jsonin 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 checkgreen;npm run test:cigreen. - Any contract gap is in
for-backend.mdand the corresponding client-side mock is behind the seam and recorded in the report. client/CLAUDE.mdProject Structure updated for the newservices/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):
- 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_codeshown. Expected: the new ticket appears at the top of the inbox without a manual refresh. - 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.
- 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.
- 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). - 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. - RTL + locales. Switch
fa/en: bubbles mirror, the badge sits correctly, every string is translated.npm run checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs to update:
client/CLAUDE.mdProject Structure — addservices/tickets,services/notifications, the/support/tickets+/notificationsroute 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 exposesis_internalto 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 aREQ-NNNentry to../../shared-working-context/frontend/requests/for-backend.md(e.g. aclientMessageId/idempotency field for optimistic send, anunreadCounton 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 forfrontend-phase-15-b15(the admin lens over these services). Update the mock registry../../shared-working-context/reports/mocks-registry.mdfor the two client-side service mocks. - Memory: save a
projectmemory note (with aMEMORY.mdpointer) for the non-obvious decisions this phase locks in — the user-app types deliberately omitisInternal, the polled-unread-count stale-while-revalidate pattern, the optimistic draft-preserving send +clientMessageIdreconciliation, thedata_json→notificationDeepLinktyped-union mapping, and the post-confirmationtel:-only emergency surface (no VoIP seam).