Skip to main content

FCM Notifications Service

๐Ÿ“– Overviewโ€‹

Service Path: internal/api/v1/notifications-center/services/fcm.service.js

The FCM (Firebase Cloud Messaging) Notifications service manages push notification viewing and read-marking for notifications sent from various modules. Core responsibilities include:

  • Paginated Listing: Retrieve FCM notifications with flexible pagination
  • Module Filtering: Filter by source module (projects, CRM, etc.) or exclude modules
  • Read Status Calculation: Compute per-user read status from read_by array
  • Individual Read Marking: Mark single notifications as read atomically
  • Metadata Optimization: Use pre-computed metadata to avoid expensive lookups
  • Dismissal Awareness: Exclude dismissed notifications from user's view

๐Ÿ—„๏ธ Collections Usedโ€‹

  • fcm-notifications (link removed - file does not exist) - Firebase Cloud Messaging push notifications with metadata

๐Ÿ”„ Data Flowโ€‹

Notification Listing Flowโ€‹

sequenceDiagram
participant Client
participant FcmService
participant MongoDB

Client->>FcmService: getFCM(skip, limit, module, type, all, sortBy, sortOrder, accountId, userId)

FcmService->>FcmService: Build match conditions
Note over FcmService: account, users, removed_by<br/>module, type filters

FcmService->>MongoDB: Aggregate with $facet
Note over MongoDB: data: sort, skip, limit, addFields<br/>count: total documents

MongoDB-->>FcmService: { data: [...notifications], count: [{total: 45}] }

FcmService->>FcmService: Add is_read field
Note over FcmService: Check if userId in read_by array

FcmService->>FcmService: Extract metadata
Note over FcmService: activity, report, order fields

FcmService-->>Client: { data: [notifications], count: [{total: 45}] }

Individual Read Operationโ€‹

flowchart TD
A[Client: Mark Notification as Read] --> B[Build Conditions]

B --> C{Notification Exists?}
C -->|No| D[Throw 404 Not Found]

C -->|Yes| E{Already Read?}
E -->|Yes| D
E -->|No| F{Dismissed?}
F -->|Yes| D

F -->|No| G[Update: $addToSet read_by]
G --> H[Return Success]

style D fill:#ffebee
style H fill:#e8f5e9

๐Ÿ”ง Business Logic & Functionsโ€‹

getFCM({ skip, limit, module, type, all, sortBy, sortOrder, accountId, userId })โ€‹

Purpose: Retrieve paginated list of FCM notifications with filtering and read status

Parameters:

  • skip (Number) - Number of documents to skip (pagination)
  • limit (Number) - Maximum documents to return
  • module (String, optional) - Filter by source module ('projects', 'crm', etc.)
  • type (String, optional) - Filter by notification type
  • all (Boolean, optional) - If true, exclude 'projects' module
  • sortBy (String, optional) - Field to sort by (default: 'created_at')
  • sortOrder (String, optional) - Sort direction 'asc' or 'desc' (default: 'desc')
  • accountId (ObjectId) - Account to query
  • userId (ObjectId) - User viewing notifications

Returns:

{
data: [
{
id: ObjectId,
message: {
title: String,
body: String,
data: Object // Module-specific payload
},
module: String,
type: String,
is_read: Boolean,
created_at: Date,
updated_at: Date,
activity: { // Only if metadata exists
activity_type: String,
event_type: String,
report: Object,
order: Object
}
}
],
count: [{ total: Number }]
}

