# 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 `<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-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`.
