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
| Persona | Surface | Capabilities |
|---|---|---|
| Member | Mobile app | Receives push; toggles per-category prefs |
| System (other modules) | API services | Call PushNotificationsService.notifyUser(s) |
Capabilities
- Token register / revoke —
POST /devices/registerupserts onexpo_push_token(refresh owner + lastSeen on re-install or sign-in, and record the EAS project the token was minted under);DELETE /devices/:tokenrevokes (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).billingcovers 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 frompushNotifications.categories.billing.trialsis 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.scoreReactionsis 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 readprefCategoryFor(type)rather than naming a category inline; anullthere means the mail is always sent (payment receipt, expiry warning, welcome).CARD_EXPIRING,CARD_UNUSABLEandDEBT_OPENED,DEBT_LAST_DAY,DEBT_CANCELLEDare gated bybilling. - Per-category × per-channel opt-out — JSONB column
notification_prefs.prefs, default opt-in. Channelspush,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-delayedcheck-receiptsfollow-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.mdgaps.
Related
../messages-comments/— callsnotifyUseron each new message/comment.../announcements/— callsnotifyUsersfor fan-out.../notifications/— parallel email channel.../scheduling-bookings/— class reminder T-30min usesscheduleNotifyUser.../scoreboard/— reaction digest usesnotifyUserwith categoryscoreReactions.
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.tsowns permission prompt, Expo push token fetch, registration, foreground handler, and tap-to-route. Seedocs/architecture/mobile.mdfor the full lifecycle.