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 }
│ │
│ ▼
┌──────────────────────────────────────────┐
│ Push processor (async, chunked) │
│ - chunk by 100 via Expo SDK │
│ - sendPushNotificationsAsync │
│ - per ticket: │
│ status='ok' → collect id │
│ status='error' + 'DeviceNotRegistered' → revokeToken │
│ status='error' + other → log │
│ - enqueue check-receipts in 15min │
└──────────────────────────────────────────┘
│
▼
(15 min later) check-receipts:
fetch by ticketIds; soft-delete tokens for DeviceNotRegistered errorsInvariants
| 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 |
Golden paths
Mobile sign-in registers a device
- App boots; gets Expo push token.
POST /devices/registerwith{ expoPushToken, platform }.- 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.
Receipt cleanup
- 15min after send,
check-receiptsjob runs. - Fetches receipts from Expo by ticket id.
- For any with
DeviceNotRegistered, soft-delete the corresponding token (matched back via the originalmessages[i].to).
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 |
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 |