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
This commit is contained in:
Chris 2026-03-19 20:26:11 +02:00 committed by GitHub
parent 471d29e495
commit 11cd77bedd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 739 additions and 2 deletions

View file

@ -0,0 +1,36 @@
'use strict';
const {
safeAddColumns,
safeRemoveColumn,
} = require('../utils/migration-utils');
/**
* Migration to add channel_sent_at JSON column to notifications table.
* This tracks when each notification channel (telegram, email, push) was last sent,
* enabling rate limiting to prevent notification spam.
*
* Example value: {"telegram": "2026-03-19T10:30:00Z", "email": "2026-03-19T11:00:00Z"}
*/
module.exports = {
async up(queryInterface, Sequelize) {
await safeAddColumns(queryInterface, 'notifications', [
{
name: 'channel_sent_at',
definition: {
type: Sequelize.JSON,
allowNull: true,
defaultValue: null,
},
},
]);
},
async down(queryInterface) {
await safeRemoveColumn(
queryInterface,
'notifications',
'channel_sent_at'
);
},
};

View file

@ -97,6 +97,11 @@ module.exports = (sequelize) => {
type: DataTypes.DATE,
allowNull: true,
},
channel_sent_at: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: null,
},
},
{
tableName: 'notifications',
@ -142,6 +147,7 @@ module.exports = (sequelize) => {
sources = [],
sentAt = null,
level = 'info',
channel_sent_at = null,
}) {
const notification = await Notification.create({
user_id: userId,
@ -152,6 +158,7 @@ module.exports = (sequelize) => {
sources,
level,
sent_at: sentAt || new Date(),
channel_sent_at,
});
if (sources.includes('email')) {
@ -164,7 +171,8 @@ module.exports = (sequelize) => {
title,
message,
data,
Notification
Notification,
notification
);
}
@ -209,7 +217,8 @@ module.exports = (sequelize) => {
title,
message,
data,
NotificationModel
NotificationModel,
notificationInstance
) {
try {
const telegramService = require('../modules/telegram/telegramNotificationService');
@ -218,6 +227,18 @@ module.exports = (sequelize) => {
return;
}
// Check if Telegram was recently sent for this notification (within 24 hours)
// to prevent spam from delete-and-recreate pattern
if (
notificationInstance &&
notificationInstance.wasChannelRecentlySent(
'telegram',
24 * 60 * 60 * 1000
)
) {
return; // Skip sending to prevent spam
}
const UserModel = NotificationModel.sequelize.models.User;
const user = await UserModel.findByPk(userId, {
attributes: [
@ -236,6 +257,11 @@ module.exports = (sequelize) => {
data,
level: 'info',
});
// Mark that Telegram was sent for this notification
if (notificationInstance) {
await notificationInstance.markChannelAsSent('telegram');
}
}
} catch (error) {
console.error('Failed to send Telegram notification:', error);
@ -275,6 +301,36 @@ module.exports = (sequelize) => {
return this.dismissed_at !== null;
};
/**
* Mark a notification channel as sent
* @param {string} channel - The channel name (telegram, email, push)
*/
Notification.prototype.markChannelAsSent = async function (channel) {
const sentTimes = this.channel_sent_at || {};
sentTimes[channel] = new Date().toISOString();
this.channel_sent_at = sentTimes;
await this.save();
return this;
};
/**
* Check if a channel was recently sent for this notification
* @param {string} channel - The channel name (telegram, email, push)
* @param {number} thresholdMs - Time threshold in milliseconds (default: 24 hours)
* @returns {boolean} True if channel was sent within threshold
*/
Notification.prototype.wasChannelRecentlySent = function (
channel,
thresholdMs = 24 * 60 * 60 * 1000
) {
if (!this.channel_sent_at || !this.channel_sent_at[channel]) {
return false;
}
const lastSent = new Date(this.channel_sent_at[channel]);
const now = new Date();
return now - lastSent < thresholdMs;
};
Notification.getUserNotifications = async function (userId, options = {}) {
const {
limit = 10,

View file

@ -89,6 +89,9 @@ async function checkDueProjects() {
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) {
@ -98,6 +101,9 @@ async function checkDueProjects() {
// 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
@ -137,6 +143,7 @@ async function checkDueProjects() {
isOverdue,
},
sentAt: new Date(),
channel_sent_at: preservedChannelSentAt,
});
notificationsCreated++;

View file

@ -67,6 +67,9 @@ async function checkDeferredTasks() {
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) {
@ -76,6 +79,9 @@ async function checkDeferredTasks() {
// 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
@ -101,6 +107,7 @@ async function checkDeferredTasks() {
reason: 'defer_until_reached',
},
sentAt: new Date(),
channel_sent_at: preservedChannelSentAt,
});
notificationsCreated++;

View file

@ -87,6 +87,9 @@ async function checkDueTasks() {
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) {
@ -96,6 +99,9 @@ async function checkDueTasks() {
// 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
@ -132,6 +138,7 @@ async function checkDueTasks() {
isOverdue,
},
sentAt: new Date(),
channel_sent_at: preservedChannelSentAt,
});
notificationsCreated++;

View file

@ -0,0 +1,354 @@
const { Notification, User, Task } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
const telegramNotificationService = require('../../modules/telegram/telegramNotificationService');
// Mock the Telegram notification service
jest.mock('../../modules/telegram/telegramNotificationService');
describe('Notification Telegram Rate Limiting', () => {
let user;
let sendTelegramSpy;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
telegram_bot_token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
telegram_chat_id: '123456789',
notification_preferences: {
dueTasks: {
inApp: true,
telegram: true,
},
overdueTasks: {
inApp: true,
telegram: true,
},
},
});
// Mock Telegram service methods
telegramNotificationService.isTelegramConfigured = jest
.fn()
.mockReturnValue(true);
sendTelegramSpy = jest
.spyOn(telegramNotificationService, 'sendTelegramNotification')
.mockResolvedValue({ success: true });
});
afterEach(() => {
jest.clearAllMocks();
});
describe('First notification creation', () => {
it('should send Telegram notification immediately', async () => {
await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Task Due Soon',
message: 'Your task is due tomorrow',
sources: ['telegram'],
level: 'info',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
expect(sendTelegramSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: user.id,
telegram_bot_token: user.telegram_bot_token,
telegram_chat_id: user.telegram_chat_id,
}),
expect.objectContaining({
title: 'Task Due Soon',
message: 'Your task is due tomorrow',
level: 'info',
})
);
});
it('should mark telegram as sent in channel_sent_at', async () => {
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Task Due Soon',
message: 'Your task is due tomorrow',
sources: ['telegram'],
level: 'info',
});
// Reload to get updated data
await notification.reload();
expect(notification.channel_sent_at).toBeDefined();
expect(notification.channel_sent_at.telegram).toBeDefined();
const sentTime = new Date(notification.channel_sent_at.telegram);
expect(sentTime).toBeInstanceOf(Date);
expect(sentTime.getTime()).toBeLessThanOrEqual(Date.now());
});
});
describe('Delete and recreate pattern (navbar pile-up fix)', () => {
it('should NOT resend Telegram when notification recreated within 24 hours', async () => {
// Create initial notification
const firstNotification = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task Overdue',
message: 'Your task is now overdue',
sources: ['telegram'],
data: { taskUid: 'test-task-123' },
level: 'warning',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
// Verify channel_sent_at was set
await firstNotification.reload();
expect(firstNotification.channel_sent_at).toBeDefined();
expect(firstNotification.channel_sent_at.telegram).toBeDefined();
expect(firstNotification.wasChannelRecentlySent('telegram')).toBe(
true
);
sendTelegramSpy.mockClear();
// Simulate delete-and-recreate pattern (what cron jobs do)
const preservedChannelSentAt = firstNotification.channel_sent_at;
await firstNotification.destroy();
// Create new notification with preserved channel_sent_at (within 24h)
// This simulates what the service layer does
const secondNotification = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task Overdue',
message: 'Your task is still overdue',
sources: ['telegram'],
data: { taskUid: 'test-task-123' },
level: 'warning',
channel_sent_at: preservedChannelSentAt,
});
// Telegram should NOT have been sent again (rate limited)
expect(sendTelegramSpy).toHaveBeenCalledTimes(0);
// Channel tracking should be preserved
expect(secondNotification.channel_sent_at).toEqual(
preservedChannelSentAt
);
expect(secondNotification.wasChannelRecentlySent('telegram')).toBe(
true
);
});
it('should NOT send Telegram multiple times for same notification context', async () => {
// Simulate the actual cron job pattern:
// 1. Create notification
// 2. Check for existing notification
// 3. Delete if exists and unread
// 4. Create new notification
const createNotificationWithTelegramTracking = async () => {
// Check for existing notification
const existing = await Notification.findOne({
where: {
user_id: user.id,
type: 'task_due_soon',
},
order: [['created_at', 'DESC']],
});
let channelSentAt = null;
if (existing && !existing.dismissed_at && !existing.read_at) {
// Preserve channel tracking before deletion
channelSentAt = existing.channel_sent_at;
await existing.destroy();
}
// Create new notification, preserving channel_sent_at
const notification = await Notification.create({
user_id: user.id,
type: 'task_due_soon',
title: 'Task Due Soon',
message: 'Your task is due tomorrow',
sources: ['telegram'],
level: 'info',
sent_at: new Date(),
channel_sent_at: channelSentAt,
});
// Only send if not recently sent
if (
!notification.wasChannelRecentlySent(
'telegram',
24 * 60 * 60 * 1000
)
) {
await telegramNotificationService.sendTelegramNotification(
user,
{
title: notification.title,
message: notification.message,
level: notification.level,
}
);
await notification.markChannelAsSent('telegram');
}
return notification;
};
// First creation - should send
await createNotificationWithTelegramTracking();
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
sendTelegramSpy.mockClear();
// Second creation within 24h - should NOT send
await createNotificationWithTelegramTracking();
expect(sendTelegramSpy).toHaveBeenCalledTimes(0);
// Third creation within 24h - should NOT send
await createNotificationWithTelegramTracking();
expect(sendTelegramSpy).toHaveBeenCalledTimes(0);
});
});
describe('Telegram rate limit threshold', () => {
it('should resend Telegram after 24 hours have passed', async () => {
// Create notification with telegram sent 25 hours ago
const moreThanADayAgo = new Date();
moreThanADayAgo.setHours(moreThanADayAgo.getHours() - 25);
const notification = await Notification.create({
user_id: user.id,
type: 'task_overdue',
title: 'Task Overdue',
message: 'Your task is still overdue',
sources: ['telegram'],
level: 'warning',
sent_at: new Date(),
channel_sent_at: {
telegram: moreThanADayAgo.toISOString(),
},
});
// Channel was sent more than 24h ago
expect(notification.wasChannelRecentlySent('telegram')).toBe(false);
// Now create a new notification via createNotification
// (simulating cron job running after 24h)
await notification.destroy();
await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task Overdue',
message: 'Your task is still overdue',
sources: ['telegram'],
level: 'warning',
});
// Should have sent Telegram again
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
});
});
describe('User dismisses notification', () => {
it('should not create new notification if previous was dismissed', async () => {
// This tests existing behavior - not related to rate limiting
// but important for overall notification flow
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Task Due Soon',
message: 'Your task is due tomorrow',
sources: ['telegram'],
level: 'info',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
// User dismisses the notification
await notification.dismiss();
// Cron job should check for dismissed_at and NOT create new notification
// (This is handled in the service layer, not model layer)
// So Telegram won't be sent again
});
});
describe('Different notification types', () => {
it('should track telegram sends independently for different types', async () => {
// Create due_soon notification
const dueSoonNotif = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Task Due Soon',
message: 'Task due tomorrow',
sources: ['telegram'],
level: 'info',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
sendTelegramSpy.mockClear();
// Create overdue notification for same task
// (different type, so different notification)
const overdueNotif = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task Overdue',
message: 'Task is now overdue',
sources: ['telegram'],
level: 'warning',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
// Both notifications should have their own channel_sent_at
await dueSoonNotif.reload();
await overdueNotif.reload();
expect(dueSoonNotif.channel_sent_at.telegram).toBeDefined();
expect(overdueNotif.channel_sent_at.telegram).toBeDefined();
});
});
describe('Multiple tasks', () => {
it('should rate limit each task notification independently', async () => {
// Create notification for task 1
const task1Notif = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task 1 Overdue',
message: 'Task 1 is overdue',
sources: ['telegram'],
data: { taskUid: 'task-1' },
level: 'warning',
});
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
sendTelegramSpy.mockClear();
// Create notification for task 2
const task2Notif = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Task 2 Overdue',
message: 'Task 2 is overdue',
sources: ['telegram'],
data: { taskUid: 'task-2' },
level: 'warning',
});
// Should send for task 2 (different notification)
expect(sendTelegramSpy).toHaveBeenCalledTimes(1);
// Each has their own rate limiting
expect(task1Notif.channel_sent_at).not.toBe(
task2Notif.channel_sent_at
);
});
});
});

