Notification Implementation
Concrete implementation details for schemas, DTOs, producer logic, realtime events, and operational settings.
Notification Implementation
Schema and Indexes
Implemented in packages/mongodb/src/schemas/notifications:
notification.schema.tsnotification-setting.schema.ts
Key implementation points:
surfaces+statusBySurfacereplace legacy channel/read fields.- Unique setting key:
{ userId, notificationType, surface }. - Surface-focused read indexes for web and mobile in-app states.
- TTL index on
expiresAtand partial unique index onexternalRef.
Producer and Target Dispatch
Implemented in apps/api/src/modules/notifications/notifications.processor.ts.
- Producer payload accepts explicit
inAppTargetsandpushTargets. - One persistence write for all targets.
- Emits
notification:newfor in-app targets. - Applies presence-driven push suppression policy.
Read API Module
Implemented in apps/api/src/modules/notification.
- Surface required in query/body DTOs.
- Surface-scoped list/count/read/mark-all behavior.
- Realtime fanout on read operations via
notification:readandnotification:read_all.
Realtime and Presence
Implemented in:
apps/api/src/services/realtime/realtime.gateway.tsapps/api/src/services/realtime/realtime.service.tsapps/api/src/services/realtime/notification-room-presence.service.tspackages/realtime-core/src/events/event-publisher.tspackages/realtime-core/src/room-keys/room-keys.helper.ts
Details:
- Join/leave/sync events for notification rooms.
- Origin session exclusion to avoid duplicate self updates.
- Presence TTL via
NOTIFICATION_ROOM_PRESENCE_TTL_SECONDS. - Mobile suppression toggle via
NOTIFICATION_MOBILE_PRESENCE_RELIABLE.
Testing Status
- Unit coverage includes producer mapping/suppression and realtime gateway/service paths.
- Realtime e2e spec includes notification delivery, dedup, and sync replay checks.
- In environments without Redis connectivity, realtime e2e suite auto-skips with explicit warning.
Outbox Cleanup Integration (NEW)
Outbox cleanup is implemented as internal maintenance runtime in the order processing module and applies to all modules writing to outbox_events.
Implementation path:
- Scheduler:
apps/api/src/modules/order/processing/orders-maintenance.scheduler.ts - Processor:
apps/api/src/modules/order/processing/orders-maintenance.processor.ts - Service:
apps/api/src/modules/order/processing/outbox-cleanup.service.ts
Execution flow:
- Daily scheduler enqueues
OrdersMaintenanceJob.CLEANUP_OUTBOXonQueueName.ORDERS_MAINTENANCE. - Processor executes
OutboxCleanupService.cleanup(). - Cleanup service deletes old rows by status/age:
completedrows older thanOUTBOX_RETENTION_DAYS(default 7)failedrows older thanOUTBOX_FAILED_RETENTION_DAYS(default 30)
- Processor runs DLQ health check (
checkDlqHealth) and logs alert-level errors when failed-job count exceeds threshold.
Operational configuration:
OUTBOX_RETENTION_DAYSOUTBOX_FAILED_RETENTION_DAYSOUTBOX_CLEANUP_ENABLEDOUTBOX_CLEANUP_DLQ_THRESHOLD
This is an internal maintenance feature with no external REST API surface.
Transactional Notification Layer (NEW)
The transactional notification layer sits on top of the base infrastructure, providing order and training modules with standardized event-based notification routing with deduplication, template rendering, and source-aware channel selection.
Architecture Overview
Order/Training Modules → OrderNotificationService → NotificationRouterService
↓
TransactionalNotificationCatalog
↓
NotificationRecipientResolverService
↓
Template Renderers
↓
NotificationsService
↓
BullMQ Notifications Queue
↓
NotificationsProcessor (base)Implementation Files
| File | Purpose |
|---|---|
notification-event-type.ts | Enum defining 12 transactional event types |
transactional-notification.catalog.ts | Event-to-template/channel configuration catalog |
notification-router.service.ts | Core routing logic with deduplication |
notification-recipient-resolver.service.ts | Resolves recipients from event context |
types.ts | TypeScript interfaces for EventContext, Recipient, EventConfig |
email-templates/* | React-based email template renderers |
transactional.module.ts | NestJS module composition |
Entry Point (Order Module)
apps/api/src/modules/order/processing/order-notification.service.ts provides domain-specific notification methods:
notifyOrderCreated(orderId)- Emits ORDER_PLACED_CHECKOUT or ORDER_PLACED_BUYNOW based on order_sourcenotifyPaymentSuccess(orderId)- Emits ORDER_PAYMENT_RECEIVED_CHECKOUT or ORDER_PAYMENT_RECEIVED_BUYNOWnotifyPaymentFailed(orderId, reason?)- Emits ORDER_PAYMENT_FAILEDnotifyOrderCancelled(orderId, reason?)- Emits ORDER_CANCELLEDnotifyRefundRequested(orderId)- Emits ORDER_REFUND_REQUESTEDnotifyRefundApproved(orderId)- Emits ORDER_REFUND_APPROVEDnotifyRefundRejected(orderId)- Emits ORDER_REFUND_REJECTEDnotifyCourseAccessGranted(orderId, courseId, courseTitle)- Emits COURSE_ACCESS_GRANTEDnotifyTrainingEnrollmentConfirmed(orderId, trainingId, trainingTitle)- Emits TRAINING_ENROLLMENT_CONFIRMEDnotifyBookletAccessGranted(orderId, bookletId, bookletTitle)- Emits BOOKLET_ACCESS_GRANTED
Event Catalog (12 Events)
| Event Type | Template ID | Channels | Priority | Trigger |
|---|---|---|---|---|
ORDER_PLACED_CHECKOUT | checkout_order_placed | push + email | normal | Order created with source=checkout |
ORDER_PLACED_BUYNOW | buynow_order_placed | push + email | normal | Order created with source=buy_now/training_enroll |
ORDER_PAYMENT_RECEIVED_CHECKOUT | checkout_payment_received | push + email | high | Payment success for checkout order |
ORDER_PAYMENT_RECEIVED_BUYNOW | buynow_payment_received | push + email | high | Payment success for buy_now/training order |
ORDER_PAYMENT_FAILED | order_payment_failed | push + email | high | Payment failed or cancelled |
ORDER_CANCELLED | order_cancelled | push + email | normal | Order cancelled |
COURSE_ACCESS_GRANTED | course_access_granted | push + email | high | Course access granted after payment |
TRAINING_ENROLLMENT_CONFIRMED | training_enrollment_confirmed | push + email | high | Training enrollment confirmed |
BOOKLET_ACCESS_GRANTED | booklet_access_granted | push + email | high | Booklet access granted after payment |
ORDER_REFUND_REQUESTED | refund_requested | push + email | high | Customer requests refund |
ORDER_REFUND_APPROVED | refund_approved | push + email | high | Admin approves refund |
ORDER_REFUND_REJECTED | refund_rejected | push + email | high | Admin rejects refund |
EventContext Type
The EventContext interface provides all data needed for template rendering:
interface EventContext {
orderId: number;
orderNumber: string;
userId: string;
email?: string;
userName?: string;
finalPayable: number;
orderStatus: OrderStatus;
orderSource?: OrderSource; // "checkout" | "buy_now" | "training_enroll" | "admin_manual"
productId?: number;
productType?: ProductType; // "course" | "training" | "booklet"
productTitle?: string;
accessValidityDays?: number;
reason?: string;
placedAt?: Date;
paidAt?: Date;
refundedAt?: Date;
items?: NotificationProductItem[]; // For checkout multi-item orders
product?: NotificationProductItem; // For buynow single-item orders
subtotalPaisa?: number;
discountPaisa?: number;
couponCode?: string;
amountPaisa?: number;
accessType?: AccessType; // "lifetime" | "time_limited"
validityDays?: number;
expiresAt?: Date;
courseDescription?: string;
trainingCohortName?: string;
trainingSessionDate?: Date;
trainingSchedule?: string;
bookletAuthor?: string;
ctaUrl?: string;
ctaLabel?: string;
retryCtaUrl?: string;
supportEmail?: string;
}Routing and Deduplication Behavior
The NotificationRouterService.routeEvent() method implements:
- Config Lookup: Resolves event configuration from
TransactionalNotificationCatalog - Dedupe Reservation: Attempts Redis SET with NX EX (7-day TTL)
- Key format:
notification_sent:transactional:<event>:<orderId>:<productId> - If Redis unavailable: fail-open (allow send)
- If duplicate detected: skip silently
- Key format:
- Recipient Resolution: Calls
NotificationRecipientResolverService.resolve(context) - Channel Dispatch: For each channel (PUSH, EMAIL):
- Push: Calls
notificationsService.sendToUser()with inAppTargets=["mobile_in_app", "web_in_app"] and pushTargets=["mobile_push"] - Email: Calls
notificationsService.sendEmailToUsers()with rendered HTML/text
- Push: Calls
Deduplication Key
const DEDUPE_KEY_PREFIX = "notification_sent:transactional:";
const DEDUPE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
// Built using CacheKeyUtil
dedupeKey = CacheKeyUtil.build(DEDUPE_KEY_PREFIX, [
["event", event],
["orderId", context.orderId],
["productId", context.productId],
]);Push Notification Payload
await notificationsService.sendToUser({
userId: recipient.userId,
type: "transactional",
priority: config.priority,
title: config.title, // e.g., "Order Received", "Payment Successful"
body: this.buildNotificationBody(event, context), // Event-specific body text
data: {
source: "order_transactional",
event,
orderId: String(context.orderId),
orderNumber: context.orderNumber,
productId: context.productId ? String(context.productId) : "",
productType: context.productType ?? "",
},
inAppTargets: ["mobile_in_app", "web_in_app"],
pushTargets: ["mobile_push"],
externalRef: `${dedupeKey}:${recipient.userId}`,
persistOnSkip: true, // Always persist transactional notifications
});Email Notification Payload
await notificationsService.sendEmailToUsers({
userIds: recipients.map(r => r.userId),
subject: rendered.subject,
html: rendered.html,
text: rendered.text,
notificationType: "transactional",
data: {
source: "order_transactional",
event,
orderId: String(context.orderId),
orderNumber: context.orderNumber,
productId: context.productId ? String(context.productId) : "",
productType: context.productType ?? "",
previewText: rendered.previewText ?? "",
},
externalRefBase: dedupeKey,
persistOnSkip: true,
});Template Rendering
Each event type has a dedicated renderer in email-templates/:
render-checkout-email.ts- ORDER_PLACED_CHECKOUT, ORDER_PAYMENT_RECEIVED_CHECKOUTrender-buynow-email.ts- ORDER_PLACED_BUYNOW, ORDER_PAYMENT_RECEIVED_BUYNOWrender-course-access-email.ts- COURSE_ACCESS_GRANTEDrender-training-enrollment-email.ts- TRAINING_ENROLLMENT_CONFIRMEDrender-booklet-access-email.ts- BOOKLET_ACCESS_GRANTEDrender-lifecycle-email.ts- ORDER_PAYMENT_FAILED, ORDER_CANCELLED, ORDER_REFUND_REQUESTED, ORDER_REFUND_APPROVED, ORDER_REFUND_REJECTED
All renderers produce:
interface RenderedEmail {
subject: string;
html: string;
text: string;
previewText?: string;
}Fallback Behavior
If template rendering fails:
- Router catches error and logs
- Falls back to
buildFallbackEmail()which generates minimal HTML:- Subject: event-specific subject line
- Body: simple paragraph with order number and amount
- Continues with dispatch (fail-open)
Fail-Open Reliability Model
- Redis unavailable for dedupe: Allow send (fail-open)
- Recipient resolution empty: Log warning, skip dispatch
- Template rendering fails: Use fallback, continue dispatch
- Notification delivery fails: Log error, do not block order/payment workflows
Order Source Aware Routing
The OrderNotificationService resolves event types based on order_source:
order_source = "checkout"→ ORDER_PLACED_CHECKOUT, ORDER_PAYMENT_RECEIVED_CHECKOUTorder_source = "buy_now"or"training_enroll"→ ORDER_PLACED_BUYNOW, ORDER_PAYMENT_RECEIVED_BUYNOW- Falls back to buynow events if order_source is null/unknown
Testing
Subphase validation commands:
pnpm --filter @bullhouse/api check-types
pnpm --filter @bullhouse/api exec biome check src/modules/notifications
pnpm --filter @bullhouse/api test -- src/modules/notificationsTest coverage includes:
transactional-notification.catalog.spec.ts- Catalog config testsnotification-router.service.spec.ts- Router routing/dedupe testsnotification-recipient-resolver.service.spec.ts- Recipient resolution testsemail-templates/*.spec.ts- Template rendering tests