Notifications — in-app alerts & real-time delivery
The whole notification subsystem reduces to one idea: a domain event writes a row to a notification collection, then immediately publishes that row over Redis pub/sub so any connected GraphQL subscription forwards it to the right user. Persistence is the source of truth (paginated, marked read/unread); the pub/sub push is a best-effort live overlay. There are two parallel notification systems — a generic
Notificationand a workflow-specificWorkflowNotification— that the admin merges into one unified feed client-side.
Source: BE src/modules/notification + src/modules/workflow/notification + src/core/pubsub + src/core/mailer + src/core/firebase · Admin src/modules/notification + src/modules/workflow/notification · page src/pages/notifications.tsx
1. Purpose & scope
Responsible for:
- Generic in-app notifications (
Notification) — user-scoped alerts of typeORDERorSCHEME, paginated and filterable, with read/unread state. - Workflow approval notifications (
WorkflowNotification) — per-recipient alerts emitted by the workflow approval engine on every task/stage/approve/reject/complete event, with a human-readablemessageand areadflag. - Real-time delivery via three GraphQL subscriptions backed by Redis pub/sub (
graphql-redis-subscriptionsoverioredis). - A transactional email channel (
@nestjs-modules/mailer+ EJS templates) used by auth/OTP/KYC flows — separate from the in-app notification collections.
Explicitly does NOT do:
- No user notification preferences / settings. There is no preferences collection, no per-channel opt-in, no mute/snooze. Recipients are computed by the emitting domain only.
- No push (FCM) wiring. A
FirebaseServiceexists (src/core/firebase/firebase.service.ts) withsendPushNotification/sendPushNotificationToTopic, butadmin.initializeApp(...)is commented out and no module calls it — push is dormant/unimplemented. Treat it as a stub. - No SMS/WhatsApp tied to notifications.
MessageServiceexposessms(Exabytes) andwhatsapp(UltraMsg) wrappers; only WhatsApp+email-OTP paths use them (auth/otp), never the notification collections. - No cross-collection unification on the backend. The generic and workflow notifications are queried separately; the admin merges them in memory (see §7).
2. Data model
Two collections. Both extend BaseSchema (src/core/database/database.scheme.ts), so both inherit companyId, branchId, ref, key, createdBy/updatedBy, timestamps, and mongoose-delete soft-delete fields (deleted, deletedAt, deletedBy).
2.1 notifications — generic in-app notification
notification/notification.schema.ts. User-scoped alert with read/unread status.
| field | type | required | description |
|---|---|---|---|
userId |
ObjectId | — | target user (null when broadcast to "admin and user", see §4) |
refId |
ObjectId | — | the source document this alert points to (e.g. order _id) |
title |
string | — | short heading |
message |
string | — | body text |
type |
NotificationType |
default ORDER |
discriminates the source domain |
status |
NotificationStatusType |
default UNREAD |
read/unread |
group |
Array<String> |
— | free-form grouping tags (unused by current emitters) |
Inherited (from BaseSchema): ref (string, also surfaced in GraphQL + used in keyword search), companyId, branchId, createdBy, createdAt, updatedAt, soft-delete.
export enum NotificationType {
ORDER = "ORDER",
SCHEME = "SCHEME",
}
export enum NotificationStatusType {
READ = "READ",
UNREAD = "UNREAD",
}
@ApSchema({ collection: "notifications", timestamps: true })
export class Notification extends BaseSchema {
userId: Types.ObjectId; // target user
refId: Types.ObjectId; // source document
title: string;
message: string;
type: NotificationType; // default ORDER
status: NotificationStatusType; // default UNREAD
group: Array<String>;
}
// NotificationSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true })Note: the GraphQL
NotificationObjectType (notification.dto.ts) is a separate, hand-written DTO (not derived from the schema). It exposes bothrefIdand the inheritedref, and typescreatedAtasStringbutupdatedAtasNumber(an inconsistency carried into the schema.gql).
2.2 workflow_notifications — workflow approval notification
workflow/notification/workflow-notification.schema.ts. One row per recipient per event.
| field | type | required | description |
|---|---|---|---|
userId |
ObjectId | — | recipient (one row each) |
eventId |
ObjectId | — | source WorkflowEvent (workflow/event/workflow-event.schema.ts) |
workflowId |
ObjectId | — | the workflow definition (null for dynamic-stage submissions) |
taskId |
ObjectId | — | the approval task |
refId |
ObjectId | — | the business document under approval (PR, PO, Leave, Claim, …) |
kind |
WorkflowTaskKind |
— | document kind (see enum below) |
message |
string | — | pre-rendered human message (see buildMessage, §4.2) |
read |
boolean | default false |
read flag |
Indexes: { userId, read } and { userId, createdAt: -1 } for fast unread-feed queries.
@ApSchema({ collection: "workflow_notifications", timestamps: true })
export class WorkflowNotification extends BaseSchema {
userId: Types.ObjectId;
eventId: Types.ObjectId;
workflowId: Types.ObjectId;
taskId: Types.ObjectId;
refId: Types.ObjectId;
kind: WorkflowTaskKind;
message: string;
read: boolean; // default false
}
WorkflowNotificationSchema.index({ userId: 1, read: 1 });
WorkflowNotificationSchema.index({ userId: 1, createdAt: -1 });WorkflowTaskKind (workflow/task/task.schema.ts) — the document kinds that flow through approval:
export enum WorkflowTaskKind {
PurchaseRequisition = "PurchaseRequisition",
PurchaseOrder = "PurchaseOrder",
SalesQuotation = "SalesQuotation",
SalesOrder = "SalesOrder",
Leave = "Leave",
Claim = "Claim",
Loan = "Loan",
Advance = "Advance",
}The trigger source is WorkflowEventType (workflow/event/workflow-event.schema.ts); each event maps to a message template:
export enum WorkflowEventType {
TASK_CREATED = "TASK_CREATED",
STAGE_ADVANCED = "STAGE_ADVANCED",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
COMPLETED = "COMPLETED",
RESUBMITTED = "RESUBMITTED", // declared; not emitted by the engine
}The GraphQL
WorkflowNotificationDto(workflow-notification.dto.ts) is leaner than the schema: it exposes_id, userId, taskId, refId, kind, message, read, createdAtonly (noeventId/workflowId).
3. API surface
All GraphQL, code-first. Auth via @ApGqlAuthorize() (auth/decorators/gql-auth.decorator.ts).
3.1 Generic notification (notification/notification.resolver.ts)
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
notificationPage |
Query | NotificationPageInput { skip, take, type?, status?, userId?, keyword? } |
NotificationPageResult { totalRecords, data[] } |
@ApGqlAuthorize({ ignoreCompanyQuery: true }) — userId defaults to current user |
notificationCreated |
Subscription | — | Notification (nullable) |
@ApGqlAuthorize({ authNotRequired: true, ignoreCompanyQuery: true }) — no per-user filter |
updateNotificationStatus |
Mutation | (notificationId: String!, status: String!) |
Boolean! |
@ApGqlAuthorize(); audited (STATUS_CHANGE) |
createNotification |
Mutation | CreateNotificationInput |
Notification! |
@ApGqlAuthorize(); audited (CREATE). Marked "Should be removed" in source — a manual/legacy entry point. |
notificationPageresolvesuserIdserver-side:page.userId || user?._id || user?.id. It runs on the pre-company "select company" screen, henceignoreCompanyQuery: true.- The repository
page()builds an aggregation$matchfromuserId,type,status, and a regexkeywordovertitle | message | ref, then applieshandlePageFacet/handlePageResultfor pagination.
3.2 Workflow notification (workflow/notification/workflow-notification.resolver.ts)
Resolver extends ApBaseResolver<WorkflowNotificationDto>.
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
findMyNotifications |
Query | (read: Boolean) optional |
[WorkflowNotificationDto!]! |
@ApGqlAuthorize() — scoped to current user, sorted createdAt: -1 |
workflowNotificationAdded |
Subscription | — | WorkflowNotificationDto! |
@ApGqlAuthorize() — filtered to the recipient (see §5/§6) |
markNotificationRead |
Mutation | (id: String!) |
Boolean! |
@ApGqlAuthorize(); audited (STATUS_CHANGE); ownership-checked |
markAllNotificationsRead |
Mutation | — | Boolean! |
@ApGqlAuthorize(); audited (STATUS_CHANGE); company-scoped bulk update |
3.3 The third subscription — onOrderEvent
inventory/order/order.resolver.ts exposes a separate subscription onOrderEvent → OrderEvent { event, payload: Order }, published by OrderService.emitOrderEvent over Redis topic "onOrderEvent" whenever a new sales/purchase order is created (OrderEvents.NEW_ORDER). It is not persisted to a notification collection — it is a live order-feed only, included here because it shares the same Redis pub/sub plumbing.
type Subscription {
onOrderEvent: OrderEvent
workflowNotificationAdded: WorkflowNotificationDto!
notificationCreated: Notification
}4. Business rules & how events trigger notifications
4.1 Generic notification — write-then-publish
NotificationService.create (notification.service.ts) is the single writer:
public async create(model: Notification, forAdminAndUser?: boolean): Promise<Notification> {
forAdminAndUser && (await this.notRepo.create({ ...model, userId: null })); // extra broadcast row
const notification = await this.notRepo.create(model);
this.redisPubSub
.publish(NOTIFICATION_CREATED, { [NOTIFICATION_CREATED]: notification }) // "notificationCreated"
.catch(() => {}); // pub/sub is best-effort; failure never blocks the write
return notification;
}Rules:
- Persist first, publish second. The DB row is authoritative; the pub/sub push is fire-and-forget (errors swallowed).
forAdminAndUserduplication: when true, a second row is written withuserId: null(an "admin/broadcast" copy) in addition to the user-targeted row.updateStatus(id, status)flipsstatustoREAD/UNREADvia a directupdateOne(also stampsupdatedAt).- Current emitters: the only programmatic caller of
NotificationService.createwithin the codebase is thecreateNotificationmutation (flagged for removal) and the legacyORDER/SCHEMEpaths the type enum implies. The generic collection is effectively a manual/legacy channel; the live event-driven channel is the workflow one (§4.2).
4.2 Workflow notification — emitted by the approval engine
The workflow approval engine (workflow/engine/workflow.engine.ts) calls WorkflowNotificationService.createForEvent(event, recipientIds) after every state change. createForEvent (workflow-notification.service.ts):
- Renders a message from the event via
buildMessage(below). - De-dupes recipient ids (
[...new Set(...)].filter(Boolean)); returns early if none. - Writes one row per recipient via
createMany. - Publishes each saved row to Redis topic
WORKFLOW_NOTIFICATION_ADDEDas{ workflowNotificationAdded: notification }(best-effort; errors logged).
private buildMessage(event: WorkflowEvent): string {
const { ref = "Document", stageName = "this stage", remark, actorName } = event.metadata || {};
switch (event.event) {
case TASK_CREATED: return `${ref} submitted for your approval — ${stageName}`;
case STAGE_ADVANCED: return `${ref} reached ${stageName} — awaiting your approval`;
case APPROVED: return `${ref} was approved by ${actorName || "an approver"}`;
case REJECTED: return `${ref} was rejected: ${remark || "—"} — returned to ${stageName}`;
case COMPLETED: return `${ref} has been fully approved`;
default: return `Workflow update for ${ref}`;
}
}Which event goes to whom (from workflow.engine.ts):
| Engine step | WorkflowEventType |
Recipients |
|---|---|---|
| Submit → first stage activated | TASK_CREATED |
approvers of the first stage |
| Approve, stage not yet complete | APPROVED |
the submitter (task.createdBy) |
| Approve, stage complete, next stage exists | STAGE_ADVANCED |
approvers of the next stage |
| Approve, stage complete, no next stage | COMPLETED |
the submitter |
| Reject | REJECTED |
the submitter + approvers of the stage it was returned to |
So the domains that emit notifications today are the approval-gated kinds: Purchase Requisition, Purchase Order, Sales Quotation, Sales Order, Leave, Claim, Loan, Advance (the
WorkflowTaskKindenum). All of them route exclusively through this single workflow-notification path.
4.3 Read/unread
- Generic:
updateNotificationStatus(id, "READ"|"UNREAD")— free toggle either direction. - Workflow:
markNotificationRead(id)setsread: true(ownership-checked: throws "Notification not found" ifuserId !== current).markAllNotificationsReadbulk-setsread: truefor the current user, additionally scoped bycompanyIdwhen present. Workflow notifications cannot be marked unread (no mutation; the admin blocks it client-side, §7).
4.4 Email channel (separate)
MailService (core/mailer/mailer.service.ts, EJS templates in core/mailer/templates/) is a transactional sender, not wired to the notification collections. Templates: welcome.ejs, resetPassword.ejs, confirmEmail.ejs, otp.ejs, verificationCodeEmail.ejs, KYCVerification.ejs, order.ejs. Actual live callers found: otp.service.ts (sendOtpEmail). KYC mail calls are commented out. Reached via MessageService.email (core/message/message.service.ts).
5. Permissions
- No dedicated permission module/action gates these resolvers — they are gated only by authentication (
@ApGqlAuthorize()), not by RBAC. See permissions-access for the general model; notifications are intentionally outside it because they are inherently user-scoped. - Scoping is by
userId, not by company permission.notificationPageandfindMyNotificationsalways filter to the current user. - Subscription auth caveat:
notificationCreatedis declaredauthNotRequired: trueand applies no per-user filter — every subscriber on a connection receives every published generic notification. By contrastworkflowNotificationAddedenforces a recipient filter (§6). The WebSocket handshake itself still requires anAuthorizationconnection param (app-level, §8).
6. Flows
6.1 Workflow approval notification (the live path) — submit example
1. Admin submits a PR/PO/Leave/… for approval (its domain screen → submit mutation)
2. WorkflowEngine.submit(): creates tasks + approvals, activates stage 1
3. WorkflowEventService.create({ event: TASK_CREATED, targetIds: stage-1 approvers, metadata:{ ref, stageName, actorName } })
4. WorkflowNotificationService.createForEvent(event, approverIds):
a. buildMessage → "PR-001 submitted for your approval — Manager Review"
b. createMany → one workflow_notifications row per approver (read:false)
c. for each row: redis.publish("WORKFLOW_NOTIFICATION_ADDED", { workflowNotificationAdded: row })
5. Each approver's open `workflowNotificationAdded` subscription receives the payload,
but the resolver `filter` compares payload.userId to context.req.user._id →
only the intended approver's socket forwards it.
6. Admin bell/feed prepends it live; the row is also returned by findMyNotifications on next fetch.
6.2 Read flow
Admin clicks a workflow notification
→ markNotificationRead(id) → service ownership check → update read:true → audit STATUS_CHANGE
→ admin optimistically sets status READ in local state
6.3 Generic notification (legacy/manual) path
createNotification(input) [flagged "Should be removed"]
→ NotificationService.create → write notifications row → redis.publish("notificationCreated")
→ ALL notificationCreated subscribers receive it (no per-user filter)
→ admin's mapGeneralToUnified merges it into the feed
6.4 Unhappy paths
- Pub/sub down: publish is
.catch(() => {})(generic) / logged (workflow) — the write still succeeds; the user sees it on next page fetch. No live push, no error surfaced. - Mark-read on a foreign notification (workflow): service throws
CustomError("Notification not found"). - Mark workflow notification unread: unsupported — admin shows toast
"Workflow notifications cannot be marked as unread.". - No recipients:
createForEventreturns early (nothing written, nothing published).
7. Admin UI
Page route src/pages/notifications.tsx → NotificationPage (modules/notification/page.tsx), titled "Enterprise Notifications", guarded by ApGuardBuilder.isAuth().
Unified feed architecture — the admin merges the two backend systems client-side:
NotificationContextProvider(modules/notification/context.tsx) is the sole consumer of bothuseNotificationQuery()(general) anduseWorkflowNotificationQuery()(workflow). Components read onlyuseNotificationState().fetchNotifications()calls both sources —notificationPage({ skip:0, take:200, keyword })andfindMyNotifications({})— thenmergeNotifications()maps each into a commonIUnifiedNotificationand sorts bycreatedAtdesc.- Two live subscriptions are wired in the context:
useNotificationSubscription(generalnotificationCreated) anduseWorkflowNotificationSubscription(workflowNotificationAdded). Both prepend new items (dedup by_id). Bothskipunlessstatus === 'authenticated'and anaccessTokenexists.
Client-side enrichment (modules/notification/helpers.ts) — these classifications are admin-only inferences, not backend fields:
inferModule()→NotificationModule(Sales | Purchase | Inventory | Finance | HR | System | Workflow) from type/kind + keyword matching on title/message.inferPriority()→NotificationPriority(info | warning | critical | approval) from keyword heuristics; anysource === 'workflow'defaults toapproval.getActionRoute()/getActionLabel()→ deep-link (e.g. workflow task →/workflow?task=…, order →/order/:ref, scheme →/scheme/user/:userId).
Context methods (drive all UI):
fetchNotifications(),markAsRead(id, source),markAsUnread(id, source)(blocks workflow source with a toast),markAllAsRead()(calls workflowmarkAllRead+ per-row generalupdateNotificationStatus),deleteNotification(id)(local-only — removes from state + success toast; no backend delete mutation exists).unreadCountderived from items withstatus === 'UNREAD'.- Filtering (
filteredNotifications) is fully client-side by status / module / priority / date-range / keyword.
Components: NotificationCard, NotificationDetail, NotificationFilters, plus icon.tsx / listItem.tsx (bell-dropdown list item). Layout: two-panel (list + detail) on desktop, Ant Design Drawer on mobile (< 1024px); bulk-select checkboxes with "Mark Read"; Ant Pagination over the in-memory merged list (page size 10); query-param ?id= scroll-to-highlight from the bell dropdown (auto-marks read).
UX caveats baked into the merge: workflow notifications can't be unread; "delete" is cosmetic (state-only); priority/module are heuristic; general
notificationCreatedarrives un-filtered server-side so the client must dedup/scope itself.
8. Dependencies & integrations
- Redis pub/sub —
RedisService extends RedisPubSub(core/pubsub/redis.service.ts,graphql-redis-subscriptions+ioredis), provided globally byRedisModule. Topics:notificationCreated,WORKFLOW_NOTIFICATION_ADDED,onOrderEvent. Also doubles as a generic Redis cache (get/set/has/page). Key prefix${redis_prefix||"zyncount"}:. - GraphQL subscriptions transport — configured in
app.module.tsGraphQlOptions.subscriptions: bothgraphql-wsandsubscriptions-transport-ws.onConnectrequires anAuthorizationconnection param (throws "Authentication token is required" otherwise); the token is injected into request headers so per-subscription auth/filters can resolvecontext.req.user. - Workflow engine — sole live producer of workflow notifications. See workflow-approval-engine.
- Audit trail — status-change/create mutations carry
@AuditMeta(...). See audit-trail. - Mailer —
MailModule(@nestjs-modules/mailer+ EJS, SMTP via envsmtp_*), reached throughMessageService.email. Logo/store links frommailer.config.ts. - Firebase (push) —
FirebaseServicepresent but uninitialized and uncalled; treat push as not-implemented. - MessageService (
core/message) — aggregatesemail(mailer),whatsapp(UltraMsg),sms(Exabytes),push(Firebase). Only email/WhatsApp OTP paths are live.
9. Gotchas & project-specific rules
- Two systems, one feed. Generic
NotificationandWorkflowNotificationare unrelated collections/resolvers; unification is only in the admin (mergeNotifications). A rebuild can keep them separate or merge server-side — current code merges client-side. notificationCreatedhas no per-user filter — every subscriber gets every generic notification. OnlyworkflowNotificationAddedfilters by recipient (payload.userId === context.req.user._id). If you port this, add a filter to the generic subscription.- The generic channel is barely used. Its only resolver-level creator is
createNotification, explicitly commented// ---Should be Removed---. The real event-driven traffic is workflow notifications. Plan accordingly. - No preferences and no delete. There is no settings entity and no delete mutation — admin "delete" is local state only; "mark all read" is the only bulk server action.
- Best-effort delivery. Publishes are swallowed/logged on failure; the DB row is the source of truth. Clients must fetch on load, not rely solely on the live socket.
- DTO/schema drift. Generic
NotificationGraphQL type mapsupdatedAtasNumberbutcreatedAtasString;WorkflowNotificationDtodropseventId/workflowId. Match the DTOs, not the schemas, for the API surface. - Push is a stub.
FirebaseService.sendPushNotificationexists butinitializeAppis commented and nothing calls it — do not document push as a working channel. forAdminAndUserduplicate row writes a seconduserId: nullnotification — a quirk of the generic writer to surface a broadcast/admin copy.