Files
2026-06-28 21:59:59 +03:30

331 lines
26 KiB
Markdown

# 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`](./frontend-phase-8-b9.md) (booking
> detail — the ticket/support entry points hang off it) · backend **b15** contract
> ([messaging-notifications-admin](../../contracts/domains/messaging-notifications-admin.md)) + the
> **b1** notifications endpoints · **Unlocks:** [`frontend-phase-15-b15`](./frontend-phase-15-b15.md)
> (admin & partner consoles — reuses the same ticket/notification services with the admin lens) ·
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_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`](../../../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`](./frontend-phase-15-b15.md))**; 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`](./frontend-phase-0.md).
- `AuthContext` with roles, the OTP login/role router — [`frontend-phase-1-b2`](./frontend-phase-1-b2.md).
- 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`](./frontend-phase-8-b9.md). 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`](./frontend-phase-13-b14.md);
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`](../../contracts/domains/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`](../../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**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work and the tick-list.
- [`../_shared/definition-of-done.md`](../_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`](../../../product/business/12-messaging-and-emergencies.md) —
**the core rules**: no live chat / no direct channel, ticket-only, `is_internal` admin 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`](../../../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`](../../../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`](../../../product/data-model/11-notifications.md) —
`notifications` (`data_json` typed payload, polled, 90-day read retention); `support_alerts` are
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`](../../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 for `is_internal`.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`../../contracts/conventions/money-and-types.md`](../../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`](./frontend-phase-8-b9.md) —
where the support/emergency entry points mount.
- [`client/CLAUDE.md`](../../../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`](./frontend-phase-15-b15.md))**.
### 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 `clientFetch``listMyTickets(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.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 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`](./frontend-phase-8-b9.md), 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`](./frontend-phase-8-b9.md)/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`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the seam.
**(DEFERRED)** — admin global ticket queue + internal-note composer, support-alert worklist
([`frontend-phase-15-b15`](./frontend-phase-15-b15.md)); 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`](./frontend-phase-8-b9.md) 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](../_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`](../../shared-working-context/frontend/requests/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`](../../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`](../../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`](../../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`](./frontend-phase-15-b15.md) (the admin lens over these services). Update the
mock registry [`../../shared-working-context/reports/mocks-registry.md`](../../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_json``notificationDeepLink` typed-union mapping, and the post-confirmation `tel:`-only
emergency surface (no VoIP seam).