Notifications
The notifications subsystem delivers per-user, account-scoped notification events to the app’s notification bell and overlay, keeping unread counts and the unread list live through Supabase Realtime.
Overview
Section titled “Overview”Notifications are row-based and event-backed. A domain event causes a database trigger to insert one row per recipient into public.notifications; the frontend only reads, groups and marks rows read.
The 18 triggers in 20260921000000_notifications_triggers.sql cover: post created; post, project and article comments; post and post-comment reactions; project and article changes; team member and article author added; related project and article links; and org invite created, invite answered, join requested, request reviewed, role changed and member removed.
The client feature has three concerns:
- A query layer (notifications.ts) that reads and writes via the browser client — deliberately not
"use server"so the bell works without a server round trip. - Two state hooks:
useNotificationCountpowers the bell badge by seeding from a server-rendered count and re-reading on realtime events;useNotificationspowers the overlay’s unread batch, loading on open and staying current while open. - A realtime subscription helper (
subscribeWithAuthin realtime.ts) that guarantees a channel only joins after the socket carries the session JWT, because an anonymous channel is silently dropped by Row Level Security.
The core design trade-off is re-read instead of patch: one extra read buys a list that is correct for an arrival, a read in another session and a delete — without three reconciliation paths. Because Realtime sends one event per row, re-reads are debounced so a multi-row write costs a single read.
Architecture
Section titled “Architecture”flowchart TD
Bell["NotificationBell"] --> CountHook["useNotificationCount"]
Overlay["NotificationOverlay"] --> ListHook["useNotifications"]
CountHook --> SubAuth["subscribeWithAuth"]
ListHook --> SubAuth
SubAuth --> Channel["RealtimeChannel (postgres_changes)"]
CountHook --> Count["getUnreadNotificationCount"]
ListHook --> List["getUnreadNotifications"]
ListHook --> MarkRead["markNotificationsRead"]
Count --> BrowserClient["createBrowserClient"]
List --> BrowserClient
MarkRead --> BrowserClient
BrowserClient --> View["v_notifications"]
BrowserClient --> Table[("public.notifications")]
Both hooks translate the reactive account scope (from useActiveAccount) into a concrete scopeOrgId via notificationScopeOrgId, then call the query layer. Realtime callbacks do not carry data into state — they trigger a re-read.
Account Scoping
Section titled “Account Scoping”Every read is scoped to the account the user is currently acting as. null means the individual-account copy; a UUID means an organisation account. The two branches (.eq("recipient_org_id", scopeOrgId) vs .is("recipient_org_id", null)) can never overlap.
Scoping is display-only. The real security boundary is the recipient_user_id RLS predicate on the base table, because the acting-as cookie is unsigned.
Why Scope Is Held in a Ref
Section titled “Why Scope Is Held in a Ref”Both hooks keep the current scope in a useRef. The realtime callback must read the current scope without the subscription tearing down and rebuilding on every account switch. If scopeOrgId were a subscription dependency, switching accounts would drop and re-establish the WebSocket channel unnecessarily. The ref is synced in a separate effect declared before the load effect, so it is current by the time the load reads it.
Realtime Channels
Section titled “Realtime Channels”The bell subscribes to INSERT, UPDATE and DELETE on channel notifications-{userId}, always. The overlay subscribes to all events on notifications-overlay-{userId}, only while open. Both use the filter recipient_user_id=eq.{userId} — Realtime permits only one filter per subscription, so account scope is resolved client-side. A row addressed to another of the user’s accounts arrives and is discarded without touching state.
Deletes always trigger a re-read regardless of scope, because the table has REPLICA IDENTITY FULL but clients still receive only the key on DELETE, so scope cannot be read from the payload.
Mark-as-read
Section titled “Mark-as-read”markNotificationsRead uses .eq("recipient_user_id", userId).is("read_at", null) — you can only mark your own unread rows. The hook drops rows from local state optimistically before the write lands, then schedules a re-read to settle the list. There is no rollback: if the write fails, a toast.error is shown and the re-read restores the rows.
Read Path
Section titled “Read Path”The overlay list reads up to NOTIFICATION_BATCH_SIZE rows from v_notifications (which joins the destination path), ordered by last_event_at DESC (index-backed). The count query uses head: true, count: "exact" and never transfers rows. Because every view column is nullable to the type system, toListItem filters out rows missing id, type, actor_name or last_event_at; event_count defaults to 1 for ungrouped rows.
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- Query error on the bell: logged, previous count left in place; no failed state on the badge.
- Query error on the overlay: logged;
hasFailedis set only when the scope has not changed since the read started. The UI exposeshasFailedandretry. markNotificationsReadfailure: logged,toast.errorshown; the scheduled re-read restores the dropped rows.subscribeWithAuthfailure:"Failed to authorise realtime channel"is logged; the channel is never created and no events arrive.- Stale read after account switch: the scope captured at call time is compared to the ref before setting state; a mismatched result is discarded.
- Realtime event storms:
NOTIFICATION_REREAD_DEBOUNCE_MScollapses bursts to one re-read. The timer is cleared on overlay close so no stray fetch fires after unmount. - Cross-session read race:
.is("read_at", null)means a second session marking the same row read matches zero rows and cannot move an already-set timestamp.
Operational Notes
Section titled “Operational Notes”- The bell starts from a server-rendered count (
initialCount), so its first paint is correct with no client fetch. - The overlay’s channel is created on open and removed on close or unmount, preventing leaked channels.
- Constants
NOTIFICATION_BATCH_SIZEandNOTIFICATION_REREAD_DEBOUNCE_MSlive in config/constants/notifications.ts. - The
notificationstable is in thesupabase_realtimepublication withREPLICA IDENTITY FULL, set in 20260918000000_notifications_foundation.sql.
Extension Points
Section titled “Extension Points”- New event types: add a trigger that inserts a row with the right
typeanddestination_path. Theevent_countandlast_event_atcolumns are already in place for grouped notifications (the UI renders them); a grouping producer can write grouped rows without frontend changes. - New destination targets:
destination_pathflows throughv_notificationsintoNotificationListItem, so new types can point anywhere by populating that column. - Additional realtime consumers: reuse
subscribeWithAuthto obtain an authenticated channel without reimplementing the JWT-before-join dance. - Second pass (planned): per-type notification settings, grouping and digest emails.