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