Alert Surface Management
Per-user toggles + org defaults for clinical alert surfaces, fail-soft.
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)
- 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. - User preference (this user’s explicit choice).
- Org default (set by super-admin).
- 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
- Safety floor is non-negotiable.
userSuppressible:falsesurfaces (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. - 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).
- Kit-safe import boundary. The module surfaces import (
alertSurfacePrefsStore) pulls in zero supabase / heavy deps — mirrorsalertRailStore. All supabase lives inalertSurfaceSync/ the bootstrap, imported only from app-src pages. - Cosmetic vs functional stay separate. Theme Settings → Alert Rail keeps owning placement; this system owns which surfaces show. Do not merge them.
- Registry is the single source of truth. Add a surface = add one registry row +
wire its
activeflag. Nothing else hardcodes the surface list. - 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:
- Add a row to
ALERT_SURFACESinalertSurfaceRegistry.ts(key, labels TH/EN,category,userSuppressible,supportsSeverityFilter,supportsQuietHours,defaultEnabled,railWired). - In the surface component, call
const enabled = useAlertSurfaceEnabled(key, peakSeverity?)and AND it into theactivearg ofuseAlertRailSlotand the early-return gate. - (Optional) add an org-default seed row to the migration.
That’s it — the manager pages, dashboards, and persistence pick it up automatically.