Scout — Full Product Context → feature documentation

Push notifications

Remote push to a member's phone for the same events that land in the notification inbox: replies, mentions, upvotes, messages from Scout.

Summary

Scout can buzz a member's phone for the events that already land in the notification inbox: replies to your post or comment, @-mentions, upvotes and likes, messages from Scout (including a reward landing), and Scout's replies to your feedback. A push is a mirror of an inbox row, not a separate channel: it is sent only at the moment UserNotificationsService.create() has written the row (backend/src/user-notifications/user-notifications.service.ts:217), it says what the inbox row says (backend/src/user-notifications/push-message.ts:47), and a tap opens what the inbox row opens (mobile/src/push/pushRoute.ts:3).

Delivery goes through Expo's push service. It is off unless the backend sets EXPO_PUSH_ENABLED=true, and it asks for no new permission: it uses the notification permission onboarding and trip start already request.

The unlock banner and trip reminders are still local notifications the app schedules for itself. They are not part of this feature.

Status

User-facing surfaces

How it works

  1. Register. PushWatcher (mounted once at the root, mobile/app/_layout.tsx:464) calls registerPushToken() when someone is signed in and again on every return to the foreground (PushWatcher.tsx:23-37) — that is how a permission granted in the OS settings takes effect without a restart. Registration only reads the permission; it skips web and simulators, creates the Android channel before minting a token (Android drops a push whose channel does not exist), and needs an EAS project id (mobile/src/push/pushRegistration.ts:15-23). It then PUTs the Expo token to /api/push/token (mobile/src/domain/api.ts:410-414).
  2. Store. The token is upserted by token, so whoever registered a phone last owns it (backend/src/push/push-tokens.service.ts:17-23). A shared phone therefore stops receiving the previous account's pushes.
  3. Trigger. When a notification row is created, pushFor() runs (user-notifications.service.ts:228-255): (a) the type's push switch is checked (PUSH_PREFERENCE_FOR, push-message.ts:13-24; the inbox switch is deliberately not consulted); (b) PushService.hasDevices() is asked before any inbox work, so with delivery off or no devices the extra queries are skipped (backend/src/push/push.service.ts:27-35); (c) the inbox group for that row and the unread count (the badge) are resolved; (d) void push.send(...) — not awaited, so Expo latency is never on the request that caused the push.
  4. Deliver. PushService.send loads every token for the user, posts one batch to https://exp.host/--/api/v2/push/send with a 5-second timeout, and never throws (push.service.ts:37-61, expo-push.client.ts:40-57). Tickets that report DeviceNotRegistered have their token deleted (push.service.ts:55-57). Top-level Expo errors on a 200 are logged (expo-push.client.ts:54-55).
  5. Arrive. In the foreground, a push whose payload is a valid inbox payload refreshes the notifications list, the unread count and, for feedback, that thread (PushWatcher.tsx:39-55). The icon badge is synced from the unread query, and set to 0 when signed out (:61-71).
  6. Sign out. signOut calls unregisterPushToken() first, before the session is torn down (mobile/src/providers/AuthProvider.tsx:866-867). It is bounded to 3 seconds (UNREGISTER_TIMEOUT_MS, pushRegistration.ts:34), never throws, and never blocks signing out.

The payload is untrusted on the way back in: anything without source: 'inbox', a known type and a target routes nowhere rather than to a guessed screen (pushRoute.ts:7-24).

Data model

API surface

Key files

Configuration and flags

Flag Type Default Governs
EXPO_PUSH_ENABLED positive, backend env unset, which means off Whether anything is sent to Expo. Set true in prod only; locally only when testing on a real device.
EXPO_ACCESS_TOKEN backend env, optional unset Bearer token, only needed if "Enhanced push security" is on for the Expo project (expo-push.client.ts:42-43).
pushReplies / pushMentions / pushMessages user preference on Per-type push switches.
pushLikes user preference off Upvotes and likes.

The code reads it on every send, but the droplet's .env is loaded into the environment at boot, so restart the backend after changing it. See DEPLOYMENT_GUIDE.md (Push notifications).

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions