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:
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:
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):
| Type | Title | Body |
|---|---|---|
LIKE_POST | Alex Morgan liked your post | – |
LIKE_COMMENT | Alex Morgan liked your comment | comment preview |
COMMENT | Alex Morgan commented on your post | comment preview |
COMMENT_REPLY | Alex Morgan replied to your comment | reply preview |
MENTION | Alex Morgan mentioned you in a post / comment | text preview |
NEW_FOLLOWER | Alex Morgan started following you | – |
DIRECT_MESSAGE | Alex Morgan sent you a message | message preview, or "Shared a post" |
SESSION_BOOKED | Alex Morgan booked a session with you | – |
SESSION_CANCELLED | Alex Morgan cancelled your session | – |
Other types (SYSTEM, etc.) have no template: pass title/body and they are used as-is.
- Previews.
previewis flattened to one line, cut to 100 characters, and kept in the row'sdataJSON ({ preview?, commentId? }). - Rendered twice. The text is rendered once on create (stored in
title/bodyand sent as the push) and again on every read byserializeNotification(). 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:
| Type | Triggered by |
|---|---|
LIKE_POST | A reaction on your post |
LIKE_COMMENT | A reaction on your comment |
COMMENT | A comment (or reply) on your post |
COMMENT_REPLY | A reply to your comment |
NEW_FOLLOWER | Someone follows you |
MENTION | You are @mentioned |
DIRECT_MESSAGE | A DM arrives |
SESSION_BOOKED | Someone books a session with you |
SESSION_CANCELLED | A session is cancelled |
SESSION_REMINDER | An upcoming session |
CHANNEL_ROOM_CREATED | A channel you belong to opens a room |
NEW_SUBSCRIBER | Someone subscribes to you |
SYSTEM | Platform 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.
Deep-link targets
A notification carries a typed target rather than a URL. buildTarget() converts the stored targetType / targetId into navigation params the mobile app understands:
targetType | Produces |
|---|---|
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:
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:
| Key | Default | Covers |
|---|---|---|
pushEnabled | true | Master switch — off disables all push |
likes | true | LIKE_POST, LIKE_COMMENT |
comments | true | COMMENT, COMMENT_REPLY |
newFollowers | true | NEW_FOLLOWER |
mentions | true | MENTION |
directMessages | true | DIRECT_MESSAGE |
liveStreams | true | Live start alerts |
shopUpdates | true | Commerce |
sessionUpdates | true | Session lifecycle |
tips | true | TIP_RECEIVED |
emailMarketing | false | Email — opt-in |
emailUpdates | true | |
emailSecurity | true |
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):
- Load the user's registered tokens.
- Filter to structurally valid Expo tokens (
Expo.isExpoPushToken). - Chunk with
expo.chunkPushNotifications(Expo caps batch size). - Send each chunk and inspect the returned tickets.
- On a
DeviceNotRegisteredticket, delete that token — self-healing against uninstalls.
The whole function is wrapped in a try/catch that swallows everything:
} 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-tokenPush 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
| Endpoint | Purpose |
|---|---|
GET /api/v1/notifications | Cursor-paginated list, ?unreadOnly=true supported |
GET /api/v1/notifications/unread-count | Badge count |
POST /api/v1/notifications/:id/read | Mark one read |
POST /api/v1/notifications/read-all | Mark all read |
Pagination uses a compound cursor of createdAt plus id, which is stable when several notifications share a timestamp:
{ 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
- Add the case to the
NotificationTypeenum inpackages/database/prisma/schema.prismaand migrate. - Add a preference column to
NotificationPreferenceif it needs its own toggle. - Call
notify()from the service that causes the event — not the controller. - If it needs a new deep-link destination, extend
NotifyTargetandbuildTarget(). - 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.
