12 KiB
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 Integration: docs/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" mounted in CustomerLayout.tsx:34 — badge from the polled count; always navigates, never opens a popover |
| Bell (nurse) | any /nurse/* route |
same, NurseLayout.tsx:74 |
| Feed (customer) | /fa/notifications |
NotificationCenter role="customer" — 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). Unreachable: AdminLayout renders no bell and nothing links to it |
| Row | — | 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); client grows pageSize instead of paging (useNotifications.ts) |
| badge | GET /notifications/get_unread_count |
own cheap query; polled every 60 s, stale 45 s (constants.ts:25-27), gated on auth |
| mark one | POST /notifications/mark_notification_read |
optimistic — flips the row and decrements the badge, rolls back on error (useMarkNotificationRead.ts) |
| mark all | POST /notifications/mark_all_read |
optimistic, zeroes the badge; bulk ExecuteUpdate server-side |
Chain traced end to end: NotificationCenter.tsx
→ hooks → apis/index.ts (USE_NOTIFICATIONS_MOCK = false)
→ clientApi.ts:45-73
→ NotificationsController.cs
→ ListMyNotifications / GetUnreadCount / MarkNotificationRead / MarkAllRead handlers. No phantom, no gap.
Shapes live in the integration doc.
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 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 · 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
- Log in as
09120000010(سارا محمدی, customer) — see testing-setup.md. - 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). - Tap the bell →
/fa/notifications. Expecttotal: 12, unread-first, grouped under «امروز» / «دیروز» / «این هفته» / Shamsi day headers, each unread row with a dot and a tinted icon. - Find the top row —
booking_confirmed,{"booking_id":10}. Expect a chevron; tapping it navigates to/fa/bookings/10and the badge drops by one immediately (optimistic). - Find any
booking_request_accepted/evv_no_showrow. Expect the opposite: neutral grey icon, no chevron, tap does nothing but mark read. That is the type-vocabulary gap, working as coded. - 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.
- Nurse side: log in as
09120000001and open/fa/nurse/notifications. Expect 9 rows, mostlybooking_request_received— all of them untappable, and the twobooking_confirmed_nurserows untappable too. - Admin:
/fa/admin/notificationsrenders 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; onlybooking_confirmed,refund_completedandticket_messagedeep-link. booking_confirmed_nursecarries a validbooking_idbut no client branch — the nurse's most important notification cannot be opened (parse.ts:21-26).- All five
booking_request_*types carrybooking_request_id, a keyparse.tsnever reads andNotificationDatahas nokindfor — the whole request lifecycle is non-navigable, including the "pay within the window" prompt. refund_issuedvs the client'srefund_processed, andreview_moderatedvsreview_published: near-miss names on both sides of the same contract.verification_expiry_promptis dispatched withDataJson = null— even a matching client branch could not route it (ScanExpiringCredentials.Handler.cs:81).- The client handles 10 types no server code emits; the
payoutandnurse_profiledeep-link classes indeepLink.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
titleandbodyis 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/notificationsis aPlaceholderScreen, 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:
useNotificationsgrowspageSizeon page 1, so "load more" refetches the whole feed each time. - Notification retention (90 d / 24 h) is a hardcoded constant, not a
platform_configsrow, contrary to the repo's config-is-rows convention. - The two notification
page.tsxfiles are'use client'with nogenerateMetadata, departing from the thin-RSC-page convention in client/CLAUDE.md. - No unread-notification cap or archive: the feed grows until the 90-day sweep, and unread rows are never swept.