Trainee push
The one entry point
Every push goes throughcreateTraineeNotifier:
notifyreturns the number of messages Expo accepted. Use it for fire and forget sends.sendreturns the fullTraineePushResultfor callers that report delivery back to a coach.
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:
StudioNotificationConfigfor this studio and type (the studio’s own setting).NotificationTypeConfigfor the type (platform default, managed through/v1/admin/notification-configs).NOTIFICATION_CONFIG_DEFAULTSin code.
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.try/catch that logs and returns an empty result. A push failure never fails the action that triggered it.
Recipient options
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 statusok 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:
- After a send,
schedulePushReceipts(entries)hands the accepted tickets to the scheduler installed at boot. installPushReceiptsenqueues them onpush-receipt-checkin chunks of 300 with a 15 minute delay, Expo’s own guidance for when receipts are ready.- 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. - Tokens with a
DeviceNotRegisteredreceipt are pruned. - A failure is logged at
errorastrainee push: deliveries failed after Expo accepted them. Success is logged astrainee push: receipts confirmed.
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.trainee push: type disabled for this studio: the studio switched the type off.trainee push: no registered devices, nothing sent: the app is not installed, the trainee signed out, or the token was pruned.trainee push: every reachable recipient muted this type: the trainee muted the group.expo push: ticket errorwith a code: Expo refused the message.trainee push: sentwithacceptedabove zero: Expo took it. Wait 15 minutes.trainee push: deliveries failed after Expo accepted them: a credential problem. ReadbyPlatformandsamples.
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:
- Checks the user’s preferences for the type on two targets,
IN_APPandEMAIL(isNotificationDisabled). - Resolves
linkto an absolute web app URL withresolveNotificationLink. - Inserts the row unless in-app is disabled.
- Sends the
notificationemail template unless email is disabled, in the user’s stored locale. The email title isdata.headline, elsedata.title, else the type name.
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.