tududi/backend/modules/projects/dueProjectService.js
Chris 11cd77bedd
Fix Telegram notification spam with channel-level rate limiting (#951)
* Fix Telegram notification spam with channel-level rate limiting

Addresses issue #950 where Telegram notifications were sent excessively
(96-288 messages per day per task) due to the delete-and-recreate pattern
added in commit 105a913a to fix navbar notification pile-up.

Changes:
- Add channel_sent_at JSON field to notifications table to track when
  each channel (telegram, email, push) was last sent
- Add helper methods to notification model:
  - markChannelAsSent(channel): Records send timestamp
  - wasChannelRecentlySent(channel, threshold): Checks if sent within 24h
- Modify sendTelegramNotification() to check rate limit before sending
- Update service layer (dueTaskService, deferredTaskService,
  dueProjectService) to preserve channel_sent_at when recreating
  notifications
- Add comprehensive unit and integration tests (20 tests, all passing)

Impact:
- Reduces Telegram notifications from 96-288/day to 1/day per item
- Preserves in-app notification refresh behavior (every 5-15 min)
- Maintains navbar pile-up fix from original commit
- Rate limit configurable (default: 24 hours)

Fixes #950

* Fix linting and formatting issues

* Fix integration test that was trying to access private function

* Fix prettier formatting in integration test
2026-03-19 20:26:11 +02:00

206 lines
6.8 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
);
// Preserve channel_sent_at for rate limiting when recreating notifications
let preservedChannelSentAt = null;
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) {
// Preserve channel_sent_at to maintain rate limiting across recreations
preservedChannelSentAt =
existingNotification.channel_sent_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(),
channel_sent_at: preservedChannelSentAt,
});
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,
};