Push Notification Infrastructure Codemap
Push Notification Infrastructure Codemap
Architecture Overview
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Novu Framework (@novu/framework) ββ Workflows registered via getWorkflowFactory() β workflow() ββ Channels: step.push(), step.email(), step.inApp(), step.sms() ββ Provider: OneSignal (via novu.subscribers.credentials.update) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββPackages involved: server/src/notification/, server/src/instantdb/, server/src/bullmq/, server/src/moderation/, server/src/templates/
1. Push Notification Type Definitions
File: server/src/notification/enums/push.enum.ts
42 push notification types across 6 categories:
| Category | Types |
|---|---|
| Account & Auth | WELCOME, EMAIL_VERIFICATION_REMINDER, PASSWORD_RESET_REQUEST, PASSWORD_CHANGED |
| Course & Consultation | COURSE_PURCHASE_CONFIRMATION, COURSE_AVAILABLE, UPCOMING_CONSULTATION_REMINDER, CONSULTATION_COMPLETED_FEEDBACK_REQUEST, CONSULTATION_CANCELLED, COURSE_COMPLETION_BADGE, NEW_COURSE_IN_CATEGORY |
| Withdrawals & Earnings | WITHDRAWAL_REQUEST_RECEIVED, WITHDRAWAL_APPROVED, WITHDRAWAL_DENIED, EARNINGS_UPDATE |
| Engagement | INACTIVE_USER_REENGAGEMENT, COURSE_DISCOUNT_ALERT, INSTRUCTOR_MESSAGE_NOTIFICATION |
| Admin & Support | NEW_COURSE_SUBMITTED, COURSE_APPROVED, COURSE_REJECTED, USER_REPORTED_ALERT |
| Financial & System | NEW_WITHDRAWAL_REQUEST, PAYOUT_PROCESSING_FAILURE, REVENUE_MILESTONE_ACHIEVEMENT, SUSPICIOUS_LOGIN_ATTEMPT, API_KEY_EXPIRY_WARNING, SERVICE_DOWNTIME_ALERT |
2. Core Workflow Registration & Factory
File: server/src/notification/workflows.service.ts
Factory Pattern (workflows.service.ts:120-131)
private getWorkflowFactory() βββ NODE_ENV=TEST β returns mock { id, trigger: async => ({}) } βββ Production β const { workflow } = require('@novu/framework') β returns `workflow(id, steps, { payloadSchema })`Returns Novuβs workflow() function. All workflows follow:
workflow(Workflows.XXX, async ({ step, payload }) => { await step.push('step-name', async () => { // data retrieval happens INSIDE step callbacks // renders templates, constructs { subject, body, data } });}, { payloadSchema: z.object({...}) });Workflow Registration (workflows.service.ts:1422-1449)
getWorkFlows() returns an array of 23 workflow instances:
| # | Workflow | Line | Push? |
|---|---|---|---|
| 1 | consultationReminderWorkflow() | 490 | β (conditional) |
| 2 | consultationRescheduledWorkflow() | 592 | β (inApp only) |
| 3 | consultationRescheduleApprovedWorkflow() | 629 | β (inApp only) |
| 4 | consultationCancelledWorkflow() | 668 | β (email + inApp) |
| 5 | consultationEnrollmentWorkflow() | 738 | β (email + inApp) |
| 6 | bookingConfirmationWorkflow() | 818 | β (email + inApp) |
| 7 | orderNotificationWorkflow() | 911 | β (email only) |
| 8 | productEnrollmentWorkflow() | 967 | β (email only) |
| 9 | productGiftReceivedWorkflow() | 999 | β (email only) |
| 10 | productGiftSentWorkflow() | 1028 | β (email only) |
| 11 | pushNotificationWorkflow() | 132 | β Primary push |
| 12 | inAppNotificationWorkflow() | 165 | β (inApp only) |
| 13 | emailWorkflow() | 201 | β (email only) |
| 14 | productUpdateWorkflow() | 1059 | β (email only) |
| 15 | sendPaymentNotificationWorkflow() | 1089 | β (email only) |
| 16 | sendUserActionNotificationWorkflow() | 1120 | β (email only) |
| 17 | otpWorkflow() | 232 | β (sms + email) |
| 18 | withdrawalMethodVerifiedWorkflow() | 1258 | β (email only) |
| 19 | withdrawalTransactionWorkflow() | 1180 | β (email only) |
| 20 | sendCourseCompletionNotificationWorkflow() | 1150 | β (email only) |
| 21 | multistepWorkflow() | 294 | β Push + email + inApp + sms |
| 22 | adminSystemAlertWorkflow() | 459 | β (inApp only) |
| 23 | chatNotificationWorkflow() | 1381 | β Chat push with digest |
3. Push Notification Workflow (Single Channel)
File: server/src/notification/workflows.service.ts:132-163
Invocation Path
pushNotificationWorkflow().trigger({ to: subscriberId, payload: { userId: string, template: PushNotification, // enum key data: { TITLE?, MESSAGE? } }})Step Flow
step.push('send-push-notification') βββ userService.getUser(payload.userId) βββ templateService.processTemplate( β payload.template, // PushNotification enum β NotificationChannel.PUSH, // 'push' β payload.data, // interpolation variables β user // user context for template β ) βββ Validates: subject && body !== null βββ Returns: { subject, body, data }Typical Caller Pattern
Triggered mostly via triggerWithCircuitBreaker() which wraps the trigger in:
CircuitBreakerStrategy: 60% error threshold, 8 req volume, 15s sleep, 90s windowRetryStrategy: 3 max retries, 15s max delay, exponential backoff (1.5s base)
4. Chat Message Push Notification Flow
Message Creation & Queuing
ModerationService (moderation.service.ts:270-303) βββ After moderation pass: βββ notificationService.addNotificationJob({ messageId, roomId, senderId, senderName, messageType, content, timestamp, roomName, roomType, entityType }) βββ notificationQueue.add('PROCESS_NOTIFICATION', data) (queue: NOTIFICATION)BullMQ Notification Processor
File: server/src/notification/processors/notification.processor.ts
NotificationProcessor.process(job) βββ job.name === 'PROCESS_NOTIFICATION' βββ job.data.messageId exists? βββ YES β processChatNotification(job) βββ NO β handle other types
processChatNotification(job): βββ 1. getMessageDetails(messageId) β β instantdbService.getMessage() βββ 2. Check message age threshold β β settingsService.getSettingValue('CHAT_NOTIFICATION_MAX_AGE_MINUTES') β β Default: 2 minutes β β If too old: markMessageNotificationProcessed() + return βββ 3. Route by room type: β β PRIVATE: β βββ getNotificationRecipients(roomId, senderId, timestamp) β β βββ getRoomById() + getRoomParticipants() β β βββ Filter: skip sender β β βββ Filter: shouldNotifyUser() per recipient β β βββ isUserInRoomView(userId, roomId) β ephemeral presence β β βββ getUserLastSeen(userId, roomId) β persistent read receipt β βββ triggerChatNotificationWorkflow(recipientId, messageData) β β triggerTarget = { subscriberId: recipientId } β β GROUP: β βββ notificationService.ensureRoomTopic(roomId, roomName) β β β deriveRoomTopicKey(roomId) β `room-${roomId}` β β β Check if topic exists via novu.topics.get() β β β If not: novu.topics.create({ key, name: `Room: ${roomName}` }) β βββ triggerChatNotificationWorkflow(null, messageData) β β triggerTarget = { type: "Topic", topicKey: `room-${roomId}` } β βββ 4. Mark message as notification processed β instantdbService.markMessageNotificationProcessed(messageId)Chat Notification Workflow Definition
File: server/src/notification/workflows.service.ts:1381-1420
chatNotificationWorkflow(): βββ step.digest('batch-chat-messages', () => { β amount: digestWindow || 5 seconds β digestKey: subscriberId || `room-${roomId}` β }) βββ step.push('send-chat-notification', () => { subject: payload.sender // sender name for private, room name for group body: payload.content // formatted + truncated content data: { messageId, roomId, messageType, timestamp } })Message Formatting
File: server/src/notification/services/message-formatter.service.ts
| Message Type | Notification Content |
|---|---|
| TEXT | Truncated to CHAT_NOTIFICATION_CONTENT_MAX_LENGTH (default: 100) + ellipsis |
| FILE / IMAGE / VIDEO / AUDIO / DOCUMENT | "You received a file" |
Digest windows:
- PRIVATE rooms:
CHAT_NOTIFICATION_DIGEST_WINDOW_DM(1s) - GROUP rooms:
CHAT_NOTIFICATION_DIGEST_WINDOW_GROUP(10s) - CATEGORY rooms:
CHAT_NOTIFICATION_DIGEST_WINDOW_CATEGORY(10s)
5. Event-Driven Push Notification Flow
File: server/src/notification/listeners/notification.listener.ts
Trigger Pattern
All event handlers follow:
@OnEvent(AppEvents.XXX)async handleXxxEvent(payload) { await this.workflowsService.triggerWithCircuitBreaker(() => this.workflowsService.someWorkflow().trigger({ to, payload }) );}Events β Workflow Mapping (with push)
| Event | Workflow | Push? |
|---|---|---|
SPACE_SESSION_LIVE | multistepWorkflow() | β
push: { data: { TITLE, MESSAGE } } |
SPACE_SESSION_REMINDER_24HR | multistepWorkflow() | β (email + inApp) |
SPACE_SESSION_REMINDER_1HR | multistepWorkflow() | β (email + inApp) |
SPACE_REGISTRATION_COMPLETED | multistepWorkflow() | β (email + inApp) |
SPACE_REGISTRATION_CANCELLED | multistepWorkflow() | β (email + inApp) |
COURSE_APPROVED | productUpdateWorkflow() | β (email only) |
COURSE_REJECTION | productUpdateWorkflow() | β (email only) |
CONSULTATION_APPROVED | productUpdateWorkflow() | β (email only) |
CONSULTATION_REJECTED | productUpdateWorkflow() | β (email only) |
PRODUCT_ENROLLMENT | productEnrollmentWorkflow() | β (email only) |
The SPACE_SESSION_LIVE handler (notification.listener.ts:419-450) is the only event-driven path that triggers push notifications:
await this.workflowsService.multistepWorkflow().trigger({ to: payload.recipientUserId, payload: { userId: payload.recipientUserId, push: { data: { TITLE: 'Space is live now', MESSAGE: 'The session has started. Join now.' } }, inapp: { data: { ... } }, data: { spaceId, sessionId }, priority: 'high', },})6. Multistep Workflow (Multi-Channel)
File: server/src/notification/workflows.service.ts:294-457
Single trigger β up to 4 channel steps, each with conditional skip:
multistepWorkflow(): βββ step.email('send-multistep-email') β βββ IF email.key β processTemplate() with Enum key β βββ IF email.data β use TITLE/MESSAGE directly β βββ skip: !payload.email β βββ step.push('send-multistep-push') β βββ IF push.key β processTemplate() with PushNotification enum β βββ IF push.data β use TITLE/MESSAGE directly β βββ skip: !payload.push β βββ step.inApp('send-multistep-inapp') β βββ IF inapp.key β processTemplate() with InAppNotification enum β βββ IF inapp.data β use TITLE/MESSAGE directly β βββ skip: !payload.inapp β βββ step.sms('send-multistep-sms') βββ IF sms.key β processTemplate() with SmsNotification enum βββ IF sms.data β use MESSAGE directly βββ skip: !payload.smsPayload schema:
{ userId: string, eventId?: AppEvents, email?: { key?: EmailNotification, data?: { TITLE, MESSAGE } }, push?: { key?: PushNotification, data?: { TITLE, MESSAGE } }, inapp?: { key?: InAppNotification, data?: { TITLE, MESSAGE } }, sms?: { key?: SmsNotification, data?: { MESSAGE } }, data?: Record<string, any>, priority?: 'low' | 'medium' | 'high' | 'urgent'}7. Consultation Reminder Push
Scheduling (BullMQ β ReminderProcessor)
File: server/src/bullmq/processors/reminder.processor.ts
ReminderProcessor.process(job) βββ job.name === 'CONSULTATION_REMINDER_24HR' β β reminderType = TWENTY_FOUR_HOURS βββ job.name === 'CONSULTATION_REMINDER_30MIN' β β reminderType = THIRTY_MINUTES β βββ Validate session exists & status is SCHEDULED/UPCOMING βββ Fetch student + consultant βββ Trigger for student: workflow.trigger({ to: student.id, ... }) βββ Trigger for consultant: workflow.trigger({ to: consultant.id, ... })Reminder Workflow Definition
File: server/src/notification/workflows.service.ts:490-590
consultationReminderWorkflow(): βββ Data retrieval helper: getConsultationReminderData() β βββ sessionsService.findOne(sessionId, [relations...]) β βββ Load: consultation β consultant β user β βββ Determine userForEmail + otherParty β βββ step.email('send-consultation-reminder-email') β ALWAYS β βββ template: EmailNotification.UPCOMING_CONSULTATION_REMINDER β with MJML template CONSULTATION β βββ Conditional: reminderType === THIRTY_MINUTES βββ step.push('send-30min-push-reminder') β β subject: 'Session Starting Soon' β β body: `Your consultation with ${otherParty.firstName} is in 30 minutes.` β β data: { sessionId, meetingLink } βββ step.inApp('send-30min-in-app-reminder') β subject: 'Session Starting Soon' β body: same as push β data: { sessionId, meetingLink }8. Device Token Registration & Novu Sync
File: server/src/notification/notification.service.ts
Client β Server Flow
Client sends: POST /notification/device-token (or similar) β NotificationController (unimplemented in controller β called from other services)
saveDeviceToken(data: DeviceTokenDto, user?: User) β notification.service.ts:166 β βββ 1. Check existing: deviceTokenRepo.findOne({ deviceToken, fingerprint }) β βββ 2. Token already exists: β βββ Update lastActive β βββ If no user attached but user provided: attach user + sync credentials β βββ Return 'success' β βββ 3. New token: β βββ deviceTokenRepo.save({ deviceToken, fingerprint, user, lastActive }) β βββ If user provided: fetch all user tokens β sync to Novu β βββ setCredential(user.id, allDeviceTokens) β βββ setCredential(subscriberId, deviceTokens) β notification.service.ts:297 βββ Resolve provider ID: β - Config: ONESIGNAL_PROVIDER_ID β - Normalize: 'onesignal' β 'one-signal' β - Fallback: NovuProviderId.OneSignal = 'one-signal' βββ novu.subscribers.credentials.update({ providerId, credentials: { deviceTokens } }, subscriberId)Device Token Entity
File: server/src/notification/entities/device-tokens.entity.ts
| Column | Type | Notes |
|---|---|---|
id | UUID (PK) | Auto-generated |
deviceToken | string | Push device token |
fingerprint | string | Device fingerprint |
user | Relation β User | Nullable (attached on first login) |
lastActive | timestamp | Updated on token reuse |
createdAt / updatedAt | timestamps | Auto |
9. Topic-Based Group Push
Topic Lifecycle
Room Creation (instantdb.service.ts:365-419):
getOrCreateRoom() or createRoom() βββ IF type === GROUP: βββ notificationService.ensureRoomTopic(roomId, roomName)Topic Initialization (notification.service.ts:338-359):
ensureRoomTopic(roomId, roomName): βββ deriveRoomTopicKey(roomId) β `room-${roomId}` βββ Try: novu.topics.get(topicKey) β βββ Found: log + return β βββ Not found: novu.topics.create({ key, name: `Room: ${roomName}` }) βββ Return topicKeyParticipant Subscription (instantdb.service.ts:137-188):
createRoomParticipant({ entityType, entityId, userId, role }) βββ Create participant in InstantDB βββ IF room.type === GROUP: βββ notificationService.addSubscriberToTopic([userId], topicKey)Participant Unsubscription (instantdb.service.ts:310-332):
leaveRoom(roomId, userId) βββ Delete participant from InstantDB βββ IF room.type === GROUP: βββ notificationService.removeSubscriberFromTopic(userId, topicKey)Topic Notification Delivery
From Chat Processor (notification.processor.ts:287-296):
// GROUP room β topic triggertriggerTarget = { type: "Topic", topicKey: `room-${messageData.roomId}`}
await workflowsService.chatNotificationWorkflow().trigger({ to: triggerTarget, payload: { ... }})Generic Topic Notification (notification.service.ts:271-286):
notifyTopic(topicKey: Topics | string, payload, workflowId?): βββ resolveTopicWorkflowId(topicKey) β βββ Topics.ADMIN β Workflows.ADMIN_SYSTEM_ALERT β βββ default β undefined (warns + returns null) βββ novu.trigger({ workflowId, to: { type: "Topic", topicKey }, payload })10. Resilience & Error Handling
File: server/src/notification/workflows.service.ts:1293-1379
Circuit Breaker + Retry
@UseResilience( CircuitBreakerStrategy({ errorThresholdPercentage: 60, requestVolumeThreshold: 8, sleepWindowInMilliseconds: 15000, rollingWindowInMilliseconds: 90000, timeoutInMilliseconds: 5000, }), RetryStrategy({ maxRetries: 3, maxDelay: 15000, backoff: new ExponentialBackoff({ baseDelay: 1500 }), }))async triggerWithCircuitBreaker<T>(workflowTrigger: () => Promise<T>)isRetryableError() classifies errors: timeouts, network errors, template processing errors, user lookup errors, Novu validation errors, rate limiting (429), service unavailable (502/503/504).
Chat Notification Failure Isolation
In NotificationProcessor (notification.processor.ts:136-142):
- Errors are logged but NOT thrown (prevents job retry)
- Chat message creation is decoupled from notification delivery
- βChat notification failed, but chat message creation succeededβ
11. Test Trigger Endpoint
File: server/src/notification/notification.controller.ts:99-138
POST /notification/test-trigger with { channel: 'push' | 'email' | 'inapp' | 'sms' | 'multistep' }:
| Channel | Workflow | Trigger Payload |
|---|---|---|
push | pushNotificationWorkflow() | { template: PushNotification.WELCOME, data: { TITLE, MESSAGE } } |
email | emailWorkflow() | { template: EmailNotification.WELCOME, data: { TITLE, MESSAGE } } |
inapp | inAppNotificationWorkflow() | { template: InAppNotification.CUSTOM, data: { TITLE, MESSAGE } } |
sms | otpWorkflow() | { template: SmsNotification.OTP_CODE, channel: SMS, data: { CODE, TITLE, MESSAGE } } |
multistep | multistepWorkflow() | { email, push, inapp, sms all with data } |
12. Novu Subscriber Management
File: server/src/notification/notification.service.ts
| Method | Line | Description |
|---|---|---|
createSubscriber(user) | 156 | Creates Novu subscriber with id, email, name |
updateSubscriber(user) | 219 | Patches subscriber email + name |
deactivateSubscriber(subscriberId) | 230 | Deletes subscriber from Novu |
getSubscribers() / listSubscribers() | 195/203 | Lists subscribers (limit 20) |
generateSubscriberHash(subscriberId) | 311 | HMAC-SHA256 hash for secure inbox |
resubscribeUserToGroupRooms(userId) | 373 | Re-subscribes user to all GROUP room topics |
resubscribeUserToRoomTopic(userId, roomId) | 409 | Re-subscribes user to single room topic |
Topic Management
| Method | Line | Description |
|---|---|---|
addSubscriberToTopic(subscriberIds, topicKey) | 208 | Subscribe users to a topic |
removeSubscriberFromTopic(subscriberId, topicKey) | 234 | Unsubscribe user from topic |
createTopic(key, name) | 263 | Create a Novu topic |
getTopic(key) | 258 | Get topic details |
syncListToTopic(roles, topic) | 248 | Sync user roles to a topic |
ensureRoomTopic(roomId, roomName) | 338 | Get-or-create room topic |
deriveRoomTopicKey(roomId) | 325 | room-${roomId} |
getNotificationTriggerTarget(roomType, recipientId, roomId) | 435 | Returns { subscriberId } or { type: "Topic", topicKey } |
13. Key Enums & Constants
File: server/src/notification/enums/
| Enum | File | Purpose |
|---|---|---|
PushNotification | push.enum.ts | 42 push template keys |
Workflows | workflows.enum.ts | 40+ workflow ID strings |
NotificationChannel | notification-channel.enum.ts | EMAIL, PUSH, IN_APP, SMS |
Topics | topics.enum.ts | ADMIN, USERS, SYSTEM |
NovuProviderId | novu.enums.ts | OneSignal = 'one-signal' |
NovuRecipientType | novu.enums.ts | Topic |
AppEvents | common/enums/app-events.enum.ts | 170+ domain events |
14. Data Flow Diagrams
Push Notification (Single Channel)
[Service/Controller] β workflowsService.pushNotificationWorkflow().trigger({ to: subscriberId, payload: { userId, template, data } }) β Novu Framework β step.push('send-push-notification') β userService.getUser(userId) β templateService.processTemplate(template, 'push', data, user) β Returns { subject, body, data } β Novu dispatches to OneSignal β Device receives pushChat Message Push
[User sends message] β ModerationService processes message β notificationService.addNotificationJob({ messageId, ... }) β BullMQ Queue (NOTIFICATION) β NotificationProcessor.processChatNotification() β getMessageDetails() β Check message age (CHAT_NOTIFICATION_MAX_AGE_MINUTES) β Route: PRIVATE β per-recipient with shouldNotifyUser() filtering GROUP β topic-based single trigger β MessageFormatterService.formatNotificationContent() β workflowsService.chatNotificationWorkflow().trigger() β step.digest() for batching β step.push('send-chat-notification') β { subject, body, data } β markMessageNotificationProcessed()Device Token Registration
[Client registers device] β notificationService.saveDeviceToken({ token, fingerprint }, user) β Upsert in device-tokens table β Fetch all tokens for user β setCredential(userId, tokens) β novu.subscribers.credentials.update({ providerId: 'one-signal', credentials: { deviceTokens: [...] } })Event-Driven Push (Space Session Live)
[Space goes live] β EventEmitter emits AppEvents.SPACE_SESSION_LIVE β NotificationListener.handleSpaceSessionLiveEvent() β triggerWithCircuitBreaker() β CircuitBreakerStrategy + RetryStrategy β multistepWorkflow().trigger({ to: recipientUserId, payload: { push: { data: { TITLE, MESSAGE } }, inapp: {...}, ... } }) β Novu Framework β step.push('send-multistep-push') β subject, body, data β step.inApp('send-multistep-inapp') β subject, body, data15. File Index
| File | Path | Key Lines |
|---|---|---|
| Workflows Service | server/src/notification/workflows.service.ts | 132, 294, 490, 1059, 1309, 1381, 1422 |
| Notification Service | server/src/notification/notification.service.ts | 166, 208, 271, 297, 338, 373, 435 |
| Notification Processor | server/src/notification/processors/notification.processor.ts | 70, 149, 183, 270, 350 |
| Notification Listener | server/src/notification/listeners/notification.listener.ts | 59, 201, 329, 374, 419, 452 |
| Notification Controller | server/src/notification/notification.controller.ts | 99 |
| Message Formatter | server/src/notification/services/message-formatter.service.ts | 22, 92 |
| Reminder Processor | server/src/bullmq/processors/reminder.processor.ts | 32 |
| Push Enum | server/src/notification/enums/push.enum.ts | 1-43 |
| Workflows Enum | server/src/notification/enums/workflows.enum.ts | 1-102 |
| Notification Channel Enum | server/src/notification/enums/notification-channel.enum.ts | 1-6 |
| Topics Enum | server/src/notification/enums/topics.enum.ts | 1-5 |
| Novu Enums | server/src/notification/enums/novu.enums.ts | 1-7 |
| Device Token DTO | server/src/notification/dto/device-token.dto.ts | 1-11 |
| Device Token Entity | server/src/notification/entities/device-tokens.entity.ts | 1-26 |
| InstantDB Service | server/src/instantdb/instantdb.service.ts | 137, 247, 285, 365, 758, 905, 927 |
| Instant Listener | server/src/instantdb/listeners/instant.listener.ts | 51, 98, 219 |
| Moderation Service | server/src/moderation/moderation.service.ts | 287 |
| App Events | server/src/common/enums/app-events.enum.ts | 1-171 |
| Template Service | server/src/templates/template.service.ts | (template processing) |