Administration

Notification Preferences

How email and in-app notifications are configured, where the defaults come from, and how the bell keeps read state in sync.

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 emailEstimateChangeRequest field 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.

EventEmailIn-app
Work order assigned
You're set as the assignee on a work order.
YesYes
Work order status changed
A work order you're watching changes status.
YesYes
Work order overdue
A work order passes its due date without being closed.
YesYes
Work order comment
Someone comments on a work order you're involved with.
YesYes
Requisition approved
A requisition you submitted is approved.
YesYes
Requisition rejected
A requisition you submitted is rejected.
YesYes
Approval required
You're the next approver in a requisition's approval chain.
YesYes
PO approval required
You're the next approver in a purchase order's approval chain.
YesYes
Estimate approval required
You're the next approver in an estimate's internal approval chain.
YesYes
Estimate client-accepted
A client accepts an estimate.
YesYes
Estimate client-rejected
A client declines an estimate.
YesYes
Proposal deposit failed
A client's deposit is declined or returned by their bank after they accepted the proposal.
YesYes
New ticket
A new support/service ticket comes in.
YesYes
Ticket assigned
You're set as the assignee on a ticket.
YesYes
Ticket comment
Someone comments on a ticket you're involved with.
YesYes
Contract expiring
A contract is approaching its expiration date.
YesYes
Low stock alert
A part's quantity on hand drops to or below its minimum.
YesYes
PM schedule due
A preventive-maintenance schedule's next-due date has passed.
YesYes
New maintenance request
A new maintenance request is submitted.
YesYes
Estimate change-request — in-app only
Estimate change-request
A client (or internal reviewer) requests changes to an estimate before deciding.
No email variantYes
Email only — no in-app counterpart
Estimate approved
An estimate you submitted for internal approval is approved.
YesNot shown in bell
Estimate rejected
An estimate you submitted for internal approval is rejected.
YesNot shown in bell
Estimate expiring soon
An estimate is approaching its expiration date.
YesNot shown in bell
Any WO created (admin)
Org-wide firehose: any work order is created, not just your own.
YesNot shown in bell
Any WO status changed (admin)
Org-wide firehose: any work order changes status.
YesNot shown in bell
Any WO comment (admin)
Org-wide firehose: a comment is added to any work order.
YesNot shown in bell
The three "admin" rows are org-wide firehoses, not personal events. "Any WO created," "any WO status changed," and "any WO comment" fire for every work order in the org, not just ones you're assigned to or watching — they exist for admins who want full visibility, and are shown only to admin users in Settings. Off by default, since most admins don't want a copy of every single work order event in their inbox.

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.

Why bother with this instead of seeding a row per user? Because it makes shipping a new default safe. If a future update changes a code default — say, turning "PM schedule due" email on by default for new users — that change only affects users who never touched that field. Anyone who already made a deliberate choice keeps it, because their choice is the only thing actually stored. If defaults were seeded as real rows at signup, changing a default later would either require a bulk migration or would silently do nothing for existing users — and there'd be no way to tell "user explicitly turned this off" apart from "user never had an opinion." Storing only deltas keeps that distinction intact for free.

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.

Two exceptions always ride along regardless of the picker: the estimate's sales rep is always included for estimate-decision and failed-deposit notifications, and the ticket's assignee (if any) is always included for new-ticket notifications — on top of whoever is picked in the Recipients list.

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.

If a notification shows read on one device but not another for a moment — that's the Realtime subscription catching up, not a bug. It resolves itself within the subscription's normal delivery time; there's no manual refresh needed.
© 2026 Landscapt. All rights reserved.