View file

@ -0,0 +1,182 @@
const { Notification, User } = require('../../../models');
describe('Notification Model', () => {
let testUser;
beforeEach(async () => {
const bcrypt = require('bcrypt');
testUser = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
describe('channel tracking methods', () => {
let notification;
beforeEach(async () => {
notification = await Notification.create({
user_id: testUser.id,
type: 'task_due_soon',
title: 'Test Notification',
message: 'This is a test notification',
sources: ['telegram'],
level: 'info',
sent_at: new Date(),
});
});
describe('markChannelAsSent', () => {
it('should mark a channel as sent with current timestamp', async () => {
const beforeMark = new Date();
await notification.markChannelAsSent('telegram');
expect(notification.channel_sent_at).toBeDefined();
expect(notification.channel_sent_at.telegram).toBeDefined();
const sentTime = new Date(
notification.channel_sent_at.telegram
);
expect(sentTime).toBeInstanceOf(Date);
expect(sentTime.getTime()).toBeGreaterThanOrEqual(
beforeMark.getTime()
);
});
it('should track multiple channels independently', async () => {
await notification.markChannelAsSent('telegram');
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay
await notification.markChannelAsSent('email');
expect(notification.channel_sent_at.telegram).toBeDefined();
expect(notification.channel_sent_at.email).toBeDefined();
const telegramTime = new Date(
notification.channel_sent_at.telegram
);
const emailTime = new Date(notification.channel_sent_at.email);
expect(emailTime.getTime()).toBeGreaterThanOrEqual(
telegramTime.getTime()
);
});
it('should update existing channel timestamp when marked again', async () => {
await notification.markChannelAsSent('telegram');
const firstTime = notification.channel_sent_at.telegram;
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay
await notification.markChannelAsSent('telegram');
const secondTime = notification.channel_sent_at.telegram;
expect(secondTime).not.toBe(firstTime);
expect(new Date(secondTime).getTime()).toBeGreaterThan(
new Date(firstTime).getTime()
);
});
it('should persist to database', async () => {
await notification.markChannelAsSent('telegram');
const reloaded = await Notification.findByPk(notification.id);
expect(reloaded.channel_sent_at).toBeDefined();
expect(reloaded.channel_sent_at.telegram).toBe(
notification.channel_sent_at.telegram
);
});
});
describe('wasChannelRecentlySent', () => {
it('should return false when channel was never sent', () => {
expect(notification.wasChannelRecentlySent('telegram')).toBe(
false
);
});
it('should return false when channel_sent_at is null', () => {
notification.channel_sent_at = null;
expect(notification.wasChannelRecentlySent('telegram')).toBe(
false
);
});
it('should return true when channel was sent within threshold', async () => {
await notification.markChannelAsSent('telegram');
expect(
notification.wasChannelRecentlySent('telegram', 1000)
).toBe(true);
});
it('should return false when channel was sent outside threshold', async () => {
// Manually set a timestamp from 2 days ago
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
notification.channel_sent_at = {
telegram: twoDaysAgo.toISOString(),
};
// Check with 24h threshold (should be false)
expect(
notification.wasChannelRecentlySent(
'telegram',
24 * 60 * 60 * 1000
)
).toBe(false);
});
it('should use default threshold of 24 hours', async () => {
const oneDayAgo = new Date();
oneDayAgo.setHours(oneDayAgo.getHours() - 23); // 23 hours ago
notification.channel_sent_at = {
telegram: oneDayAgo.toISOString(),
};
// Should be true (within 24h default)
expect(notification.wasChannelRecentlySent('telegram')).toBe(
true
);
// Set to 25 hours ago
const moreThanADayAgo = new Date();
moreThanADayAgo.setHours(moreThanADayAgo.getHours() - 25);
notification.channel_sent_at = {
telegram: moreThanADayAgo.toISOString(),
};
// Should be false (outside 24h default)
expect(notification.wasChannelRecentlySent('telegram')).toBe(
false
);
});
it('should check channels independently', async () => {
await notification.markChannelAsSent('telegram');
expect(notification.wasChannelRecentlySent('telegram')).toBe(
true
);
expect(notification.wasChannelRecentlySent('email')).toBe(
false
);
});
it('should handle different thresholds for different channels', async () => {
await notification.markChannelAsSent('telegram');
await notification.markChannelAsSent('email');
// Both within 1 hour
expect(
notification.wasChannelRecentlySent(
'telegram',
60 * 60 * 1000
)
).toBe(true);
expect(
notification.wasChannelRecentlySent('email', 60 * 60 * 1000)
).toBe(true);
});
});
});
});

View file

@ -301,5 +301,93 @@ describe('dueTaskService', () => {
expect(result.notificationsCreated).toBe(0);
});
});
describe('Telegram rate limiting', () => {
const telegramNotificationService = require('../../../../modules/telegram/telegramNotificationService');
beforeEach(() => {
// Mock Telegram service
jest.spyOn(
telegramNotificationService,
'isTelegramConfigured'
).mockReturnValue(true);
jest.spyOn(
telegramNotificationService,
'sendTelegramNotification'
).mockResolvedValue({ success: true });
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should not resend Telegram if notification recreated within 24 hours', async () => {
// Setup user with Telegram enabled
user.telegram_bot_token =
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
user.telegram_chat_id = '123456789';
user.notification_preferences = {
dueTasks: { inApp: true, telegram: true },
overdueTasks: { inApp: true, telegram: true },
};
await user.save();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
due_date: tomorrow,
status: Task.STATUS.NOT_STARTED,
});
const sendTelegramSpy = jest.spyOn(
telegramNotificationService,
'sendTelegramNotification'
);
// First check - should send Telegram
await checkDueTasks();
const firstCallCount = sendTelegramSpy.mock.calls.length;
expect(firstCallCount).toBeGreaterThan(0);
// Get the created notification
const notification = await Notification.findOne({
where: {
user_id: user.id,
type: 'task_due_soon',
},
order: [['created_at', 'DESC']],
});
expect(notification).not.toBeNull();
expect(notification.channel_sent_at).toBeDefined();
expect(notification.channel_sent_at.telegram).toBeDefined();
// Verify notification is still within 24h threshold
expect(notification.wasChannelRecentlySent('telegram')).toBe(
true
);
sendTelegramSpy.mockClear();
// Second check within 24h - notification will be recreated but Telegram should NOT be resent
await checkDueTasks();
const secondCallCount = sendTelegramSpy.mock.calls.length;
expect(secondCallCount).toBe(0);
// Notification should still exist (recreated in-app)
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_due_soon',
},
});
expect(notifications.length).toBe(1);
});
});
});
});