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
- iOS and Android. Android delivers through Firebase Cloud Messaging: the app
ships
mobile/google-services.json(Firebase projectscout-497000, wired byandroid.googleServicesFileinmobile/app.json), and Expo holds the FCM V1 service-account key (EAS credentials, never in the repo). Android pushes go out on the app's owndefaultchannel at HIGH importance with FCM high priority (mobile/src/push/androidChannel.ts,backend/src/push/push.service.ts). Web has no push and hides the PUSH TO THIS PHONE section. - Android reaches phones with the first build that carries
google-services.json(after 1.1.105). Older Android builds register nothing. - Built and merged; delivery is off unless
EXPO_PUSH_ENABLED=true, and it needs a new app build to reach phones. Token registration is new native-facing behaviour, so it does not arrive by OTA. - Backend delivery: flagged off by default.
EXPO_PUSH_ENABLEDmust equal the stringtrue(backend/src/push/expo-push.client.ts:36-38). Unset, empty or any other value means nothing leaves the server. - Per-type push switches: shipped, independent of the inbox switches. Upvotes and
likes default off; replies, mentions and messages default on
(
backend/prisma/schema.prisma,Profile.push*; defaults mirrored atuser-notifications.service.ts:157-172).
User-facing surfaces
- Settings, then Notifications, "PUSH TO THIS PHONE" section
(
mobile/app/notification-settings.tsx:347): four switches (Replies, Mentions, Upvotes and likes, Messages from Scout; rows at:248-252). Above them a permission banner appears only when the OS permission is not granted (:350): never asked shows "Turn on notifications" (the OS prompt); denied shows "Open iOS Settings" or, on Android, "Open Android settings" (Linking.openSettings(),:219-221). The permission is re-read on mount and on every return to the foreground (:206-216). While the permission is missing the switches still show the server's value but are dimmed and disabled. - The support account (the
SUPPORT_INBOX_EMAILprofile) sees two read-only "Always on" rows instead: New feedback and Feedback replies (:257-258,:399-400), driven by the read-onlyisSupportInboxfield. - The lock screen. Title is who or what ("alice replied to your post",
"alice and 3 others upvoted your post", or just "Scout" for messages from Scout),
body is the same excerpt the inbox row shows
(
push-message.ts:68-93). Banners about the same post or feedback thread stack together (threadId,:83). - The app icon badge equals the inbox unread count (
mobile/src/push/PushWatcher.tsx:57-71). - A tap opens the same screen the inbox row opens, including the tap that
cold-starts the app (
mobile/src/trips-v2/useTripNotificationActions.ts:41-46). It also marks the inbox row read (the payload carriesnotificationId, the group's id) through the samemarkNotificationReadMutationthe inbox uses, so the badge clears; signed out, it just routes, and a failed mark-read never blocks navigation.
How it works
- Register.
PushWatcher(mounted once at the root,mobile/app/_layout.tsx:464) callsregisterPushToken()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 thenPUTs the Expo token to/api/push/token(mobile/src/domain/api.ts:410-414). - 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. - 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. - Deliver.
PushService.sendloads every token for the user, posts one batch tohttps://exp.host/--/api/v2/push/sendwith a 5-second timeout, and never throws (push.service.ts:37-61,expo-push.client.ts:40-57). Tickets that reportDeviceNotRegisteredhave their token deleted (push.service.ts:55-57). Top-level Expoerrorson a 200 are logged (expo-push.client.ts:54-55). - 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). - Sign out.
signOutcallsunregisterPushToken()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
PushToken(backend/prisma/schema.prisma, tablepush_tokens):token(unique, max 255),userId(cascade delete with the profile),platform(iosorandroid),createdAt,lastSeenAt. Migrationbackend/prisma/migrations/20261011120000_push_notifications/migration.sql.Profile.pushReplies,pushMentions,pushLikes,pushMessages— positive booleans, independent of thenotify*inbox columns. The support account's two feedback types have no column: they always push (push-message.ts:22-23).
API surface
PUT /api/push/token{ token, platform }— register or move this device to the caller.DELETE /api/push/token{ token }— remove it, scoped to the caller; a token owned by someone else is a silent no-op (backend/src/push/push.controller.ts:16-26,push-tokens.service.ts:25-28).JwtAuthGuard, so guests can register too (they can be replied to). The user always comes from the verified token, never the body.- Notification preferences (
GET/PATCHon the existing preferences route) now also carrypushReplies,pushMentions,pushLikes,pushMessages(writable,user-notifications.controller.ts:16-19) and the read-onlyisSupportInbox(user-notifications.service.ts:149,:528).
Key files
backend/src/push/—push.module.ts,push.controller.ts,push-tokens.service.ts(register / unregister / tokensFor / forget / hasAny),push.service.ts(hasDevices,send),expo-push.client.ts(the one HTTP call).backend/src/user-notifications/push-message.ts— which switch governs which type, and the copy. Notbackend/src/notifications/, which is operator Pushover.backend/src/user-notifications/user-notifications.service.ts:228—pushFor().backend/src/feedback/support-inbox.module.ts— shared so the preferences view can ask whether the viewer is the support account.mobile/src/push/pushRegistration.ts,PushWatcher.tsx,pushRoute.ts.mobile/src/trips-v2/useTripNotificationActions.ts:41— the single tap listener (push first, analytics sourcepush; local notifications fall through).mobile/app/notification-settings.tsx— the settings section.
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
- Push and inbox switches are independent. Muting replies in the inbox does not
stop their push, and the reverse. A muted-in-inbox type can still push because the
group is resolved across all types (
user-notifications.service.ts:222-226). - A deleted target pushes nothing (
group.deleted,:245), and self-actions never create a row to begin with. - Grouping. If a post already has an unread group, the push is titled for the whole group ("alice and 2 others upvoted your post") — the same wording as the inbox row.
- Permission is read, never requested, by registration. Someone who skipped onboarding's ask and has never started a trip gets no token until they use the banner in Settings.
- Failure never reaches the caller. Missing devices, an Expo outage, a timeout or
a bad ticket are logged and swallowed (
push.service.ts:58-60,user-notifications.service.ts:252-254); the comment or reply that caused the push has already succeeded. - Dead tokens are pruned only when a send discovers them (see below).
- The badge can lag. The server sets it when it pushes; the app corrects it when it next reads the unread count.
- Sign-out unregister is best effort. If it times out or fails, the server still moves the token to whoever registers it next.
What this feature does NOT do
- It does not push anything that is not an inbox notification. Patch unlocks, trip reminders (leave-by, patch ahead, head-out) and everything else local stay local. Missions and What's New are not inbox types and are not pushed either.
- No upvote batching or digest. Every upvote or like that creates a row can push (if the member turned upvotes on, which is off by default).
- No quiet hours or do-not-disturb schedule. The only controls are the four switches and the OS.
- No Expo receipts polling. The only token cleanup is the send-time
DeviceNotRegisteredticket error. A token that Expo accepts but later fails to deliver is not detected. - No push on web. Registration returns nothing on web and on simulators or emulators; web hides the push switches.
- One Android channel. Every push and local notification shares the
defaultchannel ("Notifications"), so Android's per-channel controls cannot split replies from messages; the in-app switches do that. - Nothing is sent unless
EXPO_PUSH_ENABLED=true. It is fail-closed, so tests, CI and local databases never post to Expo. - No new permission prompt. It uses onboarding's and trip start's ask. Settings can trigger the OS prompt only if the user never answered, and otherwise points to the OS settings.
- No email or SMS fallback, and no push to members who have not signed in (guests count as signed in).
- No "we'll ping you near a patch". Nothing location-based is pushed from the server.
- It is not real-time delivery you can rely on. Push is best effort; the inbox remains the record.
Tests that cover it
- Backend:
backend/src/push/__tests__/expo-push.client.spec.ts,push.service.spec.ts,push-tokens.db.spec.ts;backend/src/user-notifications/__tests__/push-message.spec.ts(pins every string against the mobile copy) anduser-notifications.service.db.spec.ts(switches, independence from inbox switches, no inbox work without devices);backend/api-tests/push-token.api.spec.ts(routes, guests, auth). - Mobile:
mobile/src/push/__tests__/pushRegistration.test.ts(simulator and web skip, Android platform and channel-before-token, read-only permission, 3s bound),pushRoute.test.ts;mobile/hook-tests/PushWatcher.test.tsx,mobile/hook-tests/useTripNotificationActions.test.tsx;mobile/src/providers/__tests__/AuthProvider.test.tsx(unregister first on sign-out);mobile/screen-tests/notification-settings.test.tsx(banner states, dimmed switches, support account rows, Android wording, web hides push);mobile/src/services/__tests__/notifications.test.ts(Android channel before the prompt).
Open questions
- The real round trip is verified on both platforms in prod: iOS through APNs, and
Android on 2026-10-12, when a push to a prod Android token got an
okExpo receipt and arrived on the phone. Still not verified on a device: the lock screen stacking bythreadId, and the icon badge, since delivery is off in every test environment. - Testing push with an upvote or like sends nothing by default.
pushLikesis off for every account until the member turns it on, so the inbox row is written but no push goes out. Test with a reply or mention, or turn the switch on first. - Whether
EXPO_ACCESS_TOKENis required depends on the Expo project's security setting, which this repo cannot see.