* 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
189 lines
5.9 KiB
JavaScript
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,
|
|
};
|