Summary
Three separate things share the word "notification" in this codebase. Keeping them apart is the whole point of this document.
- The notification inbox — the user-facing feature. Replies,
@-mentions, upvotes and direct messages from Scout land in a per-user inbox atscout://notifications, with an unread badge on the Home bell. Delivery is poll-on-open: nothing is pushed, so a notification becomes visible on the app's next fetch. - The local patch-unlock banner — an on-device notification the app
schedules for itself the instant it detects an unlock
(
Notifications.scheduleNotificationAsync(..., trigger: null)). No server, no push token, no network call. - Pushover operator alerts — a device-scoped, operator-only channel that pings the developer's own phone on installs, signups and paid orders. Never sent to, or seen by, a Scout user.
There is still no remote/APNs/FCM push to end users anywhere in this
codebase. A repo-wide grep for getExpoPushTokenAsync,
getDevicePushTokenAsync, expo-server-sdk and ExponentPushToken returns zero
matches in both mobile/ and backend/.
What changed, and what it replaced
The broadcast inbox is gone. It held five rows, all release notes, with zero
polls, zero comments, zero likes, zero CTAs and zero expiries ever used.
Announcements moved to the community general board as pinned official posts;
BroadcastMessage was repurposed as the carrier for 1:1 direct messages.
Status (shipped / beta-badged / flagged off)
- Notification inbox, grouping, per-type settings, tap-through: shipped, unconditional. No feature flag.
@-mentions: shipped, limited to thread participants.- Direct messages: shipped. Sent by an admin; there is no user-to-user messaging.
- Local patch-unlock banner: shipped, user-controllable via the toggle now on the notification-settings screen, plus the OS permission.
- Pushover operator alerts: unchanged. Still gated behind the positive,
prod-only
DEVICE_ALERTS_ENABLED/SIGNUP_ALERTS_ENABLEDenv flags, with the orders-paid alert still ungated.
User-facing surfaces
-
scout://notifications→mobile/app/notifications.tsx— the inbox. Reached from the Home-header bell (fg-messages) and the drawer row Notifications (drawer-item-notifications), both badged with the unread count fromuseNotificationsUnreadCount().Both badges read the notification count only since this was fixed. They called
useMessagesUnreadCount()— the unread broadcast count — after the inbox they badge became the notification inbox, so a reply, mention or upvote lit nothing and the inbox was reachable only by typing the deep link. The drawer row was also still labelled "Messages" and pointed at/messages, taking the redirect hop. -
scout://messages→mobile/app/messages.tsx— a redirect to/notifications. Kept because the deep link has been shared. -
scout://message/<id>→mobile/app/message/[id].tsx, an 8-line re-export ofmobile/src/screens/MessageScreen.tsx— one direct message: optional hero image, category badge and relative date, markdown body, an optional poll, a like button, a comment thread and a keyboard-sticky composer, plus an optional block CTA. Reached from the notification inbox's "From Scout" lane (notificationRoute()maps adirect_messageto/message/<id>) or from the deep link itself — the broadcast inbox that used to list these is gone. Split intoMessageScreenViewModel(what it shows),MessageScreenViewModelImpl(every hook,useLocalSearchParamsincluded) and a pureMessageScreenLayout, per the house pattern — so it renders in the Screen mocks gallery atscout://dev-screen-mock/message, see screen-mocks.md. The split moved no behaviour; the limits it exposed are recorded below rather than fixed. -
scout://notification-settings→mobile/app/notification-settings.tsx— per-type toggles, reached from Settings. Split intoNotificationSettingsScreenViewModel(what it shows),NotificationSettingsScreenViewModelImpl(every hook) and a pureNotificationSettingsScreenLayout, per the house pattern — so it renders in the Screen mocks gallery atscout://dev-screen-mock/notification-settings, see screen-mocks.md. The split moved no behaviour; the limits it exposed are recorded below rather than fixed. -
The
@-mention picker inside the comment composer on post detail. -
Release notes and announcements now appear as pinned official posts on the community general board, not in any inbox.
How it works (end-to-end)
Creating a notification
UserNotificationsService.create()
(backend/src/user-notifications/user-notifications.service.ts) writes one
dumb row per event. It silently does nothing in two cases: a self-action, and
a P2002 from the dedupe index. Both are no-ops rather than errors because
every caller is a side effect of some other action that already succeeded — a
comment that posted, a vote that registered — and a notification must never be
able to fail it retroactively.
Four call sites, all in community-posts.service.ts, plus one in
messages.service.ts:
| Trigger | Type | Notifies |
|---|---|---|
createComment (top-level) |
reply |
the post author |
createComment (with parentId) |
replyComment |
the parent comment's author |
createComment mentions |
mention |
each tagged user |
vote |
like |
the post author |
likeComment |
likeComment |
the comment's author |
sendDirect |
direct_message |
the recipient |
RewardsService.grant() |
reward_earned |
the scout who earned the reward |
The seventh type, reward_earned, is a different shape from the other six:
it has no actorId (rewards.service.ts:140-152 passes actorId: null —
the system granted it, not another member) and its targetType/targetId
point at a UserReward row rather than a post or comment. See
rewards.md for the full grant mechanics.
Grouping and muting, both on read
Writes carry no counters and no conditionals. Every question about what a user actually sees is answered in the read path:
- Grouping collapses rows sharing
(type, targetType, targetId), so four upvotes on one post read as one entry. A group is unread if any row in it is unread, and marking it read updates all of them. - Preferences filter on read, not at write time. Muting a type hides its history; re-enabling brings it back.
This is what makes the badge and the list the same query with a different
SELECT. A badge reading "3" over an empty list is unrepresentable here
rather than merely untested.
Target enrichment, also on read
A Notification row stores only (type, targetType, targetId). That is enough
to group and to count, and deliberately no more — but it is not enough to
render. resolveTargets() fills four more fields onto each group before the
controller returns it:
| Field | post target |
post_comment target |
message target |
|---|---|---|---|
postId |
targetId |
the comment's postId |
null (routes to /message/<id>) |
excerpt |
null |
the comment's text | preview, else a body excerpt |
context |
the post's title | the post's title | the message's title |
deleted |
post missing | comment or post missing | message missing |
postId is the load-bearing one: a mention is recorded against a comment
id, and a comment id alone cannot produce a post route. It is resolved
server-side because the client cannot — turning each comment id into a post id
would cost one fetch per row, on a screen rendering up to a hundred rows.
Batched by target type, never per row: three findManys plus one for the
comments' parent posts covers any page.
This shipped missing. For a period the API returned none of these four, so
notificationRoute() hit if (!n.postId) return null for every mention,
replyComment and likeComment and those rows rendered inert — three of
the six types un-tappable — while context and excerpt were always null so
every row read as a bare "someone did something". Nothing caught it: the
service was consistent with itself, screen-tests/notifications.test.tsx
seeded the four fields by hand, and no test compared the two. The notifications
Maestro flow now does.
Delivery
There is no push, no websocket, no server-sent event. useNotifications()
polls with a 60-second stale window for the list and 30 seconds for the badge,
matching what the messages inbox used. Those windows are the delivery
latency.
Blocks
Replies and upvotes are already blocked before they are written
(assertNotBlocked precedes the insert), so no notification can exist for them.
Mentions are the exception — a mention targets someone who need not be the
post author — and are dropped silently via notifiableMentions(). Rejecting
the comment instead would make a block discoverable by whether your comment
posts.
Tap-through
notificationRoute()
(mobile/src/components/notifications/notificationRoute.ts) maps a notification
to a route client-side. The server never hands the app a URL, so nothing an
API returns can become a path the app navigates to blindly.
reply / mention / replyComment → /post/<postId>?comment=<commentId>
like (on a post) → /post/<postId>
likeComment → /post/<postId>?comment=<commentId>
direct_message → /message/<id>
reward_earned → /rewards (a hardcoded client constant)
deleted target → null (row renders inert)
reward_earned's route is the clearest illustration of "the server never
hands the app a URL": notificationRoute() special-cases the type and
returns the literal string '/rewards'
(mobile/src/components/notifications/notificationRoute.ts:35) — it does
not read a targetId-derived path the way every other type does, because
there is no per-reward page to route to (all rewards render on one shared
list, /app/rewards, see rewards.md). notificationCopy()
also special-cases it, naming the actor "Scout" the same way a
direct_message does, since a reward has no human actor either
(notificationRoute.ts:72).
The post detail screen (src/screens/PostScreen.tsx, behind the
app/post/[id].tsx route) reads ?comment= and highlights that comment with a
brass wash and left rail — the same visual language an unread notification row
uses. The highlight is decided in buildPostScreenData, which marks the named
comment isFocused.
Direct messages
MessagesService.sendDirect() creates a published BroadcastMessage with a
required targetUserId and raises a direct_message notification. liveWhere()
scopes every read path to targetUserId = <caller>. A legacy row with a null
recipient matches nobody — deliberately, since "visible to everyone" is the
behaviour being removed.
Data model (Prisma)
Notification—userId(recipient),type,actorId(null for direct messages andreward_earned),targetType,targetId,readAt,createdAt.typeis a plainString, not an enum — addingreward_earnedrequired no migration, only extending theNotificationTypeunions (user-notifications.service.ts:28-36,mobile/src/domain/notification-types.ts:17) and thePREFERENCE_FORmap (below).targetIdis TEXT, not UUID:Post.idandPostComment.idare cuids whileBroadcastMessage.idandUserReward.idare uuids, so one column holds all of them.- The dedupe key is a functional unique index over
(user_id, type, target_type, target_id, COALESCE(actor_id, <sentinel>)), SQL-only because Prisma cannot express one. TheCOALESCEis load-bearing: Postgres treats NULLs as distinct, so a plain unique index would not dedupe direct messages at all.
Profile.notifyReplies/notifyMentions/notifyLikes/notifyDirectMessages— positive booleans defaultingtrue.reward_earnedhas no preference column of its own —PREFERENCE_FORmaps it ontonotifyDirectMessages(user-notifications.service.ts:106), so muting direct messages also mutes reward notifications. There is no separate toggle for rewards anywhere in Settings.Post.mentions/PostComment.mentions—String[]of user ids, never parsed@nametext.BroadcastMessage.targetUserId— the recipient. Now always a 1:1 message.- Still present but unused:
poll_options,poll_votes,message_likes,message_comments,message_comment_likes,broadcast_message_reads— all zero-row, and the services behind them (messages-social.service.ts,messages-comments-admin.service.ts,message-cta-routes.ts) are still wired. The cleanup that removes them has NOT been done. Nothing in the app reaches poll or category UI any more, but the tables, endpoints and admin triage feed are all still live. See Open questions.
API surface
App-facing, JwtAuthGuard — guests included, unlike the old messages inbox,
because guests post, comment and get replied to:
GET /api/notifications— grouped, preference-filtered, and target-enriched (postId,excerpt,context,deleted— see Target enrichment above)GET /api/notifications/unread-countPOST /api/notifications/read-all,POST /api/notifications/:id/readGET /api/notifications/preferences,PATCH /api/notifications/preferences
Admin, AdminGuard:
POST /api/admin/announcements— publishes a pinned official post to the community general board. Whatnpm run release:announcecalls.
Key files
backend/src/user-notifications/— service, controller, module. Deliberately separate frombackend/src/notifications/, which is Pushover only.backend/src/community-posts/community-posts.service.ts— the four triggers andnotifiableMentions.backend/src/messages/messages.service.ts—sendDirect, and the recipient-scopedliveWhere.backend/src/rewards/rewards.service.ts:140-152— the seventh trigger,reward_earned, best-effort and never able to fail the grant that caused it. See rewards.md.backend/src/community-posts/announcements.ts— shared announcement creation.backend/scripts/migrate-messages-to-community.ts— the one-time migration.mobile/app/notifications.tsx,mobile/app/notification-settings.tsx— the latter also exportsDEFAULT_NOTIFICATION_PREFERENCES, the all-on fallback the screen renders before (and instead of) a server answer.mobile/src/dev/mocks/notification-settings.tsx— the gallery entry, four states. Every value in it is synthetic: notification preferences are user data, so there is no real row to freeze.mobile/src/screens/MessageScreen.tsx— the direct-message detail screen, exportingbuildMessageScreenData(the two-way status, the comment threading, the per-comment delete rights and the "is this CTA navigable" rule) so its mock cannot disagree with it about any of them.mobile/src/dev/mocks/message.tsx— the gallery entry, ten states. Message bodies, poll votes, like counts, comments and handles are synthetic (a direct message is the most private user content in the app); what a message points at is real catalog — thestl-forest-parkandnp-gateway-arch-national-parkS3 location photos, the non-adminOnlyst-louiscollection, and CTAroutevalues verbatim fromMESSAGE_CTA_ROUTES. Its header also names the states it refuses to build: loading and error (the screen has neither, see Edge cases), read vs unread and signed-out (identical pixels either way).mobile/src/components/notifications/— row, skeleton, route mapper.mobile/src/components/community/mentions.ts— mention matching and insertion.mobile/research/notifications-lab.html— the four layout variants; C won.
Configuration and flags
| Flag | Type | Default | Governs |
|---|---|---|---|
notificationsEnabled (mobile persisted store) |
positive, user-controlled | true |
The local patch-unlock banner only. |
DEVICE_ALERTS_ENABLED |
positive, backend env | absent ⇒ off | Pushover install/guest alerts to the operator. |
SIGNUP_ALERTS_ENABLED |
positive, backend env | absent ⇒ off | Pushover new-account alert. |
PUSHOVER_TOKEN / PUSHOVER_USER |
backend env | absent ⇒ no-op | Operator alerts only. |
No flag gates the notification inbox, mentions, or direct messages.
Edge cases and known limits
- Re-liking does not re-notify.
vote()upserts, so the trigger re-runs on every re-vote; the dedupe index is what makes "one notification per person per thing, ever" a database guarantee rather than a convention. - A deleted target renders but is inert — "This content is no longer available", dimmed, not pressable.
- The list is capped at ~100 groups. There is no purge job; old rows accumulate.
- Preference changes do not invalidate the preferences cache entry, which lives under its own query key. Under the notifications prefix it was swept up by the same invalidation and refetched, making the toggle visibly snap back.
- Guests receive notifications but lose them with the guest session if they
never promote. The drawer row is gated on
hasSession, notisSignedIn, so a guest sees it. (A guest cannot receivereward_earnedspecifically —RewardsService.grant()refuses any anonymous profile — but this is a property of the rewards feature, not of notification delivery itself.) - Muting direct messages also mutes reward notifications.
reward_earnedhas no preference column of its own; it ridesnotifyDirectMessages(user-notifications.service.ts:106). A scout who turns off DM notifications to stop seeing admin messages will also stop seeing "you earned a reward" — there is no way to have one without the other. - A row's
accessibilityLabelIS what a screen reader gets — not a supplement. An explicit label on a pressable replaces its children in the accessibility tree, soNotificationRow's label is the entire announcement. It read${who} ${action}, ${context}and omitted the excerpt, meaning VoiceOver said "trailwarden mentioned you, Parking + access notes" and then stopped — the words the notification exists to deliver reached nobody, while sitting visibly on screen. NowspokenLabel()joins headline, context and excerpt. Found by the Maestro flow, which queries the same tree VoiceOver reads; every screen test passed because they query rendered output instead. - The Home bell is invisible to Maestro AND to VoiceOver.
MessageBellrenders insideTopBar, an absolutely-positioned BlurView over the scroll view, and is absent from the accessibility tree on both platforms — the same defect Near Me's top bar has. It is an accessibility bug in its own right, and it means the bell's badge can only be covered by a screen test (screen-tests/index.test.tsx); the E2E asserts the drawer badge instead. - A direct message still carries a comment thread, because
messages-socialwas left in place. That path is reachable fromscout://message/<id>and effectively lets a user reply to Scout — unplanned, but not harmful. It should be either removed or made deliberate. - The message screen has NO loading state and NO error state — a still-loading
inbox, a failed inbox fetch, a guest and a dead id all render "Message not
found".
MessageScreenfinds its message withmessages.find((m) => m.id === id)overuseMessages().messagesand reads neither that hook'sisLoadingnor itserror, so all four causes collapse to the sameundefinedand to the same terminal card, "This message is no longer available." A reader offline is told the message was deleted. The screen also dropsuseMessageThread'sloadinganderrorthe same way: a thread that failed to load is indistinguishable from an empty one ("No comments yet — be the first."). Surfaced by the view-model split; deliberately not fixed there — it is behaviour that predates it, andMessageScreenStatusis typed'not-found' | 'ready'because those are the only two branches the screen actually has. - A direct message can never carry a poll or a CTA in practice, but both
branches are live.
MessagesService.sendDirect()hardcodescategory: 'announcement'and writes neither, so only the surviving legacy admin create path can produce one. The screen still renders both, and the gallery'spoll/poll-voted/ctastates are what that code looks like. opened_notificationshad to be added toKNOWN_FEATURE_EVENTS(backend/src/achievements/feature-events.controller.ts) — the allowlist silently rejects unknown tokens.- The notification-settings screen shows all four types ON when it does not
actually know.
prefsQuery.isLoadingandprefsQuery.isErrorare both dropped:prefs = prefsQuery.data ?? DEFAULT_NOTIFICATION_PREFERENCES, so a still-in-flight or outright failedGET /api/notifications/preferencesrenders exactly the same screen as a successful all-on response. A user who opens the screen offline is told nothing is muted, and flipping a switch from there sends a diff against a guess. Surfaced by the view-model split; deliberately not fixed there — it is behaviour that predates it. - A per-type toggle has no busy state and fails silently.
update.isPendingis never read, so a switch mid-flight looks settled; andonErrorrestores the previous value into the query cache without telling the user, so a failed write reads as the switch springing back for no reason. - The patch-unlock toggle does not know the OS permission. It writes only
the persisted
notificationsEnabledpreference, whichmobile/src/services/notifications.ts:60checks before scheduling a banner. Whether the handset would actually show that banner is asked in onboarding (mobile/app/onboarding-notifications.tsx) and bymobile/src/hooks/usePermissionPrompt.ts, never on this screen — so the toggle can sit ON against a handset with notifications denied, and nothing on the screen says so.
What this feature does NOT do
- Scout still sends NO push notifications to end users, in any form. There
is no push-token registration, no APNs or FCM integration, no
expo-server-sdk. The only notification a device displays on its own is the local, immediately-fired patch-unlock banner, which never touches the network. A notification is discovered when the app next fetches — nothing wakes the app. - Nothing is real-time. No websocket, no SSE, no background refresh. Up to a minute can pass before a new notification appears, and only while the app is open.
- There is no user-to-user messaging. Direct messages are admin→user only. A user cannot start, reply to, or delete a thread.
@-mentions cannot reach anyone outside the thread. The picker offers only the post author and existing commenters. There is no global handle search and no endpoint that enumerates users.- Mentions are not parsed from text. Typing
@someoneby hand notifies nobody — only picking a person from the composer records the id that drives the notification. - Notifications do not fire for: new announcements or release notes, patch unlocks, achievements, trips, follows (there is no following), or store events.
- There is no notification history beyond the inbox, no "mark unread", no per-notification delete, and no digest or email summary.
- Announcements have no draft state.
release:announcepublishes immediately; a community post is visible the moment it exists. - Polls, message categories, message comments and message CTAs are gone, not hidden. Anything describing the inbox as supporting polls or discussion threads is describing removed code.
Tests that cover it
End-to-end (the only layer that compares client and server):
mobile/maestro/tests/notifications.yaml— the suite's only two-account flow, and it has to be:create()drops self-actions and the@-picker excludes the viewer, so one account cannot raise a notification at all. A posts, A's inbox is asserted empty, B comments tagging A, and A returns to find the drawer badge, both areplyand amentionnaming B by a handle the flow derived from its own provisioned email, the enriched context/excerpt, and a tap-through to the highlighted comment.mobile/maestro/lib/sign-in-as.yamlis the multi-user building block it needed.- Not covered there: the "From Scout" DM lane. Only an admin can create a
direct message and
liveWhere()scopes reads to the recipient, so a provisioned account has none — a declared gap, not awhen:guard that would skip silently on every run.
Backend:
src/user-notifications/__tests__/user-notifications.service.db.spec.ts(21) — self-action suppression, dedupe including the NULL-actor case, grouping boundaries, read-state across a group, badge/list agreement under muting, and target enrichment: postId on both target types, excerpt/context, the deleted-target flag, and that a mixed page resolves in one batched lookup rather than a query per row.src/community-posts/__tests__/community-notifications.db.spec.ts(10) — the triggers, and every block-suppression rule including the silently-skipped mention that still posts its comment.src/messages/__tests__/direct-messages.db.spec.ts(5) — recipient scoping, including that a legacy null-recipient row is visible to nobody.src/community-posts/__tests__/official-post-index.db.spec.ts,welcome-post-guard.db.spec.ts— the narrowed official-post index, asserted in both directions.scripts/__tests__/migrate-messages-to-community.db.spec.ts,announcements.db.spec.ts— the migration's refusals (drafts, truncation, duplicates) and announcement authoring.src/user-notifications/user-notifications.module.spec.ts,src/admin/announcements-admin.module.spec.ts— boot-time DI wiring.
Mobile:
screen-tests/notifications.test.tsx(7) — the variant-C lane with its paired opposite branch, grouped upvotes, empty, error, and the inert deleted row. Falsified: a hollow row fails 4 of the 7. Note the limit of this file: it seedspostId/context/excerptby hand, which is exactly how it stayed green while the server sent none of them.screen-tests/notifications.test.tsxalso pins the row's spoken label in both directions — excerpt announced, and a clean label when there is none.screen-tests/index.test.tsx— the Home bell badge, andscreen-tests/custom-drawer.test.tsx— the drawer badge. Each seeds the notifications and messages unread endpoints with different numbers, so "a badge appeared" cannot pass: only the right number does. Both falsified by reverting the hook, which renders the messages count instead.screen-tests/notification-settings.test.tsx(4) — toggles reflecting server state, optimistic flip, and the absence of the old "Push Notifications" label. Passed unchanged through the view-model split, which is what proves the split moved no behaviour.screen-tests/screen-mocks.test.tsxrenders the four gallery states. Note the limit of those four: this screen has exactly one render branch, so the states differ only in where the switches point, and a switch's position is invisible togetByTestId/getByText. Blanking a state's seed leaves the rot test green; blanking the row copy an expectation names fails it. The states are for looking at on a device, not for asserting on.screen-tests/post-id.test.tsx—?comment=highlighting and the mention picker, each with a paired opposite branch.screen-tests/messages.test.tsx— the redirect target.screen-tests/message-id.test.tsx(4) — the direct-message detail screen: the seeded message with its thread, plus three unhappy paths that are all the same branch (an id in no fetched list, a signed-out guest with no fetch attempt, and a message with neither poll nor CTA). Passed unchanged through the view-model split, which is what proves the split moved no behaviour.screen-tests/screen-mocks.test.tsxrenders the tenmessagegallery states. Falsified: blanking thethreadstate's comment seed failsmessage/threadby name and nothing else.hook-tests/useNotifications.test.tsx(4) — including guests being enabled.src/components/notifications/__tests__/notificationRoute.test.ts(12),src/components/community/__tests__/mentions.test.ts(13).
Open questions
- The migration has not been run against production. The "five rows, all
release notes, zero polls/comments" finding is from local
scout_dev. If prod differs, the migration needs a data-preservation step before it runs. - Whether
DEVICE_ALERTS_ENABLED/SIGNUP_ALERTS_ENABLEDare currentlytrueon prod remains unverifiable from the repository. - The
notificationsMaestro flow passes end to end against a production build talking to the deployed prod backend (91 commands, 2026-09-01). It confirms on device: one comment raising both areplyand amention, the drawer badge, "2 unread", the server-resolved context and excerpt, the tap-through to the highlighted comment, mark-all clearing the count and the badge while leaving the rows, and its own cleanup. Two of its four findings (accessibilityLabel, and the drawer being unavailable on a pushed root route) were only reachable on a device. - The dead-code cleanup is outstanding. Six zero-row tables and the
message-social / comment-triage / CTA-route services survive. They were
deliberately left rather than dropped in the same change: removing them is
pure deletion with no user-facing effect, it requires reworking
messages.service.ts'sreads/likes/pollOptionsincludes and the message detail screen, and a destructive migration is a poor thing to rush. It wants its own change, with the drop migration running only after the release-notes migration has been applied to production.