Skip to content

Notifications ​

One function, three destinations. Any feature that needs to tell a user something calls notify() and stops thinking about it.

The single entry point ​

apps/api/src/services/notify.ts:

ts
await notify({
  recipientId: post.userId,
  actorId: req.user.id,
  type: "COMMENT",
  preview: comment.body,                 // optional: what the actor wrote
  target: { targetType: "post", targetId: post.id },
  preferenceKey: "likes",
});

That one call fans out to three places:

Rendering diagram…

Persistence is awaited; the realtime emit and the push are fire-and-forget with .catch(() => {}). A dead push service or a restarting esu can never fail the user action that triggered the notification.

Wording ​

Callers don't write notification text. renderNotification() in apps/api/src/services/notificationText.ts words it from the type and the actor's name (display_name, then username, then "Someone" when there is no actor):

TypeTitleBody
LIKE_POSTAlex Morgan liked your post–
LIKE_COMMENTAlex Morgan liked your commentcomment preview
COMMENTAlex Morgan commented on your postcomment preview
COMMENT_REPLYAlex Morgan replied to your commentreply preview
MENTIONAlex Morgan mentioned you in a post / commenttext preview
NEW_FOLLOWERAlex Morgan started following you–
DIRECT_MESSAGEAlex Morgan sent you a messagemessage preview, or "Shared a post"
SESSION_BOOKEDAlex Morgan booked a session with you–
SESSION_CANCELLEDAlex Morgan cancelled your session–

Other types (SYSTEM, etc.) have no template: pass title/body and they are used as-is.

  • Previews. preview is flattened to one line, cut to 100 characters, and kept in the row's data JSON ({ preview?, commentId? }).
  • Rendered twice. The text is rendered once on create (stored in title/body and sent as the push) and again on every read by serializeNotification(). Rows written before templates existed ("Someone liked your post") and actors who have since renamed themselves always display current text. The push, already delivered, keeps the name as it was at the time.

The in-app row shows the actor's avatar, falling back to gradient initials when there is none or it fails to load (components/notifications/notification-item.tsx).

Notification types ​

NotificationType in the Prisma schema:

TypeTriggered by
LIKE_POSTA reaction on your post
LIKE_COMMENTA reaction on your comment
COMMENTA comment (or reply) on your post
COMMENT_REPLYA reply to your comment
NEW_FOLLOWERSomeone follows you
MENTIONYou are @mentioned
DIRECT_MESSAGEA DM arrives
SESSION_BOOKEDSomeone books a session with you
SESSION_CANCELLEDA session is cancelled
SESSION_REMINDERAn upcoming session
CHANNEL_ROOM_CREATEDA channel you belong to opens a room
NEW_SUBSCRIBERSomeone subscribes to you
SYSTEMPlatform announcements, and creator application decisions (approved / rejected)

Likes are reported once per person and post (or comment): notify() skips a LIKE_POST / LIKE_COMMENT when that actor already has one for the same target, read or not. Liking, unliking and liking again doesn't notify or push the owner a second time.

A notification carries a typed target rather than a URL. buildTarget() converts the stored targetType / targetId into navigation params the mobile app understands:

targetTypeProduces
post{ screen: "post", postId }, plus commentId when notify() was given one (stored in data)
profile{ screen: "profile", userId }
conversation{ screen: "conversation", conversationId }
session{ screen: "session", sessionId }
channel{ screen: "channel", channelId }
room{ screen: "room", roomId }
livestream{ screen: "livestream", livestreamId }
creator_verification{ screen: "creator_verification" }. Opens the creator application status screen

The same object is used for the in-app row tap and the push notification payload, so tapping either lands in the same place.

Preferences ​

Every notify() call names a preferenceKey, checked in sendPushNotification before anything is sent:

ts
const prefs = await getNotificationPreferences(recipientUserId);
if (!prefs.pushEnabled || (preferenceKey && !prefs[preferenceKey])) return;

preferenceKey: null marks an account notice that no category toggle should silence, such as the result of a creator application review. Only pushEnabled can turn it off.

NotificationPreference columns:

KeyDefaultCovers
pushEnabledtrueMaster switch — off disables all push
likestrueLIKE_POST, LIKE_COMMENT
commentstrueCOMMENT, COMMENT_REPLY
newFollowerstrueNEW_FOLLOWER
mentionstrueMENTION
directMessagestrueDIRECT_MESSAGE
liveStreamstrueLive start alerts
shopUpdatestrueCommerce
sessionUpdatestrueSession lifecycle
tipstrueTIP_RECEIVED
emailMarketingfalseEmail — opt-in
emailUpdatestrueEmail
emailSecuritytrueEmail

PUT /user/notifications writes only these keys, and only boolean values; anything else in the body is ignored.

TIP_RECEIVED reads "Alex tipped you 1,500 coins", with the tip's message as the body. Its data holds coins and sourceTipId, so hiding the message also clears it from the notification.

Preferences gate push only. The in-app notification row is always written and the realtime event is always emitted, so muting push does not create gaps in the notifications screen.

Email preferences have no delivery mechanism

The three email* columns exist and are editable in the app, but nothing sends email. The packages/transactional package holds templates and is not wired to a provider. Planned

Push delivery ​

Expo's push service, via expo-server-sdk (apps/api/src/services/notification.ts):

  1. Load the user's registered tokens.
  2. Filter to structurally valid Expo tokens (Expo.isExpoPushToken).
  3. Chunk with expo.chunkPushNotifications (Expo caps batch size).
  4. Send each chunk and inspect the returned tickets.
  5. On a DeviceNotRegistered ticket, delete that token — self-healing against uninstalls.

The whole function is wrapped in a try/catch that swallows everything:

ts
} catch {
  // Never let notification failures propagate — fire-and-forget
}

Tokens are registered by the app through:

POST   /api/v1/user/push-token   { token }
DELETE /api/v1/user/push-token

Push receipts are not checked

Expo returns a ticket immediately and a receipt later. Only tickets are inspected, so delivery failures that surface at receipt time (expired credentials, throttling) are invisible. A follow-up job that fetches receipts by ticket id would close that loop.

Reading notifications ​

EndpointPurpose
GET /api/v1/notificationsCursor-paginated list, ?unreadOnly=true supported
GET /api/v1/notifications/unread-countBadge count
POST /api/v1/notifications/:id/readMark one read
POST /api/v1/notifications/read-allMark all read

Pagination uses a compound cursor of createdAt plus id, which is stable when several notifications share a timestamp:

ts
{ OR: [
  { createdAt: { lt: cursorDate } },
  { createdAt: cursorDate, id: { lt: cursor.id } },
]}

Page size is clamped to 50. Copy this pattern for any new paginated list.

Adding a notification ​

  1. Add the case to the NotificationType enum in packages/database/prisma/schema.prisma and migrate.
  2. Add a preference column to NotificationPreference if it needs its own toggle.
  3. Call notify() from the service that causes the event — not the controller.
  4. If it needs a new deep-link destination, extend NotifyTarget and buildTarget().
  5. Add the toggle to the mobile settings screen (app/(app)/(tabs)/me/settings/notifications.tsx).

Keep notify() calls in the service layer. Putting them in controllers means they get skipped by any other code path that performs the same action.

Internal documentation — PlayPalz platform