Notifications Module
HTTP surface for the event-driven notification system — customer + vendor mobile-device registration for FCM push, admin broadcasts (one-off email or push to a defined audience),…
HTTP surface for the event-driven notification system — customer + vendor mobile-device registration for FCM push, admin broadcasts (one-off email or push to a defined audience), and the admin notification audit log.
Source:
api-modules/notifications(registered viaNotificationsModule.forRoot({ providers, templates, resolvers })inapps/api/src/app.module.ts).The module is a router, not a sender. It listens for domain events (
order.placed,order.vendor.fulfilled, etc.) via NestJS@OnEvent, resolves recipients + channels via per-event resolvers, renders templates, and dispatches to provider modules. Concrete senders live in:
api-modules/notifications-email-mailer— SMTP email via themailermoduleapi-modules/notifications-push-fcm— Firebase Cloud Messaging pushBoth register
NotificationProviderinstances againstNOTIFICATION_PROVIDERS. Adding a new channel (SMS, WhatsApp) is a new provider module — the core notifications module doesn't change.
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
POST /store/devices, DELETE /store/devices | required (customer) | — |
POST /vendor/devices, DELETE /vendor/devices | required (vendor session) | — |
POST /admin/notifications/broadcasts, POST /admin/notifications/broadcasts/:id/cancel | required | notifications: broadcast |
GET /admin/notifications/broadcasts, GET /admin/notifications/broadcasts/:id, GET /admin/notifications/log | required | notifications: view |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Architecture
┌──────────────────────┐ EventEmitter2 ┌────────────────────────────────┐
│ order / shipping / │ ──────────────▶ │ NotificationOrchestratorService │
│ catalog / cart … │ │ (listens via @OnEvent) │
└──────────────────────┘ └─────────────────┬──────────────┘
│
┌───────────────────────────────┼──────────────────────┐
▼ ▼ ▼
RecipientResolver NotificationTemplate NotificationProvider
(per-event class) (per-(event, channel)) (email, push, ...)
│ │ │
└───── BullMQ "notification" ───┴──── enqueue ─────────┘
│
▼
NotificationLog
(audit row per send)Admin broadcast bypasses the resolver path: the controller writes a broadcast row, enqueues onto the notification.broadcast queue, and the worker fans out per-recipient notification jobs based on the chosen audience.
Domain types
DeviceResponse
type DeviceResponse = {
id: string;
platform: "android" | "ios";
appKind: "customer" | "vendor";
lastSeenAt: string; // ISO; updated on every re-register
};BroadcastResponse
type BroadcastResponse = {
id: string;
status: string; // "scheduled" | "running" | "completed" | "cancelled" | "failed"
audienceType: string; // "all_customers" | "all_vendors" | "user_ids"
channels: string[]; // subset of ["email", "push"]
scheduledFor: string | null; // ISO; null = send-now
totalRecipients: number | null;
sentCount: number;
failedCount: number;
createdAt: string;
};NotificationLogResponse
One row per send attempt (success or failure). Use this to answer "did the customer get the email?" from support.
type NotificationLogResponse = {
id: string;
recipientUserId: string | null; // null when send was to a non-user (rare)
channel: string; // "email" | "push"
provider: string; // e.g. "smtp-mailer", "fcm"
eventType: string; // e.g. "order.placed", "admin.broadcast"
broadcastId: string | null; // populated for broadcast sends
subject: string | null; // email subject (or push title for "push")
status: string; // "sent" | "failed"
error: string | null;
sentAt: string; // ISO
};Devices
Both the customer and vendor app register their FCM token after login. Re-registering the same token is idempotent — it refreshes lastSeenAt instead of creating a duplicate row. Apps are expected to call DELETE on logout to stop unrelated pushes from leaking to a shared device.
POST /store/devices — Register customer device
Body
{
"platform": "android", // "android" | "ios"
"token": "fcm-registration-token-..." // 10..4096 chars
}Response 201 — DeviceResponse with appKind="customer".
DELETE /store/devices — Unregister customer device
Body
{ "token": "fcm-registration-token-..." }Idempotent — unknown token is a no-op 200.
Response 200
{ "data": { "ok": true }, "message": "Success", "statusCode": 200 }POST /vendor/devices — Register vendor device
Same shape and rules as the storefront version, scoped to the vendor session. Returns DeviceResponse with appKind="vendor".
DELETE /vendor/devices — Unregister vendor device
Same shape as storefront.
Admin broadcasts
Base path: /admin/notifications/broadcasts.
POST /admin/notifications/broadcasts — Create a broadcast
Required permission: notifications: broadcast. Send-now (omit scheduleFor) or scheduled (future ISO datetime).
Body
{
"audience": {
"type": "user_ids",
"userIds": ["01J9...", "01J9..."] // 1..10_000
},
"channels": ["email", "push"], // 1..2 of "email" | "push"
"content": {
"email": {
"subject": "Festive sale starts tomorrow",
"html": "<h1>Save 30%</h1>...",
"text": "Save 30%..." // optional plaintext fallback
},
"push": {
"title": "Festive sale",
"body": "Up to 30% off — starts in 24h",
"imageUrl": "https://cdn.example/banner.png",
"data": { "deepLink": "/sale" }
}
},
"scheduleFor": "2026-05-12T03:00:00.000Z" // optional; past values reject
}audience.type discriminator (mutually exclusive):
| Type | Extra fields | Resolved audience |
|---|---|---|
all_customers | — | every user with appKind="customer" device or email |
all_vendors | — | every user with appKind="vendor" device or email |
user_ids | userIds: string[] (1..10_000) | the listed ids |
Cross-field rule: content.email is required when channels contains "email"; content.push is required when channels contains "push".
Response 201 — BroadcastResponse. status reflects whether it's scheduled or already running.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod; missing content for a listed channel; scheduleFor in the past |
| 403 | FORBIDDEN | Caller lacks notifications: broadcast |
GET /admin/notifications/broadcasts — List broadcasts
Required permission: notifications: view. Newest first.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
status | string? | — | filter by status |
Response 200 — paginated envelope of BroadcastResponse[].
GET /admin/notifications/broadcasts/:id — Broadcast detail
Required permission: notifications: view. Returns the BroadcastResponse with current counters (sentCount, failedCount).
POST /admin/notifications/broadcasts/:id/cancel — Cancel a broadcast
Required permission: notifications: broadcast.
| Scenario | Behavior |
|---|---|
| Scheduled (not yet started) | Removes the delayed start job from BullMQ; marks the row cancelled |
| In-progress (start job has run, per-recipient jobs are still queued) | Marks the row cancelled. Per-recipient jobs are still popped but skip when they see the cancelled flag. In-flight provider calls (currently uploading to FCM, currently in SMTP RCPT) are not aborted |
Already terminal (completed, failed, cancelled) | No-op 200 |
Response 200 — BroadcastResponse.
Admin log
GET /admin/notifications/log — Audit log
Required permission: notifications: view. One row per send attempt. Newest first.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
recipientUserId | string? | — | filter to one user (support flow) |
eventType | string? | — | e.g. "order.placed", "admin.broadcast" |
broadcastId | string? | — | every send from one broadcast |
eventType="admin.broadcast" is the sentinel for sends from the broadcast pathway; per-event sends use the underlying domain event name.
Response 200 — paginated envelope of NotificationLogResponse[].
Event-driven sends (no HTTP surface)
The orchestrator subscribes to these events out of the box (see resolvers/order/). The set is extensible — add a RecipientResolver + NotificationTemplate (+ register them in NotificationsModule.forRoot) for any new event.
| Event | Recipients | Channels | Template summary |
|---|---|---|---|
order.placed | customer, every vendor on the order | email + push | "Your order #N is placed" / "New order on <product>" |
order.paid | customer | "Payment received for order #N" | |
order.vendor.fulfilled | customer | email + push | "Your order is on the way" with tracking |
order.vendor.delivered | customer | email + push | "Order delivered" |
order.vendor.cancelled / order.cancelled | customer (+ vendor when admin-initiated) | "Your order was cancelled" | |
shipping.tracking.updated (out_for_delivery) | customer | push | "Out for delivery — arriving today" |
Recipient resolution falls back gracefully — a customer with no FCM device gets email-only; one with no email skips the email channel. Send failures land in notification_log with status="failed" and the provider error.
Configuration
Process env (used by provider modules)
| Env var | Module | Purpose |
|---|---|---|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM | mailer / notifications-email-mailer | SMTP credentials + From address for outbound email |
FCM_PROJECT_ID, FCM_CLIENT_EMAIL, FCM_PRIVATE_KEY | notifications-push-fcm | Firebase service-account credentials for push delivery |
Constants
| Name | Value | Purpose |
|---|---|---|
NOTIFICATION_QUEUE | "notification" | BullMQ queue for per-recipient dispatch jobs |
NOTIFICATION_BROADCAST_QUEUE | "notification.broadcast" | BullMQ queue for broadcast start + fan-out jobs |
ADMIN_BROADCAST_EVENT_TYPE | "admin.broadcast" | Sentinel event_type on log rows from a broadcast |
Related modules
notifications-email-mailer— SMTP email provider. Wraps@sc/mailer.notifications-push-fcm— FCM push provider.mailer— low-level SMTP transport.queue— BullMQ infrastructure shared with inventory and other async workers.order/shipping— primary event sources today. Seeorder.md,shipping.md.
Inventory Module
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, and bulk CSV imports.
Order Module
HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).