Business Logic Flow:

  1. Convert User and Account IDs

    const userObjectId = new mongoose.Types.ObjectId(userId);
    const accountObjectId = new mongoose.Types.ObjectId(accountId);
  2. Build Match Options

    const options = {
    account: accountObjectId,
    users: userObjectId, // User must be recipient
    removed_by: { $ne: userObjectId }, // Not dismissed by user
    };

    if (module) options.module = module; // Optional module filter
    if (type) options.type = type; // Optional type filter
    if (all) options.module = { $ne: 'projects' }; // Exclude projects if 'all' is true
    • users field: Array of recipient ObjectIds, query matches if userId in array
    • removed_by: Array of users who dismissed notification
    • all parameter: Special case to exclude projects module
  3. Build Sort Criteria

    const sort =
    sortBy && sortOrder ? { [sortBy]: sortOrder === 'asc' ? 1 : -1 } : { created_at: -1 };
    • Default: newest first (created_at: -1)
    • Dynamic field sorting supported
  4. Create Aggregation Pipeline with Facets

    const aggregationQuery = [
    { $match: options },
    {
    $facet: {
    data: [
    { $sort: sort },
    { $skip: skip },
    { $limit: limit },
    // Add is_read field based on user's read status
    {
    $addFields: {
    is_read: {
    $cond: {
    if: { $in: [userObjectId, { $ifNull: ['$read_by', []] }] },
    then: true,
    else: false,
    },
    },
    },
    },
    // Project fields for response
    {
    $project: {
    id: '$_id',
    _id: 0,
    message: 1,
    module: 1,
    type: 1,
    is_read: 1,
    created_at: 1,
    updated_at: 1,
    // Conditionally include metadata if exists
    activity: {
    $cond: {
    if: { $ifNull: ['$metadata', false] },
    then: {
    activity_type: '$metadata.activity.activity_type',
    event_type: '$metadata.activity.event_type',
    report: '$metadata.report',
    order: '$metadata.order',
    },
    else: '$$REMOVE',
    },
    },
    },
    },
    ],
    count: [{ $count: 'total' }],
    },
    },
    ];
    • $facet: Runs multiple aggregations in parallel
      • data facet: Sorted, paginated notification list
      • count facet: Total matching documents
    • $addFields: Calculates is_read dynamically
      • $in operator: Checks if userObjectId in read_by array
      • $ifNull: Handles missing read_by array (defaults to [])
    • $project: Shapes response, renames _id to id
    • $$REMOVE: Excludes activity field if no metadata
  5. Execute Aggregation

    const fcmData = await FcmNotification.aggregate(aggregationQuery);

    return fcmData[0] || {};
    • Returns first element of result array
    • Returns empty object if no results

Key Business Rules:

  • Recipient Check: User must be in users array to see notification
  • Dismissal Exclusion: Dismissed notifications (removed_by contains userId) excluded
  • Read Status Calculation: Computed per-user from read_by array
  • Module Filtering:
    • module=projects: Only project notifications
    • all=true: All modules except projects
    • No filter: All modules
  • Metadata Optimization: Uses pre-computed metadata field to avoid $lookup
  • Pagination: Standard skip/limit pattern
  • Default Sorting: Newest first (created_at descending)

Example Usage:

// Get first page of project notifications
const result = await getFCM({
skip: 0,
limit: 25,
module: 'projects',
sortBy: 'created_at',
sortOrder: 'desc',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});
// { data: [...25 notifications], count: [{total: 45}] }

// Get all notifications except projects
const allResult = await getFCM({
skip: 0,
limit: 25,
all: true,
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});

Side Effects:

  • None (read-only operation)

Performance Considerations:

  • Index Requirements:

    // Primary index for listing
    { account: 1, users: 1, module: 1, created_at: -1 }

    // Index for filtering read/dismissed
    { account: 1, users: 1, read_by: 1, removed_by: 1 }
  • Metadata Optimization: Pre-computed metadata eliminates expensive $lookup operations

  • Expected Time: 50-150ms for 100-1000 notifications

  • Facet Performance: Single query for data + count reduces round trips


readFcm({ notificationId, accountId, userId, module, type })โ€‹

Purpose: Mark single FCM notification as read for authenticated user

Parameters:

  • notificationId (ObjectId) - Notification ID to mark as read
  • accountId (ObjectId) - Account notification belongs to
  • userId (ObjectId) - User marking as read
  • module (String, optional) - Verify notification belongs to module (security)
  • type (String, optional) - Verify notification has type (security)

Returns:

{
success: true;
}

