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 POST /api/notifications/mark-read for each viewed entry (or POST /api/notifications/read-all).
Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now }).
Emits notification-read (or recomputes unread count) so other open tabs sync.
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)
FE-->>U: badge--, mark item as read
FE-->>U: navigate to notification.actionUrl
API calls
Method
Endpoint
Purpose
GET
/api/notifications
Paginated list with unreadCount
GET
/api/notifications/unread-count
Just the unread count for badge
PATCH
/api/notifications/:id/read
Mark single notification read
POST
/api/notifications/read-all
Mark all read
DELETE
/api/notifications/:id
Remove from list
Database writes
notifications — insert on create, update on read, delete on remove.
Socket events emitted
new-notification → user-{userId}. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
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.
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.
[!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.