Reminder Notifications Service
๐ Overviewโ
Service Path: internal/api/v1/notifications-center/services/reminder.service.js
The Reminder Notifications service manages CRM reminder notifications including tasks and events with due dates. Core responsibilities include:
- Category-Based Filtering: Filter by upcoming, past due, or completed reminders
- Rich Data Population: Load related deals, contacts, businesses, and people
- Dynamic Click Actions: Generate deep-link URLs to deal/contact pages
- Category Counts: Calculate counts for all categories in single query
- Pinned Reminders: Prioritize pinned reminders in sort order
- Read Status Tracking: Per-user read status from read_by array
- Deep Lookups: Multi-level $lookup for comprehensive data
๐๏ธ Collections Usedโ
- crm.reminders (link removed - file does not exist) - CRM reminder tasks and events
- crm.deals (link removed - file does not exist) - Deal data for deal-type reminders
- crm.contacts (link removed - file does not exist) - Contact, business, and person data
๐ Data Flowโ
Reminder Category Logicโ
flowchart TD
A[Reminder Created] --> B{Check reminder_status}
B -->|false - Not Completed| C{Compare due_date_time to now}
B -->|true - Completed| D[COMPLETED Category]
C -->|due_date >= now| E[UPCOMING Category]
C -->|due_date < now| F[PAST DUE Category]
E -->|Time Passes| C
F -->|User Completes| G[Update reminder_status = true]
G --> D
style E fill:#e8f5e9
style F fill:#ffebee
style D fill:#e3f2fd
Data Population Flowโ
sequenceDiagram
participant Client
participant ReminderService
participant RemindersDB
participant DealsDB
participant ContactsDB
Client->>ReminderService: getReminder(skip, limit, type, accountId, userId)
ReminderService->>RemindersDB: Aggregate with $facet
Note over RemindersDB: counts facet + data facet
alt Deal-Type Reminder
RemindersDB->>DealsDB: $lookup deal
DealsDB->>ContactsDB: $lookup business + person
ContactsDB-->>RemindersDB: Populated deal data
else Contact-Type Reminder
RemindersDB->>ContactsDB: $lookup contact
ContactsDB->>ContactsDB: $lookup businesses, people, deals
ContactsDB-->>RemindersDB: Populated contact data
end
RemindersDB->>RemindersDB: Build click_action URLs
RemindersDB-->>ReminderService: { counts, data }
ReminderService-->>Client: Reminders with populated data
๐ง Business Logic & Functionsโ
getReminder({ skip, limit, type, accountId, userId })โ
Purpose: Retrieve paginated list of CRM reminders with category filtering and rich data population
Parameters:
skip(Number) - Number of documents to skip (pagination)limit(Number) - Maximum documents to returntype(String) - Category filter: 'upcoming', 'past_due', 'completed', or 'all'accountId(ObjectId) - Account to queryuserId(ObjectId) - User assigned to reminders
Returns:
{
counts: [{
past_due: 5,
upcoming: 12,
completed: 23,
total: 40
}],
data: [
{
id: ObjectId,
headline: String,
content: String,
type: 'deal' | 'contact',
reminder_type: 'task' | 'event' | 'call',
reminder_category: 'upcoming' | 'past_due' | 'completed',
priority: 'low' | 'medium' | 'high',
pinned: Boolean,
reminder_status: Boolean,
due_date_time: Date,
is_read: Boolean,
created_at: Date,
updated_at: Date,
assigned: ObjectId,
created_by: ObjectId,
deal: { // If type='deal'
id: ObjectId,
name: String,
status: String,
pipeline_id: ObjectId,
business: { /* contact data */ },
person: { /* contact data */ }
},
contact: { // If type='contact'
id: ObjectId,
name: String,
email: String,
phone: String,
image: String,
type: 'person' | 'business',
deals: [/* deal objects */],
businesses: [/* business objects */],
people: [/* person objects */]
},
click_action: String // Generated deep-link URL
}
]
}
Business Logic Flow:
-
Convert IDs and Get Current Date
const accountObjectId = new mongoose.Types.ObjectId(accountId);
const uidObjectId = new mongoose.Types.ObjectId(userId);
const currentDate = new Date(); -
Get Active Domain Name
const domainName = await getActiveDomain({
accountId: accountObjectId.toString(),
proto: true,
});- Fetches account's custom domain or default domain
- Includes protocol (https://)
- Used for building click_action URLs
-
Build Base Match Options
const options = {
account: accountObjectId,
assigned: uidObjectId, // User must be assigned
};- Only shows reminders assigned to specific user
- Account-scoped for multi-tenancy
-
Define Category Filters
const reminderFilters = {
upcoming: {
reminder_status: false,
due_date_time: { $gte: currentDate },
},
past_due: {
due_date_time: { $lt: currentDate },
reminder_status: false,
},
completed: {
reminder_status: true,
},
};
const filter = reminderFilters[type] || {};- upcoming: Not completed AND due date in future
- past_due: Not completed AND due date in past
- completed: Marked as complete (regardless of due date)
- Empty filter: Returns all categories if type not recognized
-
Create Aggregation Pipeline with Facets
const query = [
{ $match: options },
{
$facet: {
// Calculate category counts
counts: [
{
$group: {
_id: null,
past_due: {
$sum: {
$cond: [
{
$and: [
{ $lt: ['$due_date_time', currentDate] },
{ $eq: ['$reminder_status', false] },
],
},
1,
0,
],
},
},
upcoming: {
$sum: {
$cond: [
{
$and: [
{ $gte: ['$due_date_time', currentDate] },
{ $eq: ['$reminder_status', false] },
],
},
1,
0,
],
},
},
completed: {
$sum: { $cond: [{ $eq: ['$reminder_status', true] }, 1, 0] },
},
total: { $sum: 1 },
},
},
],
// Get filtered, paginated data
data: [
{ $match: filter },
{ $sort: { pinned: -1, due_date_time: -1 } },
{ $skip: skip },
{ $limit: limit },
// ... (continued below)
],
},
},
];- counts facet: Calculates all category counts in single pass
- Uses $cond to count documents matching each category
- Returns single document with all counts
- data facet: Filters by selected category and paginates
- counts facet: Calculates all category counts in single pass
-
Add reminder_category Field
{
$addFields: {
reminder_category: {
$cond: [
{ $eq: ['$reminder_status', true] },
'completed',
{
$cond: [
{ $lt: ['$due_date_time', currentDate] },
'past_due',
'upcoming',
],
},
],
},
},
}- Dynamically calculates category for each reminder
- Completed takes precedence over due date logic
-
Lookup Deal Data (for type='deal')
{
$lookup: {
from: 'crm.deals',
localField: 'deal',
foreignField: '_id',
as: 'deal',
pipeline: [
// Nested lookup for business
{
$lookup: {
from: 'crm.contacts',
localField: 'business',
foreignField: '_id',
as: 'business',
},
},
// Nested lookup for person
{
$lookup: {
from: 'crm.contacts',
localField: 'person',
foreignField: '_id',
as: 'person',
},
},
{
$project: {
_id: 0,
id: '$_id',
name: 1,
status: 1,
pipeline_id: 1,
business: { $arrayElemAt: ['$business', 0] },
person: { $arrayElemAt: ['$person', 0] },
},
},
],
},
}- 3-level lookup: reminder โ deal โ business/person contacts
- Returns single deal object with nested contact data
-
Lookup Contact Data (for type='contact')
{
$lookup: {
from: 'crm.contacts',
localField: 'contact',
foreignField: '_id',
as: 'contact',
pipeline: [
// Lookup related businesses
{
$lookup: {
from: 'crm.contacts',
localField: 'businesses',
foreignField: '_id',
as: 'businesses',
},
},
// Lookup related people
{
$lookup: {
from: 'crm.contacts',
localField: 'people',
foreignField: '_id',
as: 'people',
},
},
// Lookup related deals
{
$lookup: {
from: 'crm.deals',
localField: 'deals',
foreignField: '_id',
as: 'deals',
},
},
{
$project: {
_id: 0,
id: '$_id',
name: 1,
email: 1,
image: 1,
phone: 1,
type: 1,
deals: 1,
businesses: 1,
people: 1,
},
},
],
},
}- 4-level lookup: reminder โ contact โ businesses/people/deals
- Returns comprehensive contact relationship data
-
Flatten Arrays and Add is_read
{
$addFields: {
deal: { $arrayElemAt: ['$deal', 0] },
contact: { $arrayElemAt: ['$contact', 0] },
is_read: {
$cond: {
if: { $in: [uidObjectId, { $ifNull: ['$read_by', []] }] },
then: true,
else: false,
},
},
},
}- $lookup returns arrays, convert to single object
- Calculate is_read per user from read_by array
-
Build click_action URLs
{
$project: {
id: '$_id',
_id: 0,
headline: 1,
content: 1,
type: 1,
reminder_type: 1,
reminder_category: 1,
priority: 1,
pinned: 1,
reminder_status: 1,
due_date_time: 1,
is_read: 1,
created_at: '$createdAt',
updated_at: '$updatedAt',
assigned: 1,
created_by: 1,
deal: 1,
contact: 1,
click_action: {
$switch: {
branches: [
{
case: { $eq: ['$type', 'deal'] },
then: {
$cond: {
if: { $ifNull: ['$deal', false] },
then: {
$concat: [
domainName,
'/deals/my-deal?type=deal',
'&pipeline=',
{ $toString: { $ifNull: ['$deal.pipeline_id', ''] } },
'&id=',
{ $toString: { $ifNull: ['$deal.id', ''] } },
],
},
else: domainName,
},
},
},
{
case: { $eq: ['$type', 'contact'] },
then: {
$cond: {
if: { $ifNull: ['$contact', false] },
then: {
$concat: [
domainName,
'/contacts?type=',
{
$cond: {
if: { $eq: ['$contact.type', 'person'] },
then: 'people',
else: 'businesses',
},
},
'&id=',
{ $toString: { $ifNull: ['$contact.id', ''] } },
'&tab=activity',
],
},
else: domainName,
},
},
},
],
default: domainName,
},
},
},
}- Deal URLs:
/deals/my-deal?type=deal&pipeline={id}&id={id} - Contact URLs:
/contacts?type=people&id={id}&tab=activity(or businesses) - Falls back to domain root if data missing
- Deal URLs:
-
Execute Aggregation
const reminderData = await CRMReminder.aggregate(query);
return reminderData[0] || { counts: [], data: [] };- Returns first element with counts and data facets
- Returns empty structure if no results
Key Business Rules:
- Assignment Filtering: Only shows reminders assigned to specified user
- Category Logic:
- Completed status takes precedence over due date
- Upcoming: Not completed + due date >= current date
- Past Due: Not completed + due date < current date
- Sorting Priority:
- Pinned reminders first (pinned: -1)
- Due date descending (due_date_time: -1)
- Most urgent/important appears first
- Category Counts: Always calculated regardless of filter
- Deep Population: 3-4 levels of $lookup for comprehensive data
- Dynamic URLs: Click actions generated in database for consistency
Example Usage:
// Get upcoming reminders
const upcomingResult = await getReminder({
skip: 0,
limit: 25,
type: 'upcoming',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});
// {
// counts: [{ past_due: 5, upcoming: 12, completed: 23, total: 40 }],
// data: [...12 upcoming reminders with populated data]
// }
// Get past due reminders
const pastDueResult = await getReminder({
skip: 0,
limit: 25,
type: 'past_due',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});
// {
// counts: [{ past_due: 5, upcoming: 12, completed: 23, total: 40 }],
// data: [...5 past due reminders]
// }
Side Effects:
- None (read-only operation)
Performance Considerations:
-
Complex Aggregation: 3-4 level $lookup operations (expensive)
-
allowDiskUse: Required for large result sets (automatically handled)
-
Index Requirements:
// Primary index
{ account: 1, assigned: 1, reminder_status: 1, due_date_time: 1 }
// Sort index
{ account: 1, assigned: 1, pinned: -1, due_date_time: -1 }
// Read filtering
{ account: 1, assigned: 1, read_by: 1 } -
Expected Time: 200-500ms (due to nested lookups)
-
Optimization Opportunity: Consider denormalizing frequently accessed fields
readReminder({ notificationId, accountId, userId })โ
Purpose: Mark single reminder as read for authenticated user
Parameters:
notificationId(ObjectId) - Reminder ID to mark as readaccountId(ObjectId) - Account reminder belongs touserId(ObjectId) - User marking as read
Returns:
{
_id: ObjectId,
headline: String,
content: String,
type: String,
reminder_type: String,
priority: String,
pinned: Boolean,
reminder_status: Boolean,
due_date_time: Date,
read_by: [ObjectId], // Updated with userId
account: ObjectId,
assigned: ObjectId,
created_by: ObjectId,
deal: ObjectId,
contact: ObjectId,
createdAt: Date,
updatedAt: Date
}
Business Logic Flow:
-
Convert IDs to ObjectIds
const uidObjectId = new mongoose.Types.ObjectId(userId); -
Build Match Options
const options = {
_id: new mongoose.Types.ObjectId(notificationId),
account: new mongoose.Types.ObjectId(accountId),
read_by: { $ne: uidObjectId }, // Not already read
};- Filters out reminders already read by user
- Note: No assigned check (unlike getFCM, doesn't verify assignment)
-
Find Reminder
const reminderData = await CRMReminder.findOne(options).lean().exec();
if (!reminderData) {
throw notFound('Reminder not found');
}- lean(): Returns plain JavaScript object
- Throws 404: If not found or already read
-
Update Reminder (Add User to read_by)
const reminderRead = await CRMReminder.findOneAndUpdate(
options,
{ $addToSet: { read_by: uidObjectId } },
{ new: true },
);- $addToSet: Idempotent, adds only if not present
- new: true: Returns updated document
- Uses same conditions as findOne for safety
-
Return Updated Document
return reminderRead;
Key Business Rules:
- Already Read: Returns 404 if userId in read_by array
- Atomic Operation: Uses $addToSet for idempotent updates
- Full Document Return: Returns complete updated reminder (unlike FCM's
{success: true}) - Multi-User Safe: Only adds current user to read_by
- No Assignment Check: Doesn't verify user is assigned (security consideration)
Example Usage:
// Mark reminder as read
try {
const reminder = await readReminder({
notificationId: '670512a5e4b0f8a5c9d3e1b2',
accountId: '60a7f8d5e4b0d8f3a4c5e1b0',
userId: '60a7f8d5e4b0d8f3a4c5e1b1',
});
// Access updated reminder data
console.log(reminder.read_by); // [..., userId]
} catch (error) {
// error.message === 'Reminder not found'
// Could mean: not exists or already read
}
Side Effects:
- Updates Reminder: Adds userId to read_by array
- Triggers Change Streams: If MongoDB change streams enabled
Performance Considerations:
-
Index Requirements:
{ _id: 1, account: 1, read_by: 1 } -
Expected Time: 10-30ms (single document operation)
-
Write Concern: Uses default MongoDB write concern
๐ Integration Pointsโ
CRM Module Integrationโ
Creating Reminders:
// When deal task created
const reminder = await CRMReminder.create({
account: accountId,
assigned: userId,
created_by: creatorId,
type: 'deal',
reminder_type: 'task',
headline: 'Follow up on proposal',
content: 'Call client to discuss proposal details and answer questions',
priority: 'high',
pinned: false,
reminder_status: false,
due_date_time: new Date('2024-10-15T14:00:00Z'),
deal: dealId,
});
Completing Reminders:
// Mark reminder as complete
await CRMReminder.findByIdAndUpdate(reminderId, {
reminder_status: true,
updatedAt: new Date(),
});
// Changes category from upcoming/past_due to completed
Frontend Integrationโ
Reminder Dashboard Component:
// Load reminders by category
const loadReminders = async (category = 'upcoming', page = 1) => {
const skip = (page - 1) * 25;
const response = await fetch(
`/v1/notifications-center/reminder?type=${category}&page=${skip}&limit=25`,
{ headers: { Authorization: `Bearer ${token}` } },
);
const { data, counts } = await response.json();
return {
reminders: data,
counts: counts[0], // { past_due: 5, upcoming: 12, completed: 23, total: 40 }
total: counts[0]?.total || 0,
};
};
// Category tabs with counts
const CategoryTabs = ({ counts }) => (
<Tabs>
<Tab label={`Upcoming (${counts.upcoming})`} value="upcoming" />
<Tab label={`Past Due (${counts.past_due})`} value="past_due" />
<Tab label={`Completed (${counts.completed})`} value="completed" />
</Tabs>
);
// Click reminder to navigate
const handleReminderClick = async reminder => {
// Mark as read
try {
await fetch(`/v1/notifications-center/reminder/read/${reminder.id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
});
} catch (error) {
// Already read, ignore
}
// Navigate to deal or contact
window.location.href = reminder.click_action;
};
// Complete reminder
const handleCompleteReminder = async reminderId => {
await fetch(`/v1/crm/reminders/${reminderId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ reminder_status: true }),
});
// Refresh list
await loadReminders();
};
๐งช Edge Cases & Special Handlingโ
Category Transitionโ
Reminder Becomes Past Due:
// Created as upcoming
const reminder = await CRMReminder.create({
assigned: userId,
due_date_time: new Date('2024-10-15T14:00:00Z'), // Future date
reminder_status: false,
});
// Time passes, now past due
const now = new Date('2024-10-16T10:00:00Z');
// Automatically appears in past_due category
const pastDue = await getReminder({
skip: 0,
limit: 25,
type: 'past_due',
accountId,
userId,
});
// Includes reminder (due_date < now and reminder_status = false)
Pinned Remindersโ
Sort Priority:
// Create pinned and unpinned reminders
await CRMReminder.create([
{ assigned: userId, due_date_time: new Date('2024-10-20'), pinned: true },
{ assigned: userId, due_date_time: new Date('2024-10-15'), pinned: false },
{ assigned: userId, due_date_time: new Date('2024-10-25'), pinned: true },
]);
// Get reminders (sorted by pinned DESC, then due_date DESC)
const result = await getReminder({ skip: 0, limit: 25, accountId, userId });
// Order: Oct 25 (pinned), Oct 20 (pinned), Oct 15 (unpinned)
Missing Related Dataโ
Deal or Contact Not Found:
// Reminder references deleted deal
const reminder = await CRMReminder.create({
type: 'deal',
deal: deletedDealId, // Deal was deleted
// ... other fields
});
// Lookup returns empty array
const result = await getReminder({ skip: 0, limit: 25, accountId, userId });
// reminder.deal === null (arrayElemAt returns null for empty array)
// reminder.click_action === domainName (fallback)
Already Readโ
Idempotent Read Operation:
// First read succeeds
const reminder1 = await readReminder({ notificationId, accountId, userId });
// reminder1.read_by includes userId
// Second read fails
await readReminder({ notificationId, accountId, userId });
// Throws: notFound('Reminder not found')
Contact Type URL Generationโ
Person vs Business:
// Person contact
const personReminder = {
type: 'contact',
contact: { id: '...', type: 'person' },
};
// click_action: /contacts?type=people&id=...&tab=activity
// Business contact
const businessReminder = {
type: 'contact',
contact: { id: '...', type: 'business' },
};
// click_action: /contacts?type=businesses&id=...&tab=activity
Empty Categoryโ
No Past Due Reminders:
const result = await getReminder({
skip: 0,
limit: 25,
type: 'past_due',
accountId,
userId,
});
// {
// counts: [{ past_due: 0, upcoming: 12, completed: 5, total: 17 }],
// data: [] // Empty array
// }
โ ๏ธ Important Notesโ
-
Deep Lookups: Service performs 3-4 level $lookup operations which are expensive. Consider denormalizing frequently accessed fields (deal_name, contact_name) for production optimization.
-
Category Calculation: Categories calculated dynamically based on current date and reminder_status. Reminders automatically transition from upcoming to past_due without updates.
-
Pinned Priority: Pinned reminders always appear first regardless of due date. Sort order: pinned DESC, then due_date_time DESC.
-
Category Counts: Always returned regardless of selected filter. Used for category tab badges showing counts for all categories.
-
Click Action URLs: Generated in database using $concat and $switch. Ensures consistent URL format across all clients.
-
Assignment Filtering: getReminder only returns reminders where assigned equals userId. Users only see their own reminders.
-
Read Status: Calculated per-user from read_by array. Multiple users can be in read_by for shared scenarios (though reminders typically have single assignee).
-
Full Document Return: readReminder returns complete updated reminder document, unlike FCM which returns
\{success: true\}. Allows frontend to update cache without additional fetch. -
No Assignment Check in Read: readReminder doesn't verify user is assigned to reminder. Security consideration - any user can mark any reminder as read if they know the ID.
-
Domain Resolution: getActiveDomain called once per request to build click_action URLs. Consider caching domain lookups for high-traffic scenarios.
๐ Related Documentationโ
- Common Notifications Service - Unified count and bulk read operations
- FCM Service - FCM push notification management
- CRM Reminders Collection (link removed - file does not exist) - Reminder schema and indexes
- CRM Deals Collection (link removed - file does not exist) - Deal data structure
- CRM Contacts Collection (link removed - file does not exist) - Contact, business, person schemas
- CRM Reminders Management - Create, update, delete reminders
- Domain Service (link removed - file does not exist) - Active domain resolution