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 Notification and a workflow-specific WorkflowNotification — 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 type ORDER or SCHEME, 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-readable message and a read flag.
  • Real-time delivery via three GraphQL subscriptions backed by Redis pub/sub (graphql-redis-subscriptions over ioredis).
  • 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 FirebaseService exists (src/core/firebase/firebase.service.ts) with sendPushNotification / sendPushNotificationToTopic, but admin.initializeApp(...) is commented out and no module calls it — push is dormant/unimplemented. Treat it as a stub.
  • No SMS/WhatsApp tied to notifications. MessageService exposes sms (Exabytes) and whatsapp (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 Notification ObjectType (notification.dto.ts) is a separate, hand-written DTO (not derived from the schema). It exposes both refId and the inherited ref, and types createdAt as String but updatedAt as Number (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, createdAt only (no eventId/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.
  • notificationPage resolves userId server-side: page.userId || user?._id || user?.id. It runs on the pre-company "select company" screen, hence ignoreCompanyQuery: true.
  • The repository page() builds an aggregation $match from userId, type, status, and a regex keyword over title | message | ref, then applies handlePageFacet / handlePageResult for 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).
  • forAdminAndUser duplication: when true, a second row is written with userId: null (an "admin/broadcast" copy) in addition to the user-targeted row.
  • updateStatus(id, status) flips status to READ/UNREAD via a direct updateOne (also stamps updatedAt).
  • Current emitters: the only programmatic caller of NotificationService.create within the codebase is the createNotification mutation (flagged for removal) and the legacy ORDER/SCHEME paths 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):

  1. Renders a message from the event via buildMessage (below).
  2. De-dupes recipient ids ([...new Set(...)].filter(Boolean)); returns early if none.
  3. Writes one row per recipient via createMany.
  4. Publishes each saved row to Redis topic WORKFLOW_NOTIFICATION_ADDED as { 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 WorkflowTaskKind enum). 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) sets read: true (ownership-checked: throws "Notification not found" if userId !== current). markAllNotificationsRead bulk-sets read: true for the current user, additionally scoped by companyId when 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. notificationPage and findMyNotifications always filter to the current user.
  • Subscription auth caveat: notificationCreated is declared authNotRequired: true and applies no per-user filter — every subscriber on a connection receives every published generic notification. By contrast workflowNotificationAdded enforces a recipient filter (§6). The WebSocket handshake itself still requires an Authorization connection 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: createForEvent returns early (nothing written, nothing published).

7. Admin UI

Page route src/pages/notifications.tsxNotificationPage (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 both useNotificationQuery() (general) and useWorkflowNotificationQuery() (workflow). Components read only useNotificationState().
  • fetchNotifications() calls both sources — notificationPage({ skip:0, take:200, keyword }) and findMyNotifications({}) — then mergeNotifications() maps each into a common IUnifiedNotification and sorts by createdAt desc.
  • Two live subscriptions are wired in the context: useNotificationSubscription (general notificationCreated) and useWorkflowNotificationSubscription (workflowNotificationAdded). Both prepend new items (dedup by _id). Both skip unless status === 'authenticated' and an accessToken exists.

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; any source === 'workflow' defaults to approval.
  • 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 workflow markAllRead + per-row general updateNotificationStatus), deleteNotification(id) (local-only — removes from state + success toast; no backend delete mutation exists).
  • unreadCount derived from items with status === '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 notificationCreated arrives un-filtered server-side so the client must dedup/scope itself.


8. Dependencies & integrations

  • Redis pub/subRedisService extends RedisPubSub (core/pubsub/redis.service.ts, graphql-redis-subscriptions + ioredis), provided globally by RedisModule. 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.ts GraphQlOptions.subscriptions: both graphql-ws and subscriptions-transport-ws. onConnect requires an Authorization connection param (throws "Authentication token is required" otherwise); the token is injected into request headers so per-subscription auth/filters can resolve context.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.
  • MailerMailModule (@nestjs-modules/mailer + EJS, SMTP via env smtp_*), reached through MessageService.email. Logo/store links from mailer.config.ts.
  • Firebase (push)FirebaseService present but uninitialized and uncalled; treat push as not-implemented.
  • MessageService (core/message) — aggregates email (mailer), whatsapp (UltraMsg), sms (Exabytes), push (Firebase). Only email/WhatsApp OTP paths are live.

9. Gotchas & project-specific rules

  • Two systems, one feed. Generic Notification and WorkflowNotification are 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.
  • notificationCreated has no per-user filter — every subscriber gets every generic notification. Only workflowNotificationAdded filters 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 Notification GraphQL type maps updatedAt as Number but createdAt as String; WorkflowNotificationDto drops eventId/workflowId. Match the DTOs, not the schemas, for the API surface.
  • Push is a stub. FirebaseService.sendPushNotification exists but initializeApp is commented and nothing calls it — do not document push as a working channel.
  • forAdminAndUser duplicate row writes a second userId: null notification — a quirk of the generic writer to surface a broadcast/admin copy.