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)
Architecture Overview
↓
TransactionalNotificationCatalog
↓
NotificationRecipientResolverService
↓
Template Renderers
↓
NotificationsService
↓
BullMQ Notifications Queue
↓
NotificationsProcessor (base)Implementation Files
| File | Purpose |
|---|---|
notification-event-type.ts | Enum defining transactional order/refund 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_REJECTED
Event Catalog
| Event Type | Template ID | Channels | Priority | Trigger |
|---|---|---|---|---|
ORDER_PLACED_CHECKOUT | checkout_order_placed | push + email | normal | Order created with source=checkout |
ORDER_PAYMENT_RECEIVED_CHECKOUT | checkout_payment_received | push + email | high | Payment success for checkout order |
ORDER_PAYMENT_FAILED | order_payment_failed | push + email | high | Payment failed or cancelled |
ORDER_CANCELLED | order_cancelled | push + email | normal | Order cancelled |
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;
productId?: number;
productTitle?: string;
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;
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-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_CHECKOUT- Falls back to buynow events if order_source is null/unknown
Testing
Subphase validation commands:
pnpm --filter @nomor/api check-types
pnpm --filter @nomor/api exec biome check src/modules/notifications
pnpm --filter @nomor/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