tududi/backend/modules/tasks/deferredTaskService.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

189 lines
5.9 KiB
JavaScript

const { Task, Notification, User } = require('../../models');
const { Op } = require('sequelize');
const { logError } = require('../../services/logService');
const {
shouldSendInAppNotification,
shouldSendTelegramNotification,
} = require('../../utils/notificationPreferences');
async function checkDeferredTasks() {
try {
const now = new Date();
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
const deferredTasks = await Task.findAll({
where: {
defer_until: {
[Op.not]: null,
[Op.lte]: fiveMinutesFromNow,
},
status: {
[Op.ne]: 2,
},
},
include: [
{
model: User,
attributes: [
'id',
'email',
'name',
'notification_preferences',
],
},
],
});
if (deferredTasks.length === 0) {
return {
success: true,
tasksProcessed: 0,
notificationsCreated: 0,
};
}
let notificationsCreated = 0;
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
for (const task of deferredTasks) {
try {
if (!shouldSendInAppNotification(task.User, 'deferUntil')) {
continue;
}
const recentNotifications = await Notification.findAll({
where: {
user_id: task.user_id,
type: 'task_due_soon',
created_at: {
[Op.gte]: oneDayAgo,
},
},
});
const existingNotification = recentNotifications.find(
(notif) =>
notif.data?.taskUid === task.uid &&
notif.data?.reason === 'defer_until_reached'
);
// 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 sources = [];
if (shouldSendTelegramNotification(task.User, 'deferUntil')) {
sources.push('telegram');
}
await Notification.createNotification({
userId: task.user_id,
type: 'task_due_soon',
title: 'Task is now active',
message: `Your task "${task.name}" is now available to work on`,
sources,
data: {
taskUid: task.uid,
taskName: task.name,
deferUntil: task.defer_until,
reason: 'defer_until_reached',
},
sentAt: new Date(),
channel_sent_at: preservedChannelSentAt,
});
notificationsCreated++;
} catch (error) {
logError(
`Error creating notification for task ${task.id}:`,
error
);
}
}
return {
success: true,
tasksProcessed: deferredTasks.length,
notificationsCreated,
};
} catch (error) {
logError('Error checking deferred tasks:', error);
throw error;
}
}
async function getDeferredTaskStats() {
try {
const now = new Date();
const [totalDeferred, activeNow, activeSoon] = await Promise.all([
Task.count({
where: {
defer_until: {
[Op.not]: null,
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
Task.count({
where: {
defer_until: {
[Op.not]: null,
[Op.lte]: now,
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
Task.count({
where: {
defer_until: {
[Op.not]: null,
[Op.gt]: now,
[Op.lte]: new Date(now.getTime() + 60 * 60 * 1000),
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
]);
return {
totalDeferred,
activeNow,
activeSoon,
};
} catch (error) {
logError('Error getting deferred task stats:', error);
throw error;
}
}
module.exports = {
checkDeferredTasks,
getDeferredTaskStats,
};