The backend sends push notifications to the trainee app through Expo’s push service. There is no direct APNs or FCM code in the backend. Expo holds those credentials. The tables involved (TraineePushToken, NotificationTypeConfig, StudioNotificationConfig) are described in the data model section of these docs.

Registering a device

In the app, uploadPushToken in mobile/src/lib/notifications.ts:
  1. Creates the Android notification channel coach-messages first. A push that arrives before its channel exists lands in Android’s silent default channel and stays there.
  2. Reads the EAS project id from Constants.expoConfig.extra.eas.projectId. With no project id it stops.
  3. Calls Notifications.getExpoPushTokenAsync({ projectId }).
  4. Posts the token to the backend.
Validated by pushTokenBody: token is 1 to 300 characters, platform is ios or android and optional. DELETE /v1/trainee/push-token with { "token": "..." } removes it. Details on the app side:
  • registerPushToken runs on every app start and is a no-op after the first success, on denied permission, and on simulators.
  • The latch pushRegistered lives in module scope and outlives a logout. resetPushRegistration clears it so the next trainee to sign in on the same install registers their own token.
  • retryPushRegistrationSilently retries a failed upload at most once a minute and never prompts for permission.
  • Permission prompting is separate from registration. A trainee who has not been asked, or who declined, is left alone.
The app has three Android channels: coach-messages for server pushes, rest-timer and workout-reminders for notifications the app schedules locally.

Sending

Call sites use sendTraineeNotification or the createTraineeNotifier wrapper from trainee-push.ts. They pass a studio, a NotificationConfigType and a list of recipients.

Config resolution

resolveNotificationConfig picks the studio’s StudioNotificationConfig row for the type, then the global NotificationTypeConfig row, then the code defaults in NOTIFICATION_CONFIG_DEFAULTS. A type the studio switched off returns early with disabled: true.

Recipients

Tokens are loaded only for trainees who are not deleted, in studios that are not archived. A recipient with no token is reported in noDevice. The code logs loudly when nobody has a device, because a silent zero is indistinguishable from a successful send.

Message

Each device gets one ExpoPushMessage: target comes from the recipient or from NOTIFICATION_TARGETS: home, form, activeWorkout, training, nutrition, subscription or profile. The app routes on it in mobile/src/lib/notificationRouting.ts.
Free text a coach typed must be passed as bodyOverride, not as a template variable. As a variable it only survives while the studio’s body template is exactly the message token, and the settings screen exists to change that template. With bodyOverride the coach’s words are always what is sent.

Result

TraineePushResult separates outcomes that look alike in a bare count:

Tickets are not delivery

sendExpoPush posts to Expo’s send endpoint in batches of 100 and returns one outcome per message, in order.
  • A ticket with status: ok means Expo accepted the message. It carries a receipt id. It does not mean APNs or FCM delivered anything.
  • A ticket error is Expo refusing the message outright. The code is in details.error.
  • If the HTTP request itself fails, or Expo returns fewer tickets than messages, the outcome is TransportError (TRANSPORT_ERROR). This is kept separate from Expo’s own codes so a network blip can never be mistaken for a dead device and cost a trainee their token.
Only DeviceNotRegistered prunes a token.

Receipts

A broken APNs key or a mismatched FCM sender still produces a healthy ticket. The failure only appears in the receipt, which Expo makes available later. installPushReceipts(ctx) is called once at boot and installs the scheduler. Push runs from many call sites that should not need to know a queue exists, so schedulePushReceipts is a module-level function that does nothing until a scheduler is installed. That also lets scripts and tests send without Redis. Queueing is deliberately not awaited: a coach’s send must never wait on Redis, and a queueing failure costs visibility, not the push. applyPushReceipts produces a PushReceiptSummary:
  • delivered, failed, pending and pruned counts. A ticket Expo has no verdict for yet is pending and is not retried.
  • errors, a histogram of error codes.
  • byPlatform, the same counts split by ios, android or unknown. An error histogram alone cannot say whether InvalidCredentials is the APNs key or the FCM service account.
  • samples, Expo’s message for the first 5 failures. Device tokens inside those messages are masked before logging.
Codes worth recognising: When a studio reports that pushes are not arriving, read the receipt worker’s log lines before anything else. A clean send log proves nothing.

Notification settings

Routes from notification-configs.routes.ts: The rest timer, idle workout and unfinished workout reminders are scheduled on the device. The server only supplies their wording through the trainee read route. Plan update notifications are throttled: PLAN_UPDATE_THROTTLE_HOURS is 6, tracked with Program.lastPlanNotifiedAt.

Muting

Client.mutedNotificationGroups holds the groups a trainee turned off: CHECK_INS, FORMS, PLANS, WORKOUT_REMINDERS, SUBSCRIPTION. COACH_MESSAGES is locked and cannot be muted. The check is isNotificationMuted(type, mutedGroups, formType). For FORM_SENT and FORM_REMINDERS the group depends on the form: a check-in form belongs to CHECK_INS, any other form to FORMS. A muted push is not a failure. Callers that claim a row before sending (for example form reminders) treat muted as handled.

Form reminders

FORM_REMINDERS messages are sent by the engine in apps/core-api/src/modules/update-forms/update-forms.reminder.ts. Each configured message carries an offsetDays (from -3 to 3 around the due day) and a time. The FormReminderSend table, unique on (instanceKey, messageId), guarantees each message goes out at most once per form instance.

Debugging checklist

  1. Is there a TraineePushToken row for the trainee? If not, the app never registered: permission denied, no EAS project id in the build, or a simulator.
  2. Did the send return the trainee in noDevice, muted, skipped or disabled?
  3. Did the ticket fail? Look for “expo push: ticket error” in the logs.
  4. Fifteen minutes later, what did the receipt check log? Look at byPlatform and samples.
  5. On Android, does the app’s coach-messages channel exist and is it not set to silent by the user?