Push Notifications — Behavior
State machines
Device token
register ──> active (last_seen_at touched, deleted_at = null)
│ │
│ ├── re-register (same token, new owner) → owner updated; deleted_at cleared
│ ├── revokeTokenForUser (sign-out) → deleted_at set
│ └── receipt processor: DeviceNotRegistered → deleted_at setNotification send
notifyUsers(ids, payload)
│
├── filterByPreferences(ids, category) (drop opted-out users)
├── fetch active device tokens for survivors
├── enqueue BullMQ job { tokens, payload, tokenProjects? }
│ │
│ ▼
┌────────────────────────────────────────────────────────────┐
│ Push processor (async) │
│ - filterDeliveryTokens → [{ token, expoProjectId }] │
│ - one bucket per Expo project, plus one bucket per │
│ unknown-project token (isolated) │
│ - per bucket: chunk by 100 via Expo SDK, send each chunk │
│ (buckets concurrent, bounded by the SDK's own limiter) │
│ - pair {ticket, token} inside the chunk that produced it │
│ - per ticket: │
│ status='ok' → collect id │
│ status='error' + 'DeviceNotRegistered' → revokeToken │
│ status='error' + other → log (no token) │
│ - every chunk failed → throw (BullMQ retries) │
│ some chunks failed → complete; the rest already went │
│ - enqueue check-receipts in 15min │
└────────────────────────────────────────────────────────────┘
│
▼
(15 min later) check-receipts:
fetch by ticketIds; soft-delete tokens for DeviceNotRegistered errorsWhy buckets. Expo answers a request whose messages span two Expo projects
with PUSH_TOO_MANY_EXPERIENCE_IDS and rejects all of it. The container app
was relinked from the FitKit EAS project to the Taikan one on 2026-08-22, so an
org with one member on an old build and one on a new build lost every push in
the fan-out (Sentry TAIKAN-BACKEND-4P). A token with no recorded project could
belong to either, so it is sent alone rather than assumed into today’s project.
Invariants
| Invariant | Enforcement |
|---|---|
expo_push_token is globally unique | DB unique |
| Re-registering moves ownership atomically | INSERT … ON CONFLICT DO UPDATE on token |
| Soft-delete preserves audit (no hard delete in app code) | deleted_at only |
| Caller-owns revoke gate | revokeTokenForUser checks userId === row.userId |
| Prefs default opt-in (missing key → enabled) | isChannelOptedOut returns false for missing keys |
| Push send paths are fire-and-forget at caller | All callers void-await enqueue errors |
| BullMQ retry: 3 attempts, exponential backoff 5s | Job options in service |
| Scheduled push resolves tokens at SEND time, not schedule time | scheduleNotifyUser snapshots tokens once; comment notes tokens may change but the trade-off is accepted |
| A ticket is only ever matched to the token from its own chunk | Processor builds {ticket, token} pairs inside the chunk loop. Flattening successful tickets and indexing back into the full message list misattributed everything after a failed chunk, revoking a token that was never rejected |
| Push tokens never appear in logs or Sentry | Ticket/chunk warnings carry codes, counts and project ids only; Expo’s details (which holds the conflicting raw tokens) is dropped in expoErrorInfo |
A stored expo_project_id is never replaced by NULL | Conflict-update omits the column unless a verified project was sent |
| An unverified project id is never stored | resolveExpoProjectId drops anything outside the binary’s known projects; the device still registers |
| No request mixes two Expo projects | Processor buckets by project and isolates unknown-provenance tokens one per request |
Golden paths
Mobile sign-in registers a device
- App boots; gets Expo push token.
POST /devices/registerwith{ expoPushToken, platform, appId?, locale?, expoProjectId? }.expoProjectIdis the EAS project the token was minted under; the server keeps it only if it matches a project that binary is known to use, and otherwise records no provenance rather than refusing the registration.- Service upserts row; if the token belongs to a different user, ownership transfers (the device was signed in as someone else previously).
Coach assigns a workout → member’s phone buzzes
- Workout-assignments service inserts row.
- Calls
push.notifyUser(memberUserId, { title, body, category: 'workoutAssigned', route: '/workout/...', data }). notifyUsers([id], ...)filters prefs, fetches tokens, enqueues job.- Processor sends via Expo; ticket id collected.
- Phone receives push; user taps; Expo router opens
route.
Member opts out of newComment push
PATCH /users/me/notification-preferenceswith{ newComment: { push: false } }.- Service merges into JSONB
prefs. - Next
notifyUsers(..., category: 'newComment')excludes this user.
Class reminder T-30min
- Booking confirmed.
- Bookings service computes
sendAt = startsAt - 30min. push.scheduleNotifyUser(userId, payload, sendAt)→ BullMQ job withdelay.- Job fires; pushes to current tokens.
Billing notice
BillingDebtNotificationsServicedecides a member must hear about a debt or a card: onSUBSCRIPTION_DEBT_OPENED(debt opened) orbilling.card_unusable, from the hourly subscription lifecycle pass (last day to pay, cancelled for non-payment), or from the daily 08:00 UTC card-expiry job (14 / 7 / 1 days).- Debt notices claim their stage on
subscriptions.debt_notice_stage(compare-and-set) before sending, so each is sent at most once per debt. It reads the member’snotification_prefsonce: email is sent unlessbilling/emailis off; push is queued only whenbilling/pushis on, andnotifyUserfilters again. push.notifyUser(userId, { category: 'billing', route: '/(tabs)/profile/payments', orgId, data: { subscriptionId, stage } })(card expiry:{ daysLeft }), with copy frompushCopy.billingDebtOpened/billingDebtLastDay/billingDebtCancelled/billingCardUnusable/billingCardExpiringrendered in the member’s locale (amounts in the org currency, pay-by day as a plain date).- A member who opted out of both channels still has the stage claimed, so the old notice is not sent if they opt back in. Operations: scheduled jobs › Subscription lifecycle.
Receipt cleanup
- 15min after send,
check-receiptsjob runs. - Fetches receipts from Expo by ticket id.
- For any with
DeviceNotRegistered, soft-delete the corresponding token, resolved through the{ticketId → token}map captured at send time from the chunk that produced the ticket.
Edge cases
| Scenario | Behavior |
|---|---|
| User has no device tokens | notifyUsers early-returns; no enqueue |
| User has only opted-out devices (all categories) | Filtered out at filterByPreferences |
| Token validity changes mid-flight (user signed out between enqueue + send) | Token still in queue; Expo will succeed if still registered; receipt may report DeviceNotRegistered → soft-deleted |
| Multiple devices per user | All tokens fetched; chunked across BullMQ payload |
| Expo HTTP failure | Logged; BullMQ retries (3 attempts, exponential 5s) |
| BullMQ unavailable | queue.add rejects → caught in .catch, logged. Push lost (no fallback) |
| Pref JSONB malformed | Defaults used (opt-in) |
| Scheduled push before send time → user uninstalls | Tokens fetched at schedule (snapshot stale by send time); send to dead tokens; receipt cleans up |
| Scheduled push after class is canceled | Caller is responsible for cancelling the BullMQ job id |
| Audience mixes old- and new-project tokens | One request per project, so a rejection can only cost its own project. A single mixed request would be rejected wholesale (PUSH_TOO_MANY_EXPERIENCE_IDS) |
Token has no recorded expo_project_id | Sent in a request of its own. Re-registration from a current build fills the column in and it rejoins its project’s bucket |
Queued job predates the column, or its payload carries no organizationId | Grouped by each token’s recorded project like any other job; tokens with no project either way are isolated. The job’s own tokenProjects snapshot is only a fallback for a token whose row has since lost its project |
| One project’s request fails, another’s succeeds | Job completes (retrying would re-deliver the successful project); the failure is logged and carried in the push_delivery Sentry context with its Expo code and project ids |
Side effects
| Action | Writes DB | Queues |
|---|---|---|
| Register device | device_tokens upsert | — |
| Revoke device | device_tokens update (deleted_at) | — |
notifyUser(s) | — | BullMQ send job |
scheduleNotifyUser | — | BullMQ send job with delay |
| Push processor | device_tokens update on DeviceNotRegistered | enqueues check-receipts (15min delay) |
| Update prefs | notification_prefs upsert | — |
Permissions
| Action | Auth |
|---|---|
| Register / revoke device | Authenticated; revoke checks ownership |
| Prefs read / write | Self only |
notifyUser (service-to-service) | Internal — no HTTP surface |
Managed members
Spec: managed-members/behavior.md §4.6, §4.7.
- A push about a child is delivered to the devices of their confirmed adults (
NotificationRecipientsService, used byapps/api/src/push-notifications/push-notifications.service.ts:162), never to the child, who has no device. - The payload carries
subjectUserId; tapping it switches the app to that child before opening the screen.