medOS ultra

Alert Surface Management

Per-user toggles + org defaults for clinical alert surfaces, fail-soft.

5 min read diagramsUpdated 2026-06-09docs/architecture/alert-surface-management.md

Status: Shipped 2026-06-09 (P0 — per-user toggles + org defaults, fail-soft). Read before any work on the global alert FABs, the alert rail, or per-user notification preferences.

Problem

medOS mounts 9 global alert/notification surfaces at the app root (web/src/App.tsx ≈ lines 199–230). They are stacked into one configurable column by the Alert Rail (web/src/common/components/alert-rail/alertRailStore.ts).

Before this work, the only user-facing control over those surfaces was the Alert Rail tab in Theme Settings — and that is purely cosmetic: it picks where the FABs sit (8 positions) and how they look (floating / docked / full-height rail). There was no functional control over which alert surfaces a user actually sees. Every surface was driven entirely by data events, roles, or backend rules:

Layer that existed What it controls Per-user?
Theme Settings → Alert Rail position + style of the FAB column (cosmetic) yes (localStorage)
/admin/cds-rules, /admin/policy-gates, /admin/security/detection-rules which alerts fire org-wide no — org-wide admin
each surface’s own logic render when data/role/event present no

The gap: a nurse drowning in stock alerts, or a clerk who never needs CDS alerts, had no way to curate their own noise floor — and an admin had no way to set sane org-wide defaults for which surfaces are on.

Solution

A small, additive, fail-soft preference layer that plugs into the seam the rail already exposes — the active flag of useAlertRailSlot(key, priority, active).

registry (source of truth)
   │  per-surface metadata: label, category, suppressible?, severity-filter?, quiet-hours?
   ▼
prefs store (Zustand + localStorage, NO supabase)        ← imported by surfaces (kit-safe)
   │  resolved[key] = { ...registryDefault, ...orgDefault, ...userPref }
   ▼
useAlertSurfaceEnabled(key, peakSeverity?) ── AND-ed into each surface's `active`
   ▲
   │  org defaults + cross-device user prefs (supabase, best-effort)
sync module + <AlertSurfacePrefsBootstrap/> (app-src only, mounted in App.tsx)

Resolution precedence (highest wins)

  1. Registry userSuppressible === false → surface is always on (mandatory). Cannot be turned off by a user or an admin. Applies to Security alerts and the RUDS step-up dialog — the safety floor.
  2. User preference (this user’s explicit choice).
  3. Org default (set by super-admin).
  4. Registry default.

useAlertSurfaceEnabled returns false (hide the FAB) when, for a suppressible surface: the resolved enabled is false or the user is inside their quiet-hours window or the surface’s current peak severity is below the user’s minimum-severity threshold. Exception: critical is never hidden — the threshold floor protects the patient even if the user set a high threshold.

Two surfaces, one component

AlertSurfaceManager renders the registry as a branded, bilingual (TH/EN) control panel with a KPI header. One mode prop drives both placements:

Mode Route Placement Writes
user /my-alert-settings card on User Dashboard (/user-dashboard) this user’s prefs (localStorage + best-effort supabase)
admin /admin/alert-surfaces tile on Super-Admin Dashboard org defaults (supabase alert_surface_policies, localStorage fallback)

Mandatory surfaces render as locked rows (“Always on — required”) in both modes, so the page is a complete inventory of every global alert surface, not just the toggleable ones.

Files

File Role
web/src/common/components/alert-rail/alertSurfaceRegistry.ts source of truth — one row per surface
web/src/common/components/alert-rail/alertSurfacePrefsStore.ts pure Zustand + localStorage store + useAlertSurfaceEnabled hook (no supabase → safe to import from kits)
web/src/common/components/alert-rail/alertSurfaceSync.ts supabase load/save of org defaults + user prefs (app-src only)
web/src/common/components/alert-rail/AlertSurfacePrefsBootstrap.tsx null-rendering, mounted in App.tsx — loads org defaults app-wide + syncs this user’s prefs
web/src/common/components/alert-rail/AlertSurfaceManager.tsx the shared control panel (`mode: ‘user’
web/src/containers/my-alert-settings/page.tsx per-user route page
web/src/pages/admin/alert-surfaces/index.tsx admin route page
web/supabase/migrations/20260609_alert_surface_preferences.sql alert_surface_policies (org) + alert_surface_preferences (per-user) tables + RLS

Surface wiring (the seam)

Each rail-wired surface AND-s the hook into its existing active flag and render gate:

Surface File Key Change
CDS …/medical/surface/cds-alert/CdsAlertSurface.tsx cds gate FAB + drawer, pass peak severity
Acknowledgement …/medical/builder/acknowledgements/AcknowledgementInbox.tsx ack gate FAB
Security …/security/SecurityAlertsFab.tsx security mandatory → no behavior change
Stock packages/inventory-kit/src/components/StockAlertSurface.tsx stock gate FAB + drawer, pass peak severity

Invariants

  1. Safety floor is non-negotiable. userSuppressible:false surfaces (Security, RUDS step-up) can never be turned off by user or admin. Critical-severity alerts are never hidden by a severity threshold or quiet hours.
  2. Fail-soft, always. No supabase, no migration, no network → the page and gating still work off registry defaults + localStorage. Supabase is best-effort everywhere (every call wrapped in try/catch).
  3. Kit-safe import boundary. The module surfaces import (alertSurfacePrefsStore) pulls in zero supabase / heavy deps — mirrors alertRailStore. All supabase lives in alertSurfaceSync / the bootstrap, imported only from app-src pages.
  4. Cosmetic vs functional stay separate. Theme Settings → Alert Rail keeps owning placement; this system owns which surfaces show. Do not merge them.
  5. Registry is the single source of truth. Add a surface = add one registry row + wire its active flag. Nothing else hardcodes the surface list.
  6. Every control does something. A toggle/threshold/quiet-hours control is shown only for a surface that actually honours it (per registry capability flags).

Extending

To make a new global alert surface manageable:

  1. Add a row to ALERT_SURFACES in alertSurfaceRegistry.ts (key, labels TH/EN, category, userSuppressible, supportsSeverityFilter, supportsQuietHours, defaultEnabled, railWired).
  2. In the surface component, call const enabled = useAlertSurfaceEnabled(key, peakSeverity?) and AND it into the active arg of useAlertRailSlot and the early-return gate.
  3. (Optional) add an org-default seed row to the migration.

That’s it — the manager pages, dashboards, and persistence pick it up automatically.

Ask Anything