Business Logic Flow:

  1. Convert IDs to ObjectIds

    const accountObjectId = new mongoose.Types.ObjectId(accountId);
    const userObjectId = new mongoose.Types.ObjectId(userId);
    const notificationObjectId = new mongoose.Types.ObjectId(notificationId);
  2. Build Match Conditions

    const conditions = {
    _id: notificationObjectId,
    account: accountObjectId,
    users: userObjectId, // Must be recipient
    removed_by: { $ne: userObjectId }, // Not dismissed
    read_by: { $ne: userObjectId }, // Not already read
    };

    if (module) conditions.module = module; // Optional verification
    if (type) conditions.type = type; // Optional verification
    • Multiple security checks: Account, recipient, not dismissed, not read
    • Optional module/type: Additional verification for security
  3. Find Notification

    const fcmData = await FcmNotification.findOne(conditions).lean().exec();

    if (!fcmData) {
    throw notFound('Notification not found');
    }
    • lean(): Returns plain JavaScript object (not Mongoose document)
    • Throws 404: If not found, already read, or dismissed
  4. Update Notification (Add User to read_by)

    await FcmNotification.updateOne(conditions, {
    $addToSet: { read_by: userObjectId },
    });
    • $addToSet: Adds userId only if not already present (idempotent)
    • Uses same conditions as findOne for safety
  5. Return Success

    return { success: true };

