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 and direct messages from Scout land in a per-user inbox at scout://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.
  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.
  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.

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)

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.

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:

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)

API surface

App-facing, JwtAuthGuardguests 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.
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