Scout — Full Product Context → feature documentation

Notifications and direct messages

Three separate things share the word 'notification' in this codebase.

Summary

Three separate things share the word "notification" in this codebase. Keeping them apart is the whole point of this document.

  1. 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 at scout://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.
  2. 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).
  3. 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)

User-facing surfaces

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:

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)

API surface

App-facing, JwtAuthGuard — guests included, unlike the old messages inbox, because guests post, comment and get replied to:

Admin, AdminGuard:

Key files

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

What this feature does NOT do

Tests that cover it

End-to-end (the only layer that compares client and server):

Backend:

Mobile:

Open questions