Key Business Rules:

  • Already Read: Returns 404 if userId already in read_by array
  • Dismissed: Returns 404 if userId in removed_by array (can't unmark dismissal)
  • Not Recipient: Returns 404 if userId not in users array (security)
  • Atomic Operation: Uses $addToSet for idempotent updates
  • Multi-User Safe: Only adds current user to read_by, others unaffected
  • Optional Verification: Module/type parameters add extra security layer

Example Usage:

// Mark notification as read
try {
const result = await readFcm({
notificationId: '670512a5e4b0f8a5c9d3e1b2',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
module: 'projects', // Optional verification
});
// { success: true }
} catch (error) {
// error.message === 'Notification not found'
// Could mean: not exists, already read, dismissed, or wrong module
}

Side Effects:

  • Updates Notification: Adds userId to read_by array
  • Triggers Change Streams: If MongoDB change streams enabled

Performance Considerations:

  • Index Requirements:

    // Primary index for lookup
    { _id: 1, account: 1, users: 1, read_by: 1, removed_by: 1 }
  • Expected Time: 10-30ms (single document operation)

  • Write Concern: Uses default MongoDB write concern


๐Ÿ”€ Integration Pointsโ€‹

Notification Sourcesโ€‹

Projects Module:

// When task assigned
await FcmNotification.create({
account: accountId,
users: [assignedUserId],
module: 'projects',
type: 'task_assigned',
message: {
title: 'New Task Assignment',
body: `You have been assigned to: ${taskName}`,
data: { project_id, task_id },
},
metadata: {
activity: {
activity_type: 'task',
event_type: 'assigned',
},
},
});

CRM Module:

// When deal status changes
await FcmNotification.create({
account: accountId,
users: dealOwnerIds,
module: 'crm',
type: 'deal_status_changed',
message: {
title: 'Deal Status Updated',
body: `${dealName} moved to ${newStatus}`,
data: { deal_id, old_status, new_status },
},
});

Frontend Integrationโ€‹

Notification List Component:

// Load notifications with pagination
const loadNotifications = async (page = 1, module = null) => {
const skip = (page - 1) * 25;

const response = await fetch(
`/v1/notifications-center/fcm?page=${skip}&limit=25${
module ? `&module=${module}` : ''
}&sort_by=created_at&sort_order=desc`,
{ headers: { Authorization: `Bearer ${token}` } },
);

const { data, count } = await response.json();

return {
notifications: data,
total: count[0]?.total || 0,
hasMore: skip + data.length < (count[0]?.total || 0),
};
};

// Mark as read on click
const handleNotificationClick = async notification => {
try {
await fetch(`/v1/notifications-center/fcm/read/${notification.id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
});

// Update UI to show as read
setNotificationRead(notification.id);

// Navigate to related resource
if (notification.message.data.click_action) {
window.location.href = notification.message.data.click_action;
}
} catch (error) {
// Already read or dismissed, ignore
}
};

Real-time Updates:

// Socket.IO listener for new notifications
socket.on('fcm:new', notification => {
// Add to top of list
prependNotification(notification);

// Show toast
showToast(notification.message.title, notification.message.body);

// Update unread count
incrementUnreadCount();
});

๐Ÿงช Edge Cases & Special Handlingโ€‹

Already Read Notificationโ€‹

Attempt to Mark as Read Again:

// User already marked as read
await readFcm({
notificationId: '670512a5e4b0f8a5c9d3e1b2',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});
// Throws: notFound('Notification not found')
  • Query filters out notifications where read_by contains userId
  • Returns 404 instead of success (idempotent from user perspective)

Dismissed Notificationsโ€‹

Cannot Mark Dismissed as Read:

// User previously dismissed notification
await FcmNotification.updateOne({ _id: notificationId }, { $addToSet: { removed_by: userId } });

// Attempt to mark as read fails
await readFcm({ notificationId, accountId, userId });
// Throws: notFound('Notification not found')
  • Dismissed notifications excluded from query via removed_by filter
  • Security: prevents accidentally undismissing notifications

Module Verificationโ€‹

Optional Security Check:

// Notification belongs to 'crm' module
await readFcm({
notificationId: '670512a5e4b0f8a5c9d3e1b2',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
module: 'projects', // Wrong module
});
// Throws: notFound('Notification not found')
  • Frontend can pass module for extra verification
  • Prevents marking notifications from wrong context

Multi-User Scenariosโ€‹

Shared Notifications:

// Notification sent to multiple users
const notification = await FcmNotification.create({
account: accountId,
users: [user1Id, user2Id, user3Id],
module: 'projects',
message: { title: 'Team Update', body: '...' },
});

// User 1 marks as read
await readFcm({ notificationId: notification._id, accountId, userId: user1Id });
// Only adds user1Id to read_by array

// User 2 and 3 still see as unread
const user2Result = await getFCM({
skip: 0,
limit: 25,
accountId,
userId: user2Id,
});
// notification.is_read === false for user 2

Empty Metadataโ€‹

Conditional Activity Field:

// Notification without metadata field
const result = await getFCM({ skip: 0, limit: 25, accountId, userId });
// Notification in result:
{
id: '...',
message: {...},
module: 'crm',
is_read: false,
// No activity field ($$REMOVE removes it)
}

// Notification with metadata
{
id: '...',
message: {...},
module: 'projects',
is_read: true,
activity: {
activity_type: 'task',
event_type: 'assigned',
report: null,
order: null
}
}

Exclude Projects Filterโ€‹

'all' Parameter:

// Get all notifications except projects
const result = await getFCM({
skip: 0,
limit: 25,
all: true,
accountId,
userId,
});
// Returns notifications where module != 'projects'
// Used for "All Notifications" tab excluding project notifications

โš ๏ธ Important Notesโ€‹

  1. Metadata Optimization: The metadata field is pre-computed during notification creation to avoid expensive $lookup operations during listing. Modules should populate metadata when creating notifications.

  2. is_read Calculation: Read status calculated dynamically in aggregation using $in operator. Not stored directly in notification document, allowing per-user read tracking.

  3. Dismissal vs Read: Two separate arrays (removed_by vs read_by). Dismissal hides notification entirely, read marks as viewed. Once dismissed, cannot be unmarked via read operation.

  4. Multi-User Support: Single notification can target multiple users via users array. Each user's read and dismissal status tracked independently in respective arrays.

  5. 404 Response: readFcm throws 404 for multiple reasons: not found, already read, dismissed, not recipient, or wrong module. Frontend should handle all as "notification not available".

  6. Idempotent Updates: Uses $addToSet for read_by updates. Safe to call readFcm multiple times without creating duplicate entries.

  7. Index Requirements: Proper indexes critical for performance. Compound index on (account, users, module, created_at) supports most common query patterns.

  8. Facet Performance: $facet runs data and count aggregations in parallel within single query. More efficient than separate queries but uses more memory.

  9. Sort Field Flexibility: Supports sorting by any field, but index should match common sort patterns. created_at is most common and has dedicated index.

  10. Module Filtering: module parameter supports exact match only. For multiple modules, client should make separate requests or filter client-side.

  • Common Notifications Service - Unified count and bulk read operations
  • Reminder Service - CRM reminder notifications
  • FCM Notifications Collection (link removed - file does not exist) - Schema and indexes
  • Projects Module (link removed - file does not exist) - Project notification creation
  • CRM Module (link removed - file does not exist) - CRM notification creation
  • Socket Service (link removed - file does not exist) - Real-time notification events
๐Ÿ’ฌ

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM