Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPush NotificationsPush Notifications (FCM / APNs via Expo)

Push Notifications (FCM / APNs via Expo)

Linear epic: FIT-170 Status: Shipped. Powers workout-assigned, new-message, new-comment, announcement, and class-reminder push. Last reviewed: 2026-09-27

What

Mobile push notification fan-out using Expo’s push service (which fronts FCM for Android and APNs for iOS). Device tokens are registered per user, kept current via last_seen_at, and soft-deleted on sign-out / invalid receipts. Send paths are queued via BullMQ with retry; receipt fetching happens 15min after send so we can clean up DeviceNotRegistered tokens.

Why

Real-time mobile engagement: a workout drop, a coach reply, a gym announcement, the T-30min class reminder. Push is the primary attention channel for active members.

Personas

PersonaSurfaceCapabilities
MemberMobile appReceives push; toggles per-category prefs
System (other modules)API servicesCall PushNotificationsService.notifyUser(s)

Capabilities

  • Token register / revoke — POST /devices/register upserts on expo_push_token (refresh owner + lastSeen on re-install or sign-in, and record the EAS project the token was minted under); DELETE /devices/:token revokes (caller-owns gated).
  • Categories — workoutAssigned, newMessage, newComment, announcement, bookingUpdate, classReminder, checkInReminder, automation, billing, trials, scoreReactions (canonical list in @taikan/shared/lib/schemas/notification-prefs.ts). billing covers the member billing notices: debt opened, last day to pay, plan cancelled for non-payment, card expiring in 14 / 7 / 1 days, card unusable at renewal — see payments › Member notices. It appears in every prefs UI automatically, labelled from pushNotifications.categories.billing. trials is staff-only: the coach’s brief about an hour before a class with trials (push only, route /(tabs)/schedule/[id], data { id, organizationId }) — see Trials › Coach brief push. The mobile settings screen hides it from non-staff. scoreReactions is the batched digest of reactions on a member’s scoreboard score — see scoreboard.
  • Which toggle gates which email — apps/api/src/notifications/email-types.ts. Senders read prefCategoryFor(type) rather than naming a category inline; a null there means the mail is always sent (payment receipt, expiry warning, welcome). CARD_EXPIRING, CARD_UNUSABLE and DEBT_OPENED, DEBT_LAST_DAY, DEBT_CANCELLED are gated by billing.
  • Per-category × per-channel opt-out — JSONB column notification_prefs.prefs, default opt-in. Channels push, email.
  • Notification prefs API — GET/PATCH /users/me/notification-preferences.
  • Fan-out APIs — notifyUser(userId, payload), notifyUsers(userIds, payload), scheduleNotifyUser(userId, payload, sendAt) (with BullMQ delay).
  • Processor — buckets by Expo project, chunks via expo.chunkPushNotifications (max 100 per HTTP call), sends, collects ticket IDs, schedules a 15min-delayed check-receipts follow-up job.
  • Receipt processor — fetches by ticket id, soft-deletes tokens reporting DeviceNotRegistered.
  • Project-aware delivery — Expo rejects an entire request whose messages span two Expo projects, and an audience can hold tokens from more than one container EAS project, so a mixed audience sent as one request loses every push. The processor sends one request per project and isolates unknown-provenance tokens one per request. See behavior.md.

Capabilities (gaps)

  • Web push not implemented (PWA infra exists but no service-worker fan-out).
  • No per-org pref override (all prefs are per-user).
  • No quiet hours / DND.
  • Compliance form issuance does NOT push yet — see ../forms/README.md gaps.

Source code

  • API: apps/api/src/push-notifications/
  • DB: libs/db/src/lib/schema/device-tokens.ts, libs/db/src/lib/schema/notification-prefs.ts
  • Shared: libs/shared/src/lib/schemas/notification-prefs.ts
  • Mobile client: taikan-mobile (separate repo) — src/hooks/use-push-notifications.ts owns permission prompt, Expo push token fetch, registration, foreground handler, and tap-to-route. See docs/architecture/mobile.md for the full lifecycle.