Notifications Module
Location: apps/api/src/modules/notifications/
Aggregate roots: Notification, NotificationPreference, PushToken
The Notifications context is purely a listener — it never calls other module services directly. It subscribes to domain events and creates Notification rows, publishes real-time messages via Centrifugo, and delivers Expo push notifications. A daily digest email is sent at 09:00 UTC.
Prisma Models
| Model | Table | Notes |
|---|---|---|
| Notification | notifications | In-app inbox row |
| NotificationPreference | notification_preferences | Per-user channel + kind toggles |
| PushToken | push_tokens | ExpoPushToken per device |
Notification Fields
| Field | Type | Notes |
|---|---|---|
| id | UUID PK | |
| userId | UUID FK | Recipient |
| kind | String | See kind list below |
| title | String | Human-readable title |
| body | String? | Optional detail line |
| link | String? | Deep link path |
| payload | Json? | Structured context |
| readAt | DateTime? | Null = unread |
| createdAt | DateTime |
Rollup logic: same (kind, link) within 1 hour collapses into a single row with count incremented and title updated to "N people reacted to…".
NotificationPreference Fields
| Field | Type | Notes |
|---|---|---|
| userId | UUID PK | |
| channels | Json | { push: bool, email: bool, inApp: bool } — default all true |
| kinds | Json | Per-kind toggles { "post.replied": false } — default empty (all on) |
Notification Kinds
| Kind | Trigger | Description |
|---|---|---|
user.welcome | identity.user.registered | Welcome message to new user |
invite.used | identity.invite_code.used | Inviter notified when code used |
circle.invited | social.invitation.sent | Invitee receives circle invite |
circle.removed | social.member.removed | Member notified of removal |
message.posted | messaging.message.posted | New message in conversation |
message.started | messaging.member.added | Added to group conversation |
post.replied | posts.replied | Reply on your post |
post.reacted | posts.reacted | Reaction on your post |
post.reposted | posts.reposted | Your post was reposted |
post.bookmarked | posts.bookmarked | Your post was saved |
post.mentioned | mention.detected (post) | Mentioned in a post |
reply.mentioned | mention.detected (reply) | Mentioned in a reply |
post.published | posts.scheduled.published | Scheduled post went live |
post.hidden | post.hidden | Your post was moderated |
post.restored | post.unhidden | Your post is visible again |
post.milestone | Internal (reaction count) | Post hit 5 / 15 / 50 unique reactors |
ai.session.completed | ai.session.completed | Reflection session ready |
reputation.changed | reputation.milestone | Level-up notification |
reputation.voted | reputation.vote.cast | Peer voted on you |
account.restored | user.unbanned | Account ban lifted |
account.suspended | user.banned | Account suspended |
social.followed | social.user.followed | Someone followed you |
interest.new_post | posts.created | New post in followed interest |
system.broadcast | admin.broadcast.sent | Admin broadcast message |
economy.payment.received | economy.payment.sent | Mana received |
economy.post.boosted | economy.post.boosted | Post boost active |
series.new_part | series.new_part | New part in followed series |
wiki.proposal.approved | wiki.proposal.approved | Your wiki edit approved |
posts.coauthor.invited | posts.coauthor.invited | Co-author invitation |
HTTP Endpoints
All under /api/v1/notifications/. Require JwtAuthGuard.
| Method | Path | Description |
|---|---|---|
| GET | /notifications | List my notifications (?limit=50&onlyUnread=true) |
| GET | /notifications/unread-count | Returns { count } |
| POST | /notifications/:id/read | Mark one notification read |
| POST | /notifications/read-all | Mark all as read |
| GET | /notifications/preferences | Get channel + kind prefs |
| PATCH | /notifications/preferences | Update prefs { channels?, kinds? } |
Push Delivery (PushService)
- Uses Expo Push API in batches of 100 tokens.
- On
DeviceNotRegisterederror, the token row is deleted. sendToUsers(userIds, message)fans out to all registered tokens for those users.- Badge count is passed for iOS (incremented per unread count).
- Push is best-effort: errors are logged, not thrown.
Daily Digest (09:00 UTC)
A @Cron(EVERY_DAY_AT_9AM) job:
- Groups users with ≥1 unread notification in the past 24h.
- Skips users who opted out of email channel.
- Sends a branded HTML email listing up to 5 notifications with deep links.
- Skips deleted accounts (
email.endsWith('@deleted.rgls')).