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, direct messages from Scout and Scout's replies to your feedback (feedback-conversations) land in a per-user inbox atscout://notifications, with an unread badge on the Home bell. Delivery is poll-on-open in the app, and mirrored by remote push to the phone when the member has it on (push-notifications): the inbox is the record, a push is a nudge to open it. - 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 (it is separate from the remote push above). The same local mechanism now carries two trip notifications, only during a trip the user started, plus one head-out reminder for a day planned on the Trip Calendar (trips-v2). A planned trip gets a leave-by reminder scheduled for 10 minutes before each stop's leave time. A live Just drive gets a patch-ahead alert from the background location task. Both are local and have their own "Trip reminders" switch. They carry no buttons: a tap opens the trip in the app (src/trips-v2/notificationActions.ts). - 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.
Remote push now exists, through Expo (added 2026-10-10, see
push-notifications): src/push/ on the backend,
mobile/src/push/ in the app. It mirrors inbox rows only, and nothing is sent
unless the backend sets EXPO_PUSH_ENABLED=true. There is still no APNs/FCM
integration of our own and no expo-server-sdk; the backend posts to Expo's HTTP
endpoint directly.
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.
- Deleting a notification (a row's "…" → Delete notification): shipped 2026-10-10, unconditional. No feature flag.
@-mentions: shipped, limited to thread participants.- Direct messages: shipped. Sent by an admin, and no-reply since 2026-10-10 (the message screen has no thread or composer; the backend refuses comments with a 403). There is no user-to-user messaging.
- Feedback conversations: shipped 2026-10-10 — the one two-way channel, a user ↔ Scout thread per feedback submission. See feedback-conversations.
- Local patch-unlock banner: shipped, user-controllable via the toggle now on the notification-settings screen, plus the OS permission.
- Local trip notifications (leave-by, patch ahead): shipped with
trips_v2(default on). They have their own "Trip reminders" toggle on the same screen (tripRemindersEnabled, default on), 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 header's bell (drawer-item-notifications; it was a row until the 2026-10-02 drawer redesign, see navigation-drawer), both badged with the unread count fromuseNotificationsUnreadCount().Every inbox row, in the From Scout lane and in Activity (a dead row too), ends in a "…" (
notification-menu-<id>, label "More options") that opens a small Liquid Glass menu with one item, Delete notification (mobile/src/components/notifications/NotificationActionsMenu.tsx). See Deleting a notification below.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, a 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 and a "can't be replied to — Send feedback" footer, 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. -
Announcements appear as pinned official posts on the community general board, not in any inbox. Release notes did too until 2026-09-27; they now appear on the What's new sheet after an update.
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.
A reply or replyComment is the exception to that P2002 no-op. A like is one
fact however often it's repeated, but a reply is something new to read. So once the
first was read, the same person's next one on the same target marks the row unread,
moves it to the top and pushes (relightReply). While it's still unread it's
already lit, so five quick replies stay one row, one badge and one push.
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 |
FeedbackService.create |
feedback_received |
the support account (SUPPORT_INBOX_EMAIL) |
FeedbackThreadService.reply (user) |
feedback_reply |
the support account |
FeedbackThreadService.reply (support) |
scout_reply |
the feedback's submitter |
Patch Club sync and daily job (backend/src/patch-club/club-sync.service.ts:245-257) |
club |
the club member |
The three feedback types target feedback (the submission) or
feedback_reply (one reply — so each reply is its own row and the dedupe index
never swallows the second), are resolved on read to a feedbackId, and all
ride the notifyDirectMessages switch. scout_reply has a null actor, like a
direct message. Full detail: feedback-conversations.
The club type (Patch Club, built behind the patch_club flag, off) is also actorless
(actorId: null). Its targetType is the event (club_open, club_reminder,
club_proposed, club_shipped, club_expiring) and its targetId is the club_cycles id,
so the dedupe index sends each event once per cycle. Title and body are built on read from
the cycle (user-notifications.service.ts:471-480; copy in backend/src/patch-club/club.rules.ts:87-104),
so a cycle that no longer exists renders as deleted. It has its own switches: inbox
notifyClub and push pushClub, both default on and independent of each other and of
notifyDirectMessages. In the app the row reads "Scout sent you an update" with the server's title/body, lives in the From Scout lane, and opens /patch-club (Home with patch_club off) (mobile/src/components/notifications/notificationRoute.ts:26,36,68); Settings has a "Patch Club" switch for each of notifyClub and pushClub, only with the flag on (mobile/app/notification-settings.tsx:386,448). Full detail and the copy table: patch-club.
The seventh type, reward_earned, is a different shape from the other six:
it has no actorId (rewards.service.ts's notify 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. It reads as deleted
only when that reward is revoked or gone, since the Rewards page no longer lists
it then. 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. - Feedback groups by conversation, not by target
(
groupKey(),backend/src/user-notifications/user-notifications.service.ts:640). Each reply notification targets its own reply id, which on the plain key made every message its own row: one member's feedback plus four replies read as five rows and five unread in the support inbox. Thefeedbackand everyfeedback_replyrow of one feedback now collapse into a single row keyed byfeedbackId, across types (so "sent feedback" folds in too). It is led by the newest message (its type, excerpt and time), and itscountis messages, not people. The app's meta line reads5 messages · 21m ago(threadMessageCount(),mobile/src/components/notifications/notificationRoute.ts:122). The headline and the push title count distinct senders for these types, nevercount, so it is not "and 4 others". The reply rows are fetched once, before collapsing, and reused byresolveTargets(). A reply whose row is gone cannot name its conversation and stays a row of its own, inert. - 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.
Deleting a notification
The "…" is a sibling of the row's pressable, not a child
(mobile/src/components/notifications/NotificationRow.tsx), so tapping it never
opens the notification or marks it read. It opens NotificationActionsMenu: a
transparent Modal, a clear scrim that closes it on any tap outside
(notification-menu-scrim), and a Scout.GlassSurface panel hung just under
the tap (the press event's pageY; flipped above it near the bottom of the
screen), the same vocabulary as My Patches' filter menu. Menu state and the
delete action live in NotificationsScreenViewModelImpl
(mobile/src/screens/NotificationsScreen.tsx:79); the Layout stays pure.
Tapping Delete notification closes the menu and deletes at once — no
confirm dialog, because the row is only a pointer and nothing it points at is
touched. deleteNotificationMutation
(mobile/src/query/queries/notifications.ts:102) removes the group from the list
cache optimistically and drops the badge by one only if that group was
unread; on failure it restores both and the screen shows the same alert a
failed post delete does, "Couldn't delete / Please try again."
(NotificationsScreen.tsx:84). Mark-read, mark-all-read and delete share one
optimistic shape, inboxMutation() (notifications.ts:52).
On the server, DELETE /api/notifications/:id →
UserNotificationsService.deleteGroup
(backend/src/user-notifications/user-notifications.service.ts:519) resolves the
id to its group with the same helper markRead uses, groupOf() (:549), and
hard-deletes every row in it: (userId, type, targetType, targetId), or for
feedback every row the conversation raised for this user
(feedbackThreadTargets(), :657, the same definition opening the thread uses
to mark it read). One
visible row stands for several Notification rows ("riverbend and 3 others"), so
deleting only the newest would bring the row straight back on the next fetch,
one actor shorter. The badge needs no extra work: unreadCount() counts groups
over the same list, so a deleted group simply is not there.
An id that is missing or belongs to someone else is a 404 that deletes
nothing; the two answers are identical, so the 404 confirms nothing about
another account's inbox. (Mark-read stays a silent no-op for the same cases.) A
malformed id is treated as missing before any query: notifications.id is a
Postgres uuid and Prisma throws on anything else, which used to surface as a 500
from POST /:id/read; groupOf() now rejects a non-uuid for both routes.
Delivery
There is no websocket and 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 in-app delivery
latency. Remote push (push-notifications) can reach the
phone sooner; when one arrives while the app is open, PushWatcher invalidates
these queries so the list and badge refresh at once.
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. An inbox ROW never takes a URL from the server, so
nothing an API returns can become a path the row navigates to blindly.
One bounded exception, for a PUSH only (added 2026-10-19): a direct
message whose CTA route is in the backend constant PUSH_OPENS_CTA_ROUTES
(backend/src/messages/message-cta-routes.ts) carries that CTA's path in its
push payload as href, and pushRoute() opens it instead of /message/<id>
(mobile/src/push/pushRoute.ts). Today the only such route is the patch
celebration, /celebration?patchId=[id], sent by the admin "Unlock a patch"
grant. It is not blind: the app follows href only when it names a screen in
its own deep-link registry (isRegisteredRoute() against DEEP_LINK_ROUTES),
and anything else, including every build that predates the field, opens the
message. The inbox row for the same message still opens the message. Because
the push tap skips the message (and marks the row read), the admin grant puts
any personal note in the message's preview, which is the push body, so the
note is read on the lock screen. GET /api/notifications also returns
pushHref on each group (it is resolved in the shared read path), but
notificationRoute() never reads it; only pushRoute() does. Removing a route from
PUSH_OPENS_CTA_ROUTES sends those pushes back to the message with no app
release.
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)
feedback_received / feedback_reply / scout_reply
→ /feedback-thread/<feedbackId>
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. It also
takes an optional preview (the inbox snippet and push body) and an optional
cta { label, route, param }, whose route must be in MESSAGE_CTA_ROUTES
or it throws BadRequestException
(backend/src/messages/messages.service.ts:95-125). Its first real caller is
the admin "Unlock a patch" grant (backend/src/patch-grants/, see
admin-and-content-ops), which sends the fixed
"{name} is unlocked" message with a See your patch button to
/celebration?patchId=<id>. 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.
Its second caller, added 2026-10-25, is the admin route
POST /api/admin/users/:userId/messages
(backend/src/messages/direct-messages-admin.controller.ts:15-25 →
MessagesService.adminSendDirect, backend/src/messages/messages.service.ts:145-149).
It takes the admin's own { title, body, preview? } (SendDirectMessageDto,
the composer's limits), and returns 404 User not found before writing
anything for an unknown id. The recipient is only ever the route param.
Because it goes through sendDirect, it raises the bell row and the push
(pushFor): push title "Scout", push body the preview, or the start of the
body when there is no preview (excerptOf, see Target enrichment). There is no admin UI
for it. Its one user is the owner-run CLI npm run messages:send-direct -- <items.json> (backend/scripts/send-direct-messages.ts). Anything that
messages a member must go through sendDirect. Writing broadcast_messages
/ notifications rows directly creates the message and the bell row but skips
pushFor, so no phone is ever buzzed.
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(plusnotifyClubfor the Patch Clubclubtype,schema.prisma:832, which has its own switch).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/readDELETE /api/notifications/:id— deletes the whole group the id belongs to, for the caller only;200 { ok: true }, or 404 for a missing, foreign or malformed id (see Deleting a notification above)GET /api/notifications/preferences,PATCH /api/notifications/preferences
Admin, AdminGuard:
POST /api/admin/users/:userId/messages— sends that member a direct message{ title, body, preview? }throughsendDirect(bell row + push).201 { id }, 404User not found, 400 on a missing title/body. Used bynpm run messages:send-direct; no admin UI.POST /api/admin/announcements— publishes a pinned official post to the community general board. Whatnpm run release:announcecalls (no longer run for release notes; see What's new).
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,adminSendDirect, and the recipient-scopedliveWhere.backend/src/messages/direct-messages-admin.controller.ts— the admin send-one-member route;backend/scripts/send-direct-messages.ts— the CLI that posts a batch to it on prod (stops at the first failure, reports who was sent).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 three-way status 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, nine states. Message bodies, poll votes and like counts 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. It includes aloadingstate (since 2026-10-31). Its header names the states it refuses to build: error (the screen has none, see Edge cases), read vs unread and signed-out (identical pixels either way).mobile/src/components/notifications/— row, skeleton, route mapper, andNotificationActionsMenu.tsx(the row's "…" menu).mobile/src/components/community/mentions.ts— mention matching and insertion.mobile/research/notifications-lab.html— the four layout variants; C won. Its "Row menu (C)" state (?state=menu) is the design source for the "…" menu.
Configuration and flags
| Flag | Type | Default | Governs |
|---|---|---|---|
notificationsEnabled (mobile persisted store) |
positive, user-controlled | true |
The local patch-unlock banner only. |
tripRemindersEnabled (mobile persisted store) |
positive, user-controlled | true |
The local trip notifications (leave-by, patch ahead) 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 — unless the notification was deleted.
vote()upserts, so the trigger re-runs on every re-vote; the dedupe index is what makes "one notification per person per thing" a database guarantee rather than a convention. Deleting the group removes the rows that index matches, so a fresh like, reply or message on the same thing after a delete is a new notification (and a new push, if push is on for that type). Pinned byuser-notifications.service.db.spec.ts("lets a fresh event after a delete notify again"). - A deleted target renders but is inert — "This content is no longer available", dimmed, not pressable. Its "…" still works, so it can be cleared.
- A delete made on another device within the list's one-minute stale window still shows here until the next fetch; deleting it again answers 404, which the app reports as "Couldn't delete" before the refetch removes the row.
- 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 bell 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 is no-reply (2026-10-10). It used to carry a comment
thread because
messages-socialwas left in place, which let a user reply to Scout by accident — andcreateCommentaccepted a comment on ANY message id, whoever it was addressed to. The screen now ends in a "can't be replied to — Send feedback" footer, andMessagesSocialService.createCommentthrows 403. The read/like/delete comment routes anduseMessageThreadremain wired but unused by the app (see Open questions). - The From Scout lane holds
direct_messageandscout_reply(FROM_SCOUT_TYPES,mobile/src/components/notifications/notificationRoute.ts:30).reward_earnedis headlined "Scout" too but stays in Activity. - The message screen has a loading state (since 2026-10-31) but NO error
state.
MessageScreenfinds its message withmessages.find((m) => m.id === id)overuseMessages().messages.MessageScreenStatusis'loading' | 'not-found' | 'ready': it shows the page-loader skeleton (message-detail-loading) while the message may still arrive, meaning a session exists whose access token has not landed yet, or an inbox fetch is in flight (buildMessageScreenData'spendinginput).- Why the loading state exists: cold-start pushes. A push tapped with the app closed lands here at once, but AuthProvider never persists the access token, so it only exists once the launch refresh lands (1-4 s on a phone network). Until 2026-10-31 the screen showed "Message not found" for that whole window. On the 2026-10-26 review ask, 3 of the 10 people who tapped the push saw it and left before the message loaded, so it was never shown and never marked read. Fixed in the app; builds before the fix still do it.
- Still collapsed to "Message not found": a failed inbox fetch (a reader offline after the token landed is told the message was deleted), a guest with no session, and a dead id.
- Offline on a cold start, the skeleton holds until the token refresh succeeds, because the refresh has to succeed before anything can load. The back button stays live.
- 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. The toggle itself can still sit ON against a handset with notifications denied. Since the push work the screen does show a permission banner (never asked: turn on; denied: open iOS Settings) above the PUSH TO THIS PHONE section (mobile/app/notification-settings.tsx:350), but that banner is part of the push section, not of the unlock toggle.
What this feature does NOT do
- Remote push covers inbox notifications only. See
push-notifications for what it does and does not do
(off unless
EXPO_PUSH_ENABLED=true, no batching, no quiet hours, no web). The patch-unlock banner and the trip notifications (leave-by, patch-ahead, and a Trip Calendar day's head-out reminder 10 minutes before it leaves) are local ones the app schedules for itself, never pushed, and they do not touch the network. - Nothing is real-time in the app. No websocket, no SSE, no background refresh. Without a push, up to a minute can pass before a new notification appears, and only while the app is open. A push is best effort and does not replace the inbox.
- An inbox row never opens anything but the message. Only the PUSH of a
message whose CTA route is in
PUSH_OPENS_CTA_ROUTES(today: the patch celebration) goes straight to the CTA, and only to a path in the app's deep-link registry. There is no general "server tells the app where to go". - There is no admin screen for writing one member a message in your own
words. The admin UI's only direct-message sender is "Unlock a patch" (a fixed
template). Free-text direct messages exist only as the API route
POST /api/admin/users/:userId/messages, driven by the owner-runnpm run messages:send-direct. There is no bulk "message everyone", and nothing schedules one. - There is no user-to-user messaging. Direct messages are admin→user only and no-reply. The only two-way conversation is a user ↔ Scout feedback thread the user starts by sending feedback (feedback-conversations); a member still cannot message another member.
@-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", and no digest or email summary.
- Deleting is one row at a time, permanent, and has no undo. There is no bulk select, no "clear all", no swipe-to-delete, no confirm step and no trash to restore from: the rows are hard-deleted on the server.
- Deleting a notification deletes only the notification. The post, comment, direct message, feedback thread or reward it points at is untouched, and so are the member's notification preferences. A push that already reached the phone is not recalled. A new reply, like or message after a delete raises a new notification.
- Announcements have no draft state.
release:announcepublishes immediately; a community post is visible the moment it exists. (Release notes now publish to What's new withrelease:notesinstead.) - 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/messages/__tests__/direct-messages-admin.db.spec.ts(5) — the admin route over HTTP with production validation: message + bell row + a push whose body is the preview (falsified 2026-10-25 by swapping in direct row writes: the push assertion fails), push switch off = no push, unknown user 404 with nothing written, 400s, and a bodyuserIdignored.scripts/__tests__/send-direct-messages.spec.ts(3) — the batch CLI: route and payload per item, stop-at-first-failure reporting, empty list.src/user-notifications/__tests__/user-notifications.service.db.spec.ts(49; this line said 21 until 2026-10-10, long after the file had grown) — self-action suppression, dedupe including the NULL-actor case, grouping boundaries, read-state across a group (and a malformed id being a no-op, not a throw), deleting a whole group by any of its row ids while unrelated rows and the badge follow, another user's id and a missing or malformed id being NotFound with nothing deleted, re-notifying after a delete, 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.service.spec.ts—deleteGroup's query shape against a recorded Prisma: the lookup scoped to the caller, the delete scoped to the whole group, a malformed id never queried.api-tests/notifications-guards.api.spec.ts— every inbox route 401s anonymously, includingDELETE /:id; the owner's delete removes the group, another account's id is a 404 that deletes nothing, a malformed id is a 404. Registered inapi-tests/route-registry.ts.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(18) — the variant-C lane with its paired opposite branch, grouped upvotes, empty, error, and the inert deleted row. A grouped feedback conversation names its one sender and reads5 messages · 21m ago(also in its spoken label); its paired opposite, a single-message conversation, shows no count. Falsified against the pre-change row and copy. Deleting: the "…" opens the menu without navigating or marking read, Delete removes the row at once and it stays gone after the refetch while the other rows remain, the DELETE names the right id; the paired failure path (the server and the refetch both failing) puts the row back and shows the alert; the From Scout lane deletes too; the scrim closes without deleting; a dead row still offers the menu. Falsified by removing the rollback, the optimistic removal, the alert, and by making the "…" also open the row — each fails a named test. 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; the file also now covers the PUSH TO THIS PHONE section (see push-notifications). 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(5) — the direct-message detail screen: the seeded message, plus unhappy paths (an id in no fetched list, a signed-out guest with no fetch attempt, and a message with neither poll nor CTA). The cold-start regression test renders a session whose token has not landed: it must show the skeleton and not "Message not found". It then lands the token and must show the message and post exactly one read. Its fixture answersread: trueonce the read is posted, like the server. Verified failing against the pre-fix screen ("Unable to find message-detail-loading").screen-tests/screen-mocks.test.tsxrenders themessagegallery states,loadingincluded.hook-tests/useNotifications.test.tsx(7) — including guests being enabled, anddeleteNotificationMutation: the badge drops for an unread group, does not move for a read one (paired), and both row and badge are restored on failure.src/components/notifications/__tests__/notificationRoute.test.ts(29, withthreadMessageCountcases for grouped conversations),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.