Skip to main content

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 mutations
  • memberportal/src/components/NotificationBell.tsx — bell icon, unread badge, notification drawer
  • memberportal/src/hooks/useSyncPoller.ts — EHR sync polling shared by Dashboard and Settings
  • packages/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​

TypeColorTrigger
ehr_expiredOrangeEHR session expired — reconnect required
sync_completeGreenEHR sync finished successfully
sync_partialYellowSome providers synced, others expired
sync_failedRedEHR sync failed entirely
support_replyBlueAdmin replied to patient's support ticket
support_status_changedTealAdmin 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 addNotification to 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:

EventHandlerType
Admin changes ticket statusPATCH /support/v1/admin/tickets/:idsupport_status_changed
Admin sends a replyPOST /support/v1/admin/tickets/:id/replysupport_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​

ExportDescription
notificationsArray of AppNotification objects, newest first
unreadCountNumber of unread notifications
loadedtrue 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 — ActionIcon wrapped in a Mantine Indicator showing the unread count badge (red, hides when zero)
  • On click — calls refresh() then opens a right-side Drawer
  • 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​

  1. Add the code to packages/definitions/src/fhir/r4/valuesets-medplum.json → patient-notification-type CodeSystem
  2. Add the type to NotificationType in NotificationContext.tsx
  3. Add a color and icon case in NotificationBell.tsx (notifColor / notifIcon)
  4. Call addNotification({ type: '...', title, message }) from the client, or systemRepo.createResource({ resourceType: 'PatientNotification', ... }) from the server