There are two notification systems and they share nothing but the word.

Trainee push

The one entry point

Every push goes through createTraineeNotifier:
  • notify returns the number of messages Expo accepted. Use it for fire and forget sends.
  • send returns the full TraineePushResult for callers that report delivery back to a coach.
Services receive the notifier as a dependency. Nothing calls sendExpoPush directly. That is what lets the automation lane pass a silent notifier of the same shape.

Notification types

notificationConfigType in modules/notification-configs/notification-configs.schema.ts lists fifteen types. Twelve are active (ACTIVE_NOTIFICATION_TYPES) and appear in the studio’s settings. FORM_ASSIGNED, CHECK_IN_DUE and CHECK_IN_TOMORROW are still in the enum and in the target and group maps, but are not in the active list. The workout types (REST_TIMER, WORKOUT_IDLE, WORKOUT_UNFINISHED) are scheduled locally on the device. The server only supplies their text through GET /v1/trainee/notification-configs. Plan update types carry throttleHours of 6 (PLAN_UPDATE_THROTTLE_HOURS) in the catalogue, so repeated edits to a plan do not notify each time.

Configuration layers

resolveNotificationConfig(prisma, studioId, type) returns the effective config from the first layer that exists:
  1. StudioNotificationConfig for this studio and type (the studio’s own setting).
  2. NotificationTypeConfig for the type (platform default, managed through /v1/admin/notification-configs).
  3. NOTIFICATION_CONFIG_DEFAULTS in code.
Types come in three kinds (NotificationKind): Templates use {{name}} tokens. renderTemplate replaces known tokens and leaves unknown ones as written.

What sendTraineeNotification does

1

Resolve the config

If the type is disabled for the studio, return with disabled: true.
2

Load device tokens

TraineePushToken rows for the recipients, restricted to client.studioId equal to the studio, client.deletedAt null and client.studio.deletedAt null. A push never goes to a deleted trainee or an archived studio. No tokens at all is logged at warn (no registered devices, nothing sent), because a silent zero is the most common reason a push never arrives.
3

Apply the trainee's own mutes

Client.mutedNotificationGroups holds the groups the trainee switched off. isNotificationMuted maps the type to a group. COACH_MESSAGES is locked and can never be muted.
4

Render each message

Title from the template. Body from bodyOverride when present, otherwise from the template. Each message is addressed to every device of the recipient.
5

Send through Expo

sendExpoPush posts batches of 100 to Expo’s push API.
6

Process tickets

Tokens Expo rejects with DeviceNotRegistered are deleted. Accepted tickets are scheduled for a receipt check.
The whole function is wrapped in a try/catch that logs and returns an empty result. A push failure never fails the action that triggered it.

Recipient options

Use bodyOverride for free text a coach typed. Passing it as a message variable only works while the studio’s body template is exactly {{message}}, and the settings screen exists to change that.

The message sent to Expo

The result

noDevice is kept apart from a refused send on purpose. “The app was never installed” and “the push credential is broken” need different fixes.

Tickets are not delivery

Expo answers each message with a ticket. A ticket with status ok only means Expo accepted it. A broken APNs key or a mismatched FCM sender still produces a healthy ticket and then delivers nothing. The receipt pass closes that gap:
  1. After a send, schedulePushReceipts(entries) hands the accepted tickets to the scheduler installed at boot.
  2. installPushReceipts enqueues them on push-receipt-check in chunks of 300 with a 15 minute delay, Expo’s own guidance for when receipts are ready.
  3. The worker calls applyPushReceipts, which fetches receipts and builds a summary: delivered, failed, pending, an error histogram, counts by platform and up to five sample messages.
  4. Tokens with a DeviceNotRegistered receipt are pruned.
  5. A failure is logged at error as trainee push: deliveries failed after Expo accepted them. Success is logged as trainee push: receipts confirmed.
The platform split is what tells you whether InvalidCredentials is the APNs key or the FCM service account. Expo’s free text can contain the device token, so it is masked before logging. TRANSPORT_ERROR is a local code for “the request to Expo failed”. It is kept separate from Expo’s own codes so a network blip is never mistaken for a dead device and never costs a trainee their token. Without the scheduler installed (tests, scripts), schedulePushReceipts does nothing and sends still work.

Device tokens

Trainee preferences

Trainees mute by group, not by type. notificationPreferenceGroup: CHECK_INS, FORMS, PLANS, WORKOUT_REMINDERS, COACH_MESSAGES, SUBSCRIPTION. FORM_SENT and FORM_REMINDERS map to CHECK_INS for a check-in form and FORMS otherwise. The routes are GET and PATCH /v1/trainee/notification-preferences.

Where pushes are triggered

Debugging a missing push

Work down the log lines in order. Each one rules a cause out.
  1. trainee push: type disabled for this studio: the studio switched the type off.
  2. trainee push: no registered devices, nothing sent: the app is not installed, the trainee signed out, or the token was pruned.
  3. trainee push: every reachable recipient muted this type: the trainee muted the group.
  4. expo push: ticket error with a code: Expo refused the message.
  5. trainee push: sent with accepted above zero: Expo took it. Wait 15 minutes.
  6. trainee push: deliveries failed after Expo accepted them: a credential problem. Read byPlatform and samples.
apps/core-api/src/scripts/smoke-push-notifications.ts sends test pushes from the command line.

Coach notifications

@repo/notifications writes rows to the Notification table in the auth schema and optionally emails the user. It is used by the auth tier and exposed to the web app through the oRPC notifications router.
createNotification:
  1. Checks the user’s preferences for the type on two targets, IN_APP and EMAIL (isNotificationDisabled).
  2. Resolves link to an absolute web app URL with resolveNotificationLink.
  3. Inserts the row unless in-app is disabled.
  4. Sends the notification email template unless email is disabled, in the user’s stored locale. The email title is data.headline, else data.title, else the type name.
Types today are WELCOME and APP_UPDATE (NOTIFICATION_TYPES, mirroring the Prisma NotificationType enum). NOTIFICATION_GROUPS orders the types on the preferences screen, currently one group, general, containing APP_UPDATE. createWelcomeNotification(userId) is called from the better-auth user create hook. Its text is still the placeholder This is an example notification. The package also re-exports the query helpers from @repo/database: listNotificationRowsForUser, countUnreadNotificationsForUser, getDisabledNotificationPreferences, markAllNotificationsAsReadForUser, markNotificationsAsRead, setNotificationDisabled.
The coach’s task board and inbox are not this system. Tasks and inbox items are domain rows in the public schema, written by the task generator, flows and the WhatsApp webhook.