Notification Preferences
How email and in-app notifications are configured, where the defaults come from, and how the bell keeps read state in sync.
On this page
Where preferences live
Personal notification preferences are configured from Settings → Notifications (NotificationsPage). Every toggle on that screen reads and writes a single jsonb column: profiles.notification_prefs. There is one row per user — preferences are personal, not shared at the org level.
The bell icon in the top nav (NotificationsBell) is the other half of the system — it decides what actually gets surfaced in-app based on the inApp* toggles below, while a separate server-side path handles the email* toggles for outbound email.
Email vs. in-app — two independent toggles
For most events there isn't one on/off switch — there are two, and they don't move together. You can get an email for "work order assigned" but turn off the in-app notification for it, or the reverse. Each is a separate boolean field on NotificationPrefs (e.g. emailWorkOrderAssigned and inAppWorkOrderAssigned), and the Settings UI renders them as two separate checkboxes per event row.
That pairing isn't universal, though — a handful of events only exist on one channel:
- Estimate change-request is in-app only. There is no
emailEstimateChangeRequestfield at all — this event can only ever produce a bell notification, never an email. - Estimate approved, estimate rejected, and estimate expiring soon are email only. There's no in-app counterpart for any of the three — they don't show up in the bell, only in your inbox.
- The three admin "any WO" events are email only — see below.
Full event reference
18 events support both channels independently. The remaining 7 are locked to a single channel — marked below.
| Event | In-app | |
|---|---|---|
| Work order assigned You're set as the assignee on a work order. | Yes | Yes |
| Work order status changed A work order you're watching changes status. | Yes | Yes |
| Work order overdue A work order passes its due date without being closed. | Yes | Yes |
| Work order comment Someone comments on a work order you're involved with. | Yes | Yes |
| Requisition approved A requisition you submitted is approved. | Yes | Yes |
| Requisition rejected A requisition you submitted is rejected. | Yes | Yes |
| Approval required You're the next approver in a requisition's approval chain. | Yes | Yes |
| PO approval required You're the next approver in a purchase order's approval chain. | Yes | Yes |
| Estimate approval required You're the next approver in an estimate's internal approval chain. | Yes | Yes |
| Estimate client-accepted A client accepts an estimate. | Yes | Yes |
| Estimate client-rejected A client declines an estimate. | Yes | Yes |
| Proposal deposit failed A client's deposit is declined or returned by their bank after they accepted the proposal. | Yes | Yes |
| New ticket A new support/service ticket comes in. | Yes | Yes |
| Ticket assigned You're set as the assignee on a ticket. | Yes | Yes |
| Ticket comment Someone comments on a ticket you're involved with. | Yes | Yes |
| Contract expiring A contract is approaching its expiration date. | Yes | Yes |
| Low stock alert A part's quantity on hand drops to or below its minimum. | Yes | Yes |
| PM schedule due A preventive-maintenance schedule's next-due date has passed. | Yes | Yes |
| New maintenance request A new maintenance request is submitted. | Yes | Yes |
| Estimate change-request — in-app only | ||
| Estimate change-request A client (or internal reviewer) requests changes to an estimate before deciding. | No email variant | Yes |
| Email only — no in-app counterpart | ||
| Estimate approved An estimate you submitted for internal approval is approved. | Yes | Not shown in bell |
| Estimate rejected An estimate you submitted for internal approval is rejected. | Yes | Not shown in bell |
| Estimate expiring soon An estimate is approaching its expiration date. | Yes | Not shown in bell |
| Any WO created (admin) Org-wide firehose: any work order is created, not just your own. | Yes | Not shown in bell |
| Any WO status changed (admin) Org-wide firehose: any work order changes status. | Yes | Not shown in bell |
| Any WO comment (admin) Org-wide firehose: a comment is added to any work order. | Yes | Not shown in bell |
Why defaults live in code, not a database row
There's no org-wide "default preferences" row anywhere in the database. Defaults are a plain object in code — DEFAULT_NOTIFICATION_PREFS in use-notification-prefs.ts — and each user's actual saved preferences live in profiles.notification_prefs, a jsonb column that defaults to {} on a brand-new profile.
What the app actually uses is the two merged together: code defaults, overlaid with whatever the user has explicitly changed. A brand-new user has stored nothing, so they simply get the hardcoded defaults. The moment they flip one toggle, only that field gets written to their row — everything else stays absent, still falling through to the code default.
Personal preferences vs. eligible recipients
These are two different questions and it's easy to conflate them:
- "What do I get?" — your personal
notification_prefs, covered above. This is entirely per-user and has no bearing on anyone else. - "Who in the org can even be notified?" — a separate, admin-only setting for exactly two broadcast-style CRM events: estimate decisions (client accepts or declines — which also covers a failed proposal deposit, since the people who wanted to hear a proposal was accepted are the ones who need to hear its deposit bounced) and new tickets. An admin uses the Recipients picker in Settings → Notifications to restrict the eligible pool for each, stored as an array of user IDs under
organizations.customizations(estimateDecisionRecipientIds/newTicketRecipientIds). Leaving it unset means "no restriction" — anyone in the org is eligible.
The two layers stack: the recipient pool decides who is eligible to be notified about an estimate decision or a new ticket org-wide; each eligible person's own inAppEstimateClientAccepted / emailNewTicket (etc.) toggle still decides whether they actually get it. Narrowing the pool doesn't override anyone's personal toggle, and turning your personal toggle on doesn't add you to the pool if an admin has excluded you.
How the bell decides what to show
The bell isn't backed by a live push feed for the events themselves. Every time it renders, it derives the current notification list client-side from TanStack Query data that's already loaded in the app for other reasons — work orders, parts, PM schedules, requisitions, purchase orders, estimates, maintenance requests — filtered against your inApp* preferences, plus a one-time fetch of any persisted rows from a notifications table.
There's no category or grouping UI — it's a single flat list, sorted unread items first and then by recency.
Real-time read-state sync
Read/unread state is the one part of this system that is genuinely real-time. A dedicated hook, useNotificationReads, subscribes to Supabase Realtime postgres_changes INSERT events on a notification_reads table, backed by both localStorage and that table.
On load, read state is seeded instantly from localStorage (no flicker), then merged with rows fetched from Supabase. From then on, marking something read writes to both places, and the Realtime subscription pushes any read made elsewhere — another tab, another device — into this session live.