Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both MongoDB persistence and Socket.IO push.
Trigger sources
Purchase request lifecycle — created, status changed, cancelled (PurchaseRequestService).
Offer lifecycle — new offer, accepted, rejected, withdrawn (SellerOfferService).
Payment lifecycle — confirmed, refunded, payout sent (shkeeperWebhook, PurchaseRequestService.notifyPaymentConfirmed).
Notification.create({ ...data, isRead: false, createdAt: now }).
Calls this.emitRealTimeNotification(userId, saved) which global.io.to('user-${userId}').emit('new-notification', payload).
The notification is persisted and pushed simultaneously.
Frontend reception
The frontend's global Socket.IO provider listens for new-notification events on its user-{me} room (joined on app mount via socket.emit('join-user-room', userId)).
On event: increment the bell-icon badge, optionally show a toast (notistack), and prepend the entry into the cached notifications list (React Query cache).
The bell-icon dropdown also fetches GET /api/notifications?page=1&limit=20 for paginated history.
Reading
User opens the bell-icon dropdown — frontend calls PATCH /api/notifications/:id/read for each viewed entry, or PATCH /api/notifications/mark-all-read to clear all at once.
Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now }).
After updating, the backend emits unread-count-update to user-{userId} so all open tabs (and other devices) immediately sync their badge counter.
Preferences
User.preferences.notifications (in the User schema) can hold per-category opt-outs (emailNotifications, pushNotifications, etc.). The current implementation does not enforce preferences at send-time — all enabled notifications fire. Add a check in createNotification to short-circuit when the user has opted out of a category.
Email digest (planned)
A scheduled worker should Notification.find({ userId, emailDigested: false, createdAt: { $gte: yesterday } }), batch by user, render a digest email via emailService, mark emailDigested: true. Not implemented today.
Sequence diagram
sequenceDiagram
autonumber
participant Svc as Originating Service<br/>(SellerOfferService / Webhook / ...)
participant NS as NotificationService
participant DB as MongoDB
participant IO as Socket.IO
participant FE as Frontend
actor U as User
Svc->>NS: createNotification({userId, title, message, ...})
NS->>DB: Notification.create
NS->>IO: emit user-{userId} 'new-notification'
IO-->>FE: 'new-notification' payload
FE-->>U: badge++, toast, prepend to list
U->>FE: open bell dropdown
FE->>NS: GET /api/notifications?page=1&limit=20
NS->>DB: Notification.find({userId}).sort({createdAt:-1})
NS-->>FE: { notifications, total, unreadCount }
U->>FE: click notification
FE->>NS: PATCH /api/notifications/{id}/read
NS->>DB: Notification.findOneAndUpdate(isRead:true)
NS->>IO: emit user-{userId} 'unread-count-update'
IO-->>FE: badge sync across tabs
FE-->>U: badge--, mark item as read
FE-->>U: navigate to notification.actionUrl
API calls
Method
Endpoint
Purpose
Notes
GET
/api/notifications
Paginated list with unreadCount
GET
/api/notifications/unread-count
Just the unread count for badge
GET
/api/notifications/:id
Single notification
⚠️Known bug — see below
PATCH
/api/notifications/:id/read
Mark single notification read
PATCH
/api/notifications/mark-all-read
Mark all notifications read
Previously documented incorrectly as POST /api/notifications/read-all
DELETE
/api/notifications/:id
Remove from list
⚠️Known bug — GET /api/notifications/:id: The backend controller does not perform a direct DB lookup by ID. Instead it calls getUserNotifications(userId, 1, 1) (fetches only 1 record for the user) and then does an in-memory _id comparison. Any notification that is not the user's single most-recent record will return 404 erroneously. Do not rely on this endpoint for arbitrary notification lookups until the controller is fixed to use a direct findOne({ _id, userId }).
Database writes
notifications — insert on create, update on read, delete on remove.
TTL: notifications are automatically deleted after 90 days via a MongoDB TTL index on createdAt.
Socket events emitted
new-notification → user-{userId}. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
unread-count-update → user-{userId}. Emitted whenever the unread count changes (e.g. after markAsRead or markAllRead). Used for cross-tab and cross-device badge synchronisation. There is nonotification-read event — unread-count-update is the correct event to listen to for badge sync.
level-up → user-{userId} from PointsService.addPoints.
referral-signup → user-{referrerId} from auth verify.
chat-notification → user-{participantId} from ChatService.sendMessage (these are not stored in the notifications collection — they live alongside but drive only the chat-list badge).
Side effects
Bell badge count is derived from unreadCount returned by the GET endpoint or computed client-side as items arrive.
Notification actions deep-link via actionUrl (e.g. /dashboard/buyer/requests/{id}).
User offline → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
Multiple tabs / devices → the same user-{id} room receives the event in each socket; all tabs update. Badge sync is driven by unread-count-update, not a per-item notification-read event.
Disabled categories (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
High volume (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider insertMany + per-room broadcast. The 50ms stagger in Purchase Request Flow mitigates the worst case.
Stale unread count → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against unread-count on bell-icon open.
90-day TTL → notifications older than 90 days are silently removed from MongoDB. The frontend should not assume a notification persists indefinitely.
[!tip] Always set actionUrl
Every notification should have a deep-link target. Notifications without actionUrl lead to dead clicks. The factory methods in NotificationService (e.g. notifyNewOfferReceived) already enforce this — keep the pattern when adding new helpers.
Linked flows
Every other flow in this folder emits notifications via this service.
Chat Flow — separate chat-notification socket channel for chat badges.