Notifications
The Member Portal has a persistent in-app notification system. A bell icon in the app header shows unread notifications and opens a side drawer with the full history.
Key files:
memberportal/src/contexts/NotificationContext.tsx— state, FHIR persistence, all mutationsmemberportal/src/components/NotificationBell.tsx— bell icon, unread badge, notification drawermemberportal/src/hooks/useSyncPoller.ts— EHR sync polling shared by Dashboard and Settingspackages/server/src/support/routes.ts— server-side notification creation for support events
Architecture​
Notifications are stored as PatientNotification FHIR resources on the server — they persist across sessions and devices. The client loads them from FHIR on mount and re-fetches every time the bell drawer is opened.
PatientNotification (FHIR resource)
├── patient → Reference(Patient)
├── type → notification type code
├── title → short heading
├── message → body text
├── read → boolean (false = unread)
├── sent → dateTime (creation timestamp)
├── actionLabel → optional CTA button text
└── actionPath → optional client-side route for the CTA
Notification Types​
| Type | Color | Trigger |
|---|---|---|
ehr_expired | Orange | EHR session expired — reconnect required |
sync_complete | Green | EHR sync finished successfully |
sync_partial | Yellow | Some providers synced, others expired |
sync_failed | Red | EHR sync failed entirely |
support_reply | Blue | Admin replied to patient's support ticket |
support_status_changed | Teal | Admin changed support ticket status |
Where Notifications Are Created​
On page load — token expired check​
File: memberportal/src/App.tsx
When the patient loads the app, after the NotificationContext finishes loading existing notifications from FHIR, App.tsx queries for any PatientEHRConnection with status=token_expired. If found, it creates an ehr_expired notification with deduplicate=true — so it only adds one even if the patient logs in daily while the session is still expired.
EHR sync — Dashboard and Settings​
File: memberportal/src/hooks/useSyncPoller.ts
Both DashboardPage (via the global Refresh button) and SettingsPage (via the per-provider and per-hospital Refresh buttons) use useSyncPoller. After triggering a sync via POST /fhir/referesh/all-ehrs or /fhir/referesh/single/:id, the hook polls GET /fhir/referesh/status every 3 seconds until the sync completes, then:
- Creates a Mantine toast (transient)
- Calls
addNotificationto persist the result to the bell
Support ticket events — server-side​
File: packages/server/src/support/routes.ts
The server creates PatientNotification resources directly via systemRepo.createResource in two handlers:
| Event | Handler | Type |
|---|---|---|
| Admin changes ticket status | PATCH /support/v1/admin/tickets/:id | support_status_changed |
| Admin sends a reply | POST /support/v1/admin/tickets/:id/reply | support_reply |
Both notifications include an actionPath: '/support' CTA so the patient can navigate directly to their support tickets. If the ticket was submitted anonymously (no patient reference), no notification is created.
NotificationContext​
File: memberportal/src/contexts/NotificationContext.tsx
Provided at the root in main.tsx inside NotificationProvider.
API​
| Export | Description |
|---|---|
notifications | Array of AppNotification objects, newest first |
unreadCount | Number of unread notifications |
loaded | true once the initial FHIR fetch completes |
refresh() | Re-fetches all notifications from FHIR |
addNotification(n, deduplicate?) | Creates a PatientNotification resource; shows immediately (optimistic), then swaps in the real FHIR id |
markRead(id) | Patches read: true on the resource |
markAllRead() | Patches all unread resources |
dismiss(id) | Deletes the resource |
clearAll() | Deletes all resources |
Deduplication​
Pass deduplicate: true to addNotification to skip creation if an unread notification of the same type already exists. Used for ehr_expired to avoid stacking identical alerts across sessions.
Optimistic updates​
addNotification updates local state immediately with a temp-* id so the bell badge increments without waiting for the FHIR round-trip. When createResource resolves, the temp id is replaced with the real FHIR id. If the FHIR call fails, the notification remains in memory for the current session.
NotificationBell component​
File: memberportal/src/components/NotificationBell.tsx
Rendered in App.tsx between the page header action and the ProfileDropdown.
- Bell icon —
ActionIconwrapped in a MantineIndicatorshowing the unread count badge (red, hides when zero) - On click — calls
refresh()then opens a right-sideDrawer - Drawer header — "Notifications" title + "Mark all read" / "Clear all" buttons
- Each item — type icon badge, title, relative timestamp, message, optional action button, dismiss (×) button shown on hover
- Unread items have a light blue background; clicking an item marks it read
useSyncPoller hook​
File: memberportal/src/hooks/useSyncPoller.ts
const { pollSync } = useSyncPoller();
pollSync(token, {
onDone: () => setRefreshing(false), // called when polling ends (any outcome)
onSuccess: () => setLastSyncDate(...), // called only when at least one provider synced
});
Internally polls GET /fhir/referesh/status every 3 seconds. On completion, fires both a Mantine toast and addNotification. Use this in any page that triggers an EHR sync — do not duplicate the polling logic inline.
Adding a new notification type​
- Add the code to
packages/definitions/src/fhir/r4/valuesets-medplum.json→patient-notification-typeCodeSystem - Add the type to
NotificationTypeinNotificationContext.tsx - Add a color and icon case in
NotificationBell.tsx(notifColor/notifIcon) - Call
addNotification({ type: '...', title, message })from the client, orsystemRepo.createResource({ resourceType: 'PatientNotification', ... })from the server