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 returnmodule(String, optional) - Filter by source module ('projects', 'crm', etc.)type(String, optional) - Filter by notification typeall(Boolean, optional) - If true, exclude 'projects' modulesortBy(String, optional) - Field to sort by (default: 'created_at')sortOrder(String, optional) - Sort direction 'asc' or 'desc' (default: 'desc')accountId(ObjectId) - Account to queryuserId(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:
-
Convert User and Account IDs
const userObjectId = new mongoose.Types.ObjectId(userId);
const accountObjectId = new mongoose.Types.ObjectId(accountId); -
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
-
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
-
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
- $facet: Runs multiple aggregations in parallel
-
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 notificationsall=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 readaccountId(ObjectId) - Account notification belongs touserId(ObjectId) - User marking as readmodule(String, optional) - Verify notification belongs to module (security)type(String, optional) - Verify notification has type (security)
Returns:
{
success: true;
}
Business Logic Flow:
-
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); -
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
-
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
-
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
-
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โ
-
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.
-
is_read Calculation: Read status calculated dynamically in aggregation using $in operator. Not stored directly in notification document, allowing per-user read tracking.
-
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.
-
Multi-User Support: Single notification can target multiple users via users array. Each user's read and dismissal status tracked independently in respective arrays.
-
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".
-
Idempotent Updates: Uses $addToSet for read_by updates. Safe to call readFcm multiple times without creating duplicate entries.
-
Index Requirements: Proper indexes critical for performance. Compound index on (account, users, module, created_at) supports most common query patterns.
-
Facet Performance: $facet runs data and count aggregations in parallel within single query. More efficient than separate queries but uses more memory.
-
Sort Field Flexibility: Supports sorting by any field, but index should match common sort patterns. created_at is most common and has dedicated index.
-
Module Filtering: module parameter supports exact match only. For multiple modules, client should make separate requests or filter client-side.
๐ Related Documentationโ
- 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