cleanup phases 6
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user