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: "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:

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.

Notification types

NotificationType in the Prisma schema:

TypeTriggered by
LIKE_POSTA reaction on your post
LIKE_COMMENTA reaction on your comment
COMMENTA comment on your post
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

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 }
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:

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

NotificationPreference columns:

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

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