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: "LIKE_POST",
title: `${actor.display_name} liked your post`,
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.
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 on your post |
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 |
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 } |
profile | { screen: "profile", userId } |
conversation | { screen: "conversation", conversationId } |
session | { screen: "session", sessionId } |
channel | { screen: "channel", channelId } |
room | { screen: "room", roomId } |
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 || !prefs[preferenceKey]) return;NotificationPreference columns:
| Key | Default | Covers |
|---|---|---|
pushEnabled | true | Master switch — off disables all push |
likes | true | LIKE_POST, LIKE_COMMENT |
comments | true | COMMENT |
newFollowers | true | NEW_FOLLOWER |
mentions | true | MENTION |
directMessages | true | DIRECT_MESSAGE |
liveStreams | true | Live start alerts |
shopUpdates | true | Commerce |
sessionUpdates | true | Session lifecycle |
emailMarketing | false | Email — opt-in |
emailUpdates | true | |
emailSecurity | true |
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.
