# Frontend Phase 14 — Messaging (tickets) & notifications — report **Track:** frontend · **Depends on:** frontend-phase-8-b9 (booking detail) · **Consumes:** b15 tickets ([messaging-notifications-admin.md](../../contracts/domains/messaging-notifications-admin.md)) + b1 notifications ([config-reference.md](../../contracts/domains/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 **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/notifications`** — `types.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 ``: 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` (``): 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** — `` 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** — `` (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-preserving** — `onMutate` 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](./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](../frontend/requests/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`.