# Flow — notifications

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** customer · nurse (admin/partner have no bell) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
**Integration:** [docs/integration/domains/notifications.md](../integration/domains/notifications.md)

## What it does

Every state change another domain makes — a request arrives, a payment captures, a refund settles, a ticket
gets a reply — leaves a record the user can find later. A bell in the header carries the unread count; tapping
it opens a day-grouped feed; tapping a row marks it read and, **when the type is one the client recognises**,
jumps to the thing it is about. In-app only: there is no push, SMS or email channel, and nothing is dispatched
client-side — every notification is server-raised.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| Bell (customer) | any `(customer)` route | [`NotificationBell role="customer"`](../../client/src/components/notifications/NotificationBell.tsx) mounted in [`CustomerLayout.tsx:34`](../../client/src/layout/CustomerLayout.tsx) — badge from the polled count; **always navigates**, never opens a popover |
| Bell (nurse) | any `/nurse/*` route | same, [`NurseLayout.tsx:74`](../../client/src/layout/NurseLayout.tsx) |
| Feed (customer) | `/fa/notifications` | [`NotificationCenter role="customer"`](../../client/src/components/notifications/NotificationCenter.tsx) — day groups «امروز» / «دیروز» / «این هفته» then Shamsi dates, «نمایش بیشتر» grows page 1, «علامت‌گذاری همه…» bulk-clears |
| Feed (nurse) | `/fa/nurse/notifications` | same component, `role="nurse"` |
| Feed (admin) | `/fa/admin/notifications` | **`PlaceholderScreen` stub** — icon + generic body, no data ([`page.tsx`](../../client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx)). Unreachable: `AdminLayout` renders no bell and nothing links to it |
| Row | — | [`NotificationRow.tsx`](../../client/src/components/notifications/NotificationRow.tsx) — navigable rows are a `ButtonBase` with a chevron; `kind: 'none'` rows render as a static surface (no ripple, no chevron) but still mark read |

## API

| Call | Endpoint | Notes |
| --- | --- | --- |
| list | `GET /notifications/get_notifications` | unread-first then newest-first ([`NotificationService.cs:24`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs)); client grows `pageSize` instead of paging ([`useNotifications.ts`](../../client/src/services/notifications/hooks/useNotifications.ts)) |
| badge | `GET /notifications/get_unread_count` | own cheap query; polled every 60 s, stale 45 s ([`constants.ts:25-27`](../../client/src/services/notifications/constants.ts)), gated on auth |
| mark one | `POST /notifications/mark_notification_read` | optimistic — flips the row and decrements the badge, rolls back on error ([`useMarkNotificationRead.ts`](../../client/src/services/notifications/hooks/useMarkNotificationRead.ts)) |
| mark all | `POST /notifications/mark_all_read` | optimistic, zeroes the badge; bulk `ExecuteUpdate` server-side |

