Skip to content

feat(notification): deliver notifications to phones as well as sockets - #279

Open
aquie00t wants to merge 1 commit into
mainfrom
feature/push-notifications
Open

feat(notification): deliver notifications to phones as well as sockets#279
aquie00t wants to merge 1 commit into
mainfrom
feature/push-notifications

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #274. Independent of #278 — both branch from main.

Summary

A realtime socket only exists while the app is in the foreground; both mobile platforms close it the moment the app is backgrounded. Push is the second transport, through Expo — which owns the FCM credentials, and the APNs ones when iOS arrives, all behind PushPort so a direct FCM adapter is a sibling file rather than a rewrite.

The seam is the emit, not the call sites. Every notification in this codebase is two lines: store the row, emit new-notification. PushNotifyingRealtimeService is registered as realtimeService, delegates to the socket transport and dispatches SendPushNotificationUseCase behind it. So the thirteenth notification somebody writes is delivered to phones without them knowing push exists, and a dozen use cases stay untouched. The socket is never held up — it is written first, and the push is fire-and-forget behind it.

DeviceToken.token is unique across the table, not per user. A phone handed to somebody else, or an account switched inside the app, produces the same token under a new user; anything other than a move leaves one person's notifications arriving on another person's screen. POST /devices registers (call it at every launch — tokens get reissued, and it is what keeps the row from being swept), DELETE /devices retires one, scoped to the owner because a push token travels through the app and is not a secret.

Direct messages are not pushed at all. Their text is encrypted at rest, and putting even a truncated preview in a push payload would route it through Google's servers and undo that. Chat events share the realtime channel and the decorator ignores them by event name. The payload that does go out is ids and a type — nothing anybody wrote.

Copy is Turkish and English (push-copy.ts), chosen from the device's locale rather than the profile's feed languages: a notification is read on a lock screen that is already in one language.

Root cause

There was no way to reach a phone that was not being looked at, which makes an app that relies on notifications — messages, mentions, replies — largely useless in the background.

Dead tokens

Two mechanisms, because neither covers the other:

  • Expo reports DeviceNotRegistered in the ticket for that message; those rows are deleted as they are reported.
  • A phone that was reset, lost or abandoned reports nothing, so a nightly sweep drops registrations not seen for DEVICE_RETENTION_DAYS (90). The app re-registers at every launch, so age is a sound signal here in a way it would not be for something a user does once.

Not done, and stated plainly: Expo's receipts, which catch tokens that fail later at FCM rather than at ticket time. The retention sweep covers the same ground more slowly. It belongs in the roadmap rather than in this PR.

Tests

  • Unit, 1459 passing (13 new): SendPushNotificationUseCase — one message per device, the device's own language, deep-link ids and only ids in the payload, nothing sent with no devices or an unnameable issuer, rejected tokens deleted, the table untouched when all are accepted, and a provider failure swallowed rather than surfaced. PushNotifyingRealtimeService — always emits on the socket, pushes a notification event, never pushes a chat event, ignores a malformed notification, and cannot leak an unhandled rejection.
  • E2E tests/e2e/device/device.test.ts: register, re-register, a device moving between accounts and the previous owner losing the ability to retire it, unknown platform, empty token, no session.
  • tsc -p tsconfig.build.json --noEmit, eslint, prettier --check clean.

Rollout

PUSH_ENABLED is false by default, which swaps in a service that sends nothing — devices register, nothing is delivered. So this merges and deploys with no Expo project in existence. Turning it on later needs PUSH_ENABLED=true and, if the Expo project has push security enabled, EXPO_ACCESS_TOKEN.

Migration 20260911000000_add_device_tokens adds one table and one enum, with a cascade on the user so a purged account cannot leave a live push token behind.

AI Asistan: Opus 5

A realtime socket only exists while the app is in the foreground - both
mobile platforms close it the moment the app is backgrounded - so today a
notification reaches a phone only if somebody happens to be looking at it.

Every notification here follows the same two lines: store the row, emit
new-notification. PushNotifyingRealtimeService wraps that emit and dispatches
the push behind it, so notifications written later are delivered without
anybody remembering to wire them up, and a dozen use cases stay untouched.

DeviceToken.token is unique across the table rather than per user. A shared
phone, or an account switched inside the app, produces the same token under a
new user, and anything other than a move leaves one person's notifications
arriving on another person's screen.

Direct messages are deliberately not pushed. Their text is encrypted at rest
and a preview in a push payload would route it through Google's servers; the
payload carries ids and a type and nothing else.

Dead tokens go two ways: Expo reports DeviceNotRegistered and those rows are
deleted at once, while a phone that was simply abandoned is caught by a
nightly sweep on lastSeenAt. PUSH_ENABLED is off by default, so devices
register and nothing is delivered until there is a project behind it.
@aquie00t
aquie00t force-pushed the feature/push-notifications branch from 8ccf84f to 1980a09 Compare September 5, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Push notifications for the mobile app

1 participant