tududi/backend/modules/projects/dueProjectService.js
Chris 105a913a8d
Fix notification deduplication to prevent pile-up in navbar (#945)
* Fix notification deduplication to prevent pile-up in navbar (#944)

When tasks/projects remained pending for multiple days, duplicate
notifications accumulated in the navbar instead of showing only the
most recent one.

Updated notification services to properly handle existing notifications:
- Delete unread notifications before creating new ones
- Respect dismissed notifications (don't recreate)
- Respect read notifications (don't duplicate)

Changes:
- Updated dueTaskService, deferredTaskService, and dueProjectService
- Added comprehensive unit tests with 8 test cases

Fixes #944

* Fix lint errors and add GitHub bug template reminder to docs
2026-03-14 19:45:24 +02:00

199 lines
6.4 KiB
JavaScript

const { Project, Notification, User } = require('../../models');
const { Op } = require('sequelize');
const { logError } = require('../../services/logService');
const {
shouldSendInAppNotification,
shouldSendTelegramNotification,
} = require('../../utils/notificationPreferences');
/**
* Service to check for due and overdue projects
* and create notifications for users
*/
/**
* Check for projects that are due soon or overdue
* and create notifications for the project owners
*/
async function checkDueProjects() {
try {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const dueProjects = await Project.findAll({
where: {
due_date_at: {
[Op.not]: null,
[Op.lte]: tomorrow,
},
status: {
[Op.notIn]: ['completed'],
},
},
include: [
{
model: User,
attributes: [
'id',
'email',
'name',
'notification_preferences',
],
},
],
});
if (dueProjects.length === 0) {
return {
success: true,
projectsProcessed: 0,
notificationsCreated: 0,
};
}
let notificationsCreated = 0;
for (const project of dueProjects) {
try {
const dueDate = new Date(project.due_date_at);
const isOverdue = dueDate < now;
const notificationType = isOverdue
? 'project_overdue'
: 'project_due_soon';
const level = isOverdue ? 'error' : 'warning';
// Check if user wants this notification
if (
!shouldSendInAppNotification(project.User, notificationType)
) {
continue;
}
// Check for existing notifications
const recentNotifications = await Notification.findAll({
where: {
user_id: project.user_id,
type: {
[Op.in]: ['project_due_soon', 'project_overdue'],
},
created_at: {
[Op.gte]: twoDaysAgo,
},
},
});
const existingNotification = recentNotifications.find(
(notif) =>
notif.data?.projectUid === project.uid &&
notif.type === notificationType
);
if (existingNotification) {
// If notification was dismissed, don't create it again
if (existingNotification.dismissed_at) {
continue;
}
// If notification is unread, delete it before creating the new one
// This prevents duplicate notifications from piling up
if (!existingNotification.read_at) {
await existingNotification.destroy();
} else {
// If it was already read, skip creating a new one
continue;
}
}
const { title, message } = generateNotificationContent(
project.name,
dueDate,
now,
isOverdue
);
// Build sources array based on user preferences
const sources = [];
if (
shouldSendTelegramNotification(
project.User,
notificationType
)
) {
sources.push('telegram');
}
await Notification.createNotification({
userId: project.user_id,
type: notificationType,
title,
message,
level,
sources,
data: {
projectUid: project.uid,
projectName: project.name,
dueDate: project.due_date_at,
isOverdue,
},
sentAt: new Date(),
});
notificationsCreated++;
} catch (error) {
logError(
`Error creating notification for project ${project.id}:`,
error
);
}
}
return {
success: true,
projectsProcessed: dueProjects.length,
notificationsCreated,
};
} catch (error) {
logError('Error checking due projects:', error);
throw error;
}
}
/**
* Generate notification title and message based on project due date
*/
function generateNotificationContent(projectName, dueDate, now, isOverdue) {
if (isOverdue) {
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
const title = 'Project is overdue';
let message;
if (daysOverdue === 0) {
message = `Your project "${projectName}" was due today`;
} else if (daysOverdue === 1) {
message = `Your project "${projectName}" was due yesterday`;
} else {
message = `Your project "${projectName}" was due ${daysOverdue} days ago`;
}
return { title, message };
} else {
const hoursUntilDue = Math.floor((dueDate - now) / (1000 * 60 * 60));
const title = 'Project due soon';
let message;
if (hoursUntilDue < 1) {
message = `Your project "${projectName}" is due in less than 1 hour`;
} else if (hoursUntilDue < 24) {
message = `Your project "${projectName}" is due in ${hoursUntilDue} hours`;
} else {
message = `Your project "${projectName}" is due tomorrow`;
}
return { title, message };
}
}
module.exports = {
checkDueProjects,
};