Chain traced end to end: [`NotificationCenter.tsx`](../../client/src/components/notifications/NotificationCenter.tsx)
→ hooks → [`apis/index.ts`](../../client/src/services/notifications/apis/index.ts) (`USE_NOTIFICATIONS_MOCK = false`)
→ [`clientApi.ts:45-73`](../../client/src/services/notifications/apis/clientApi.ts)
→ [`NotificationsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs)
→ `ListMyNotifications` / `GetUnreadCount` / `MarkNotificationRead` / `MarkAllRead` handlers. No phantom, no gap.
Shapes live in the [integration doc](../integration/domains/notifications.md).

## The type vocabulary — probed live

The server emits **14** type codes. `parse.ts` recognises **3 of them**; the other 11 fall to `{ kind: 'none' }`
and render as untappable rows.

| Server `type` | Raised by | To | `dataJson` | Client verdict |
| --- | --- | --- | --- | --- |
| `booking_confirmed` | `ConfirmPaymentAndPostLedger.Handler.cs:109`, `SettleBnplOrder.Handler.cs:142` | customer | `booking_id` | ✅ → `/bookings/{id}` |
| `refund_completed` | `ConfirmRefundSettlement.Handler.cs:70` | customer | `booking_id`, `refund_id` | ✅ → `/bookings/{id}/refund` |
| `ticket_message` | `PostMessage.Handler.cs:73` | ticket participants | `ticketId` | ✅ → the thread |
| `booking_confirmed_nurse` | `ConfirmPaymentAndPostLedger.Handler.cs:117` | **nurse** | `booking_id` | ❌ unknown type — the nurse's own "you have a paid booking" is dead |
| `booking_request_received` | `CreateBookingRequest.Handler.cs:97` | nurse | `booking_request_id` | ❌ unknown type **and** unknown key |
| `booking_request_accepted` | `AcceptBookingRequest.Handler.cs:57` | customer | `booking_request_id`, `payment_deadline_at` | ❌ — the pay-now prompt does not link to checkout |
| `booking_request_rejected` | `RejectBookingRequest.Handler.cs:45` | customer | `booking_request_id` | ❌ |
| `booking_request_expired_no_response` | `ExpireBookingRequests.Handler.cs:26` | customer | `booking_request_id` | ❌ |
| `booking_request_payment_window_expired` | `ExpireBookingRequests.Handler.cs:35` | customer | `booking_request_id` | ❌ |
| `evv_location_mismatch` | `CheckInVisit.Handler.cs:105` | customer | `booking_id`, `session_id` | ❌ type unknown (the id would have resolved) |
| `evv_no_show` | `DetectNoShowSessions.Handler.cs:67` | customer | `booking_id`, `session_id` | ❌ same |
| `refund_issued` | `CreateRefund.Handler.cs:256` | customer | `booking_id`, `refund_id` | ❌ client knows `refund_processed`, not `refund_issued` |
| `review_moderated` | `ModerateReview.Handler.cs:54` | customer | `reviewId` | ❌ client knows `review_published`, and keys off `nurse_profile_id` |
| `verification_expiry_prompt` | `ScanExpiringCredentials.Handler.cs:81` | nurse | *(none)* | ❌ — no payload at all, so nothing to link to |

Ten codes go the other way: `parse.ts:21-43` handles `booking_reminder`, `session_reminder`,
`payment_captured`, `booking_cancelled`, `refund_processed`, `payout_paid`, `payout_failed`, `ticket_opened`,
`review_published`, `ticket_closed` — **no server code emits any of them.** Consequently the `payout` and
`nurse_profile` branches of [`deepLink.ts`](../../client/src/services/notifications/deepLink.ts) are
unreachable, and a nurse never gets a payout notification at all.

## Rules that must hold

| Rule | Value | Source |
| --- | --- | --- |
| In-app only; no push at launch | `NotificationChannel.Sms`/`Push` exist in the enum, nothing implements them | [business/14 §MVP](../../product/business/14-notifications-and-admin.md) · `INotificationDispatcher.cs` |
| Read notifications hard-deleted after **90 days**; unread never deleted | `RetentionDays = 90`, swept every 24 h — a **hardcoded constant, not a `platform_configs` row** | `NotificationRetentionJob.cs`; business/14 §(a) |
| A notification never exists for a rolled-back transaction | the dispatcher self-commits its own row and is called **after** the handler's `CommitAsync` | `InAppNotificationDispatcher.cs`; e.g. `ExpireBookingRequests.Handler.cs:76-85` |
| Every read is scoped to the caller | `userId` from `ICurrentUser`; no by-id fetch exists | `NotificationService.cs:24,38` |
| Unread count is its own query, never derived from a page | badge must be right without a list fetch | integration doc |
| Adding a type is safe; changing a type's `dataJson` is not | the client keys deep-links off the payload and degrades to non-tappable | integration doc |

## How to test

1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Look at the header bell. **Expect** a numeric badge. Live at this stamp: `GET /notifications/get_unread_count`
   → `{"count":10}` (the seeded 6 plus requests other testers raised today, so treat the number as ≥ 6, not
   exactly 6).
3. Tap the bell → `/fa/notifications`. **Expect** `total: 12`, unread-first, grouped under «امروز» / «دیروز» /
   «این هفته» / Shamsi day headers, each unread row with a dot and a tinted icon.
4. Find the top row — `booking_confirmed`, `{"booking_id":10}`. **Expect** a chevron; tapping it navigates to
   `/fa/bookings/10` and the badge drops by one immediately (optimistic).
5. Find any `booking_request_accepted` / `evv_no_show` row. **Expect the opposite**: neutral grey icon, no
   chevron, tap does nothing but mark read. That is the type-vocabulary gap, working as coded.
6. Tap «علامت‌گذاری همه به‌عنوان خوانده‌شده». **Expect** the badge to hit 0 instantly and stay 0 after a
   refresh. **This is destructive to the shared demo DB** — the unread count does not come back. Skip it
   unless you own the world.
7. Nurse side: log in as `09120000001` and open `/fa/nurse/notifications`. **Expect** 9 rows, mostly
   `booking_request_received` — **all of them untappable**, and the two `booking_confirmed_nurse` rows
   untappable too.
8. Admin: `/fa/admin/notifications` renders a placeholder. There is no link to it and no bell in the admin
   shell; reaching it means typing the URL.

Everything above except step 6 was executed live against `:5002` with pre-minted bearer tokens on 2026-08-02.

## Known gaps

- 11 of the 14 server notification types are unknown to `parse.ts`, so their rows are untappable; only
  `booking_confirmed`, `refund_completed` and `ticket_message` deep-link.
- `booking_confirmed_nurse` carries a valid `booking_id` but no client branch — the nurse's most important
  notification cannot be opened (`parse.ts:21-26`).
- All five `booking_request_*` types carry `booking_request_id`, a key `parse.ts` never reads and
  `NotificationData` has no `kind` for — the whole request lifecycle is non-navigable, including the
  "pay within the window" prompt.
- `refund_issued` vs the client's `refund_processed`, and `review_moderated` vs `review_published`: near-miss
  names on both sides of the same contract.
- `verification_expiry_prompt` is dispatched with `DataJson = null` — even a matching client branch could not
  route it (`ScanExpiringCredentials.Handler.cs:81`).
- The client handles 10 types no server code emits; the `payout` and `nurse_profile` deep-link classes in
  `deepLink.ts` (and their icons/tints) are dead code. No payout notification is ever raised, so the nurse is
  never told a payout paid or failed.
- **Every `title` and `body` is a server-side English literal** (`"Booking confirmed"`, `"The nurse declined
  your request."`) rendered verbatim into the Persian RTL feed — `NotificationRow.tsx:62,67`, no i18n path.
  Only the chrome (headers, buttons, empty state) is translated.
- `/fa/admin/notifications` is a `PlaceholderScreen`, is not linked from anywhere, and no admin/partner shell
  mounts a bell — admins have no notification surface at all.
- `NotificationBellPopover.tsx` (4.4 KB, full mark-read/deep-link logic) is exported but mounted nowhere —
  dead UI since the desktop branch was removed.
- The list is not real pagination: `useNotifications` grows `pageSize` on page 1, so "load more" refetches the
  whole feed each time.
- Notification retention (90 d / 24 h) is a hardcoded constant, not a `platform_configs` row, contrary to the
  repo's config-is-rows convention.
- The two notification `page.tsx` files are `'use client'` with no `generateMetadata`, departing from the
  thin-RSC-page convention in [client/CLAUDE.md](../../client/CLAUDE.md).
- No unread-notification cap or archive: the feed grows until the 90-day sweep, and unread rows are never
  swept.
