Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPush NotificationsPush Notifications — Behavior

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 set

Notification 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 errors

Invariants

InvariantEnforcement
expo_push_token is globally uniqueDB unique
Re-registering moves ownership atomicallyINSERT … ON CONFLICT DO UPDATE on token
Soft-delete preserves audit (no hard delete in app code)deleted_at only
Caller-owns revoke gaterevokeTokenForUser 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 callerAll callers void-await enqueue errors
BullMQ retry: 3 attempts, exponential backoff 5sJob options in service
Scheduled push resolves tokens at SEND time, not schedule timescheduleNotifyUser snapshots tokens once; comment notes tokens may change but the trade-off is accepted

Golden paths

Mobile sign-in registers a device

  1. App boots; gets Expo push token.
  2. POST /devices/register with { expoPushToken, platform }.
  3. 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

  1. Workout-assignments service inserts row.
  2. Calls push.notifyUser(memberUserId, { title, body, category: 'workoutAssigned', route: '/workout/...', data }).
  3. notifyUsers([id], ...) filters prefs, fetches tokens, enqueues job.
  4. Processor sends via Expo; ticket id collected.
  5. Phone receives push; user taps; Expo router opens route.

Member opts out of newComment push

  1. PATCH /users/me/notification-preferences with { newComment: { push: false } }.
  2. Service merges into JSONB prefs.
  3. Next notifyUsers(..., category: 'newComment') excludes this user.

Class reminder T-30min

  1. Booking confirmed.
  2. Bookings service computes sendAt = startsAt - 30min.
  3. push.scheduleNotifyUser(userId, payload, sendAt) → BullMQ job with delay.
  4. Job fires; pushes to current tokens.

Receipt cleanup

  1. 15min after send, check-receipts job runs.
  2. Fetches receipts from Expo by ticket id.
  3. For any with DeviceNotRegistered, soft-delete the corresponding token (matched back via the original messages[i].to).

Edge cases

ScenarioBehavior
User has no device tokensnotifyUsers 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 userAll tokens fetched; chunked across BullMQ payload
Expo HTTP failureLogged; BullMQ retries (3 attempts, exponential 5s)
BullMQ unavailablequeue.add rejects → caught in .catch, logged. Push lost (no fallback)
Pref JSONB malformedDefaults used (opt-in)
Scheduled push before send time → user uninstallsTokens fetched at schedule (snapshot stale by send time); send to dead tokens; receipt cleans up
Scheduled push after class is canceledCaller is responsible for cancelling the BullMQ job id

Side effects

ActionWrites DBQueues
Register devicedevice_tokens upsert
Revoke devicedevice_tokens update (deleted_at)
notifyUser(s)BullMQ send job
scheduleNotifyUserBullMQ send job with delay
Push processordevice_tokens update on DeviceNotRegisteredenqueues check-receipts (15min delay)
Update prefsnotification_prefs upsert

Permissions

ActionAuth
Register / revoke deviceAuthenticated; revoke checks ownership
Prefs read / writeSelf only
notifyUser (service-to-service)Internal — no HTTP surface