From 819faf0d189ea448e359aab2fff911a6d0a36e9d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 9 Dec 2025 20:26:53 +0200 Subject: [PATCH] Feat telegram notifications (#692) * Add telegram notifications * fixup! Add telegram notifications * Cleanup --- backend/app.js | 4 + ...dd-telegram-to-notification-preferences.js | 141 ++++++++ backend/models/notification.js | 51 +++ backend/models/user.js | 35 +- backend/routes/test-notifications.js | 200 +++++++++++ backend/scripts/run-notification-services.js | 56 --- .../scripts/seed-notification-test-data.js | 234 ------------- backend/services/deferredTaskService.js | 28 +- backend/services/dueProjectService.js | 14 +- backend/services/dueTaskService.js | 11 +- .../services/telegramNotificationService.js | 70 ++++ .../notification-preferences.test.js | 35 +- .../utils/notificationPreferences.test.js | 35 +- backend/utils/notificationPreferences.js | 43 ++- .../components/Profile/ProfileSettings.tsx | 323 ++++++++++-------- frontend/components/Profile/tabs/AiTab.tsx | 2 +- .../components/Profile/tabs/ApiKeysTab.tsx | 2 +- .../components/Profile/tabs/GeneralTab.tsx | 2 +- .../Profile/tabs/NotificationsTab.tsx | 211 +++++++++++- .../Profile/tabs/ProductivityTab.tsx | 2 +- .../components/Profile/tabs/SecurityTab.tsx | 2 +- frontend/components/Profile/tabs/TabsNav.tsx | 38 +-- .../components/Profile/tabs/TelegramTab.tsx | 2 +- frontend/components/Profile/types.ts | 35 +- package-lock.json | 4 +- 25 files changed, 1057 insertions(+), 523 deletions(-) create mode 100644 backend/migrations/20251209000001-add-telegram-to-notification-preferences.js create mode 100644 backend/routes/test-notifications.js delete mode 100644 backend/scripts/run-notification-services.js delete mode 100644 backend/scripts/seed-notification-test-data.js create mode 100644 backend/services/telegramNotificationService.js diff --git a/backend/app.js b/backend/app.js index d5ba974..a193562 100644 --- a/backend/app.js +++ b/backend/app.js @@ -188,6 +188,10 @@ const registerApiRoutes = (basePath) => { app.use(`${basePath}/search`, require('./routes/search')); app.use(`${basePath}/views`, require('./routes/views')); app.use(`${basePath}/notifications`, require('./routes/notifications')); + app.use( + `${basePath}/test-notifications`, + require('./routes/test-notifications') + ); }; // Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility diff --git a/backend/migrations/20251209000001-add-telegram-to-notification-preferences.js b/backend/migrations/20251209000001-add-telegram-to-notification-preferences.js new file mode 100644 index 0000000..33182e4 --- /dev/null +++ b/backend/migrations/20251209000001-add-telegram-to-notification-preferences.js @@ -0,0 +1,141 @@ +'use strict'; + +const { safeChangeColumn } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + const [users] = await queryInterface.sequelize.query( + 'SELECT id, notification_preferences FROM users WHERE notification_preferences IS NOT NULL' + ); + + for (const user of users) { + const prefs = user.notification_preferences; + + if (prefs.dueTasks) { + prefs.dueTasks.telegram = false; + } + if (prefs.overdueTasks) { + prefs.overdueTasks.telegram = false; + } + if (prefs.dueProjects) { + prefs.dueProjects.telegram = false; + } + if (prefs.overdueProjects) { + prefs.overdueProjects.telegram = false; + } + if (prefs.deferUntil) { + prefs.deferUntil.telegram = false; + } + + // Update the user's preferences + await queryInterface.sequelize.query( + 'UPDATE users SET notification_preferences = :prefs WHERE id = :id', + { + replacements: { + prefs: JSON.stringify(prefs), + id: user.id, + }, + } + ); + } + + await safeChangeColumn( + queryInterface, + 'users', + 'notification_preferences', + { + type: Sequelize.JSON, + allowNull: true, + defaultValue: { + dueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + dueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + }, + comment: + 'User notification channel preferences for different notification types', + } + ); + }, + + async down(queryInterface, Sequelize) { + const [users] = await queryInterface.sequelize.query( + 'SELECT id, notification_preferences FROM users WHERE notification_preferences IS NOT NULL' + ); + + for (const user of users) { + const prefs = user.notification_preferences; + + if (prefs.dueTasks) { + delete prefs.dueTasks.telegram; + } + if (prefs.overdueTasks) { + delete prefs.overdueTasks.telegram; + } + if (prefs.dueProjects) { + delete prefs.dueProjects.telegram; + } + if (prefs.overdueProjects) { + delete prefs.overdueProjects.telegram; + } + if (prefs.deferUntil) { + delete prefs.deferUntil.telegram; + } + + await queryInterface.sequelize.query( + 'UPDATE users SET notification_preferences = :prefs WHERE id = :id', + { + replacements: { + prefs: JSON.stringify(prefs), + id: user.id, + }, + } + ); + } + + await safeChangeColumn( + queryInterface, + 'users', + 'notification_preferences', + { + type: Sequelize.JSON, + allowNull: true, + defaultValue: { + dueTasks: { inApp: true, email: false, push: false }, + overdueTasks: { inApp: true, email: false, push: false }, + dueProjects: { inApp: true, email: false, push: false }, + overdueProjects: { inApp: true, email: false, push: false }, + deferUntil: { inApp: true, email: false, push: false }, + }, + comment: + 'User notification channel preferences for different notification types', + } + ); + }, +}; diff --git a/backend/models/notification.js b/backend/models/notification.js index 86b109d..1f52648 100644 --- a/backend/models/notification.js +++ b/backend/models/notification.js @@ -158,6 +158,16 @@ module.exports = (sequelize) => { await sendEmailNotification(userId, title, message, Notification); } + if (sources.includes('telegram')) { + await sendTelegramNotification( + userId, + title, + message, + data, + Notification + ); + } + return notification; }; @@ -194,6 +204,47 @@ module.exports = (sequelize) => { } } + async function sendTelegramNotification( + userId, + title, + message, + data, + NotificationModel + ) { + try { + const telegramService = require('../services/telegramNotificationService'); + + if (!message) { + return; + } + + const UserModel = NotificationModel.sequelize.models.User; + const user = await UserModel.findByPk(userId, { + attributes: [ + 'id', + 'name', + 'surname', + 'telegram_bot_token', + 'telegram_chat_id', + ], + }); + + if (user && telegramService.isTelegramConfigured(user)) { + await telegramService.sendTelegramNotification(user, { + title, + message, + data, + level: 'info', + }); + } + } catch (error) { + console.error('Failed to send Telegram notification:', error); + } + } + + /** + * Mark a notification as read + */ Notification.prototype.markAsRead = async function () { if (!this.read_at) { this.read_at = new Date(); diff --git a/backend/models/user.js b/backend/models/user.js index 40ba6a6..6e52ef3 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -180,11 +180,36 @@ module.exports = (sequelize) => { type: DataTypes.JSON, allowNull: true, defaultValue: { - dueTasks: { inApp: true, email: false, push: false }, - overdueTasks: { inApp: true, email: false, push: false }, - dueProjects: { inApp: true, email: false, push: false }, - overdueProjects: { inApp: true, email: false, push: false }, - deferUntil: { inApp: true, email: false, push: false }, + dueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + dueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { + inApp: true, + email: false, + push: false, + telegram: false, + }, }, }, email_verified: { diff --git a/backend/routes/test-notifications.js b/backend/routes/test-notifications.js new file mode 100644 index 0000000..820f99b --- /dev/null +++ b/backend/routes/test-notifications.js @@ -0,0 +1,200 @@ +const express = require('express'); +const router = express.Router(); +const { Notification, User } = require('../models'); +const { getAuthenticatedUserId } = require('../utils/request-utils'); +const { v4: uuid } = require('uuid'); + +const NOTIFICATION_TEMPLATES = { + task_due_soon: { + title: 'Task Due Soon', + message: + 'Your test task "Complete project documentation" is due in 2 hours', + level: 'warning', + data: { + taskUid: uuid(), + taskName: 'Complete project documentation', + dueDate: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + isOverdue: false, + }, + }, + task_overdue: { + title: 'Task Overdue', + message: 'Your test task "Review pull request #123" is 3 days overdue', + level: 'error', + data: { + taskUid: uuid(), + taskName: 'Review pull request #123', + dueDate: new Date( + Date.now() - 3 * 24 * 60 * 60 * 1000 + ).toISOString(), + isOverdue: true, + }, + }, + project_due_soon: { + title: 'Project Due Soon', + message: 'Your test project "Q4 Planning" is due in 6 hours', + level: 'warning', + data: { + projectUid: uuid(), + projectName: 'Q4 Planning', + dueDate: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(), + isOverdue: false, + }, + }, + project_overdue: { + title: 'Project Overdue', + message: 'Your test project "Website Redesign" is 1 day overdue', + level: 'error', + data: { + projectUid: uuid(), + projectName: 'Website Redesign', + dueDate: new Date( + Date.now() - 1 * 24 * 60 * 60 * 1000 + ).toISOString(), + isOverdue: true, + }, + }, + defer_until: { + title: 'Task Now Active', + message: + 'Your test task "Follow up with client" is now available to work on', + level: 'info', + data: { + taskUid: uuid(), + taskName: 'Follow up with client', + deferUntil: new Date().toISOString(), + reason: 'defer_until_reached', + }, + }, +}; + +function getSources(user, notificationType) { + const sources = []; + + // Check notification preferences + const prefs = user.notification_preferences; + if (!prefs) return sources; + + // Map notification type to preference key + const typeMapping = { + task_due_soon: 'dueTasks', + task_overdue: 'overdueTasks', + project_due_soon: 'dueProjects', + project_overdue: 'overdueProjects', + defer_until: 'deferUntil', + }; + + const prefKey = typeMapping[notificationType]; + if (!prefKey || !prefs[prefKey]) return sources; + + // Add telegram to sources if enabled + if (prefs[prefKey].telegram === true) { + sources.push('telegram'); + } + + // Add email to sources if enabled + if (prefs[prefKey].email === true) { + sources.push('email'); + } + + return sources; +} + +/** + * POST /api/test-notifications/trigger + * Trigger a test notification for the authenticated user + */ +router.post('/trigger', async (req, res) => { + try { + const userId = getAuthenticatedUserId(req); + const { type } = req.body; + + if (!type) { + return res.status(400).json({ + error: 'Notification type is required', + availableTypes: Object.keys(NOTIFICATION_TEMPLATES), + }); + } + + const template = NOTIFICATION_TEMPLATES[type]; + if (!template) { + return res.status(400).json({ + error: 'Invalid notification type', + availableTypes: Object.keys(NOTIFICATION_TEMPLATES), + }); + } + + // Fetch user with notification preferences + const user = await User.findByPk(userId, { + attributes: [ + 'id', + 'name', + 'surname', + 'notification_preferences', + 'telegram_bot_token', + 'telegram_chat_id', + ], + }); + + if (!user) { + return res.status(404).json({ + error: 'User not found', + }); + } + + // Get sources based on user preferences + const sources = getSources(user, type); + + // Create the test notification + const notification = await Notification.createNotification({ + userId: userId, + type: type, + title: template.title, + message: template.message, + level: template.level, + data: template.data, + sources: sources, + sentAt: new Date(), + }); + + res.json({ + success: true, + notification: { + id: notification.id, + type: type, + title: template.title, + message: template.message, + sources: sources, + }, + }); + } catch (error) { + console.error('Error triggering test notification:', error); + res.status(500).json({ + error: 'Failed to trigger test notification', + message: error.message, + }); + } +}); + +/** + * GET /api/test-notifications/types + * Get available notification types for testing + */ +router.get('/types', async (req, res) => { + try { + const types = Object.keys(NOTIFICATION_TEMPLATES).map((key) => ({ + type: key, + title: NOTIFICATION_TEMPLATES[key].title, + level: NOTIFICATION_TEMPLATES[key].level, + })); + + res.json({ types }); + } catch (error) { + console.error('Error fetching notification types:', error); + res.status(500).json({ + error: 'Failed to fetch notification types', + }); + } +}); + +module.exports = router; diff --git a/backend/scripts/run-notification-services.js b/backend/scripts/run-notification-services.js deleted file mode 100644 index 687cf73..0000000 --- a/backend/scripts/run-notification-services.js +++ /dev/null @@ -1,56 +0,0 @@ -const { checkDueTasks } = require('../services/dueTaskService'); -const { checkDeferredTasks } = require('../services/deferredTaskService'); -const { checkDueProjects } = require('../services/dueProjectService'); - -/** - * Run all notification services - * Run with: NODE_ENV=development node backend/scripts/run-notification-services.js - */ - -async function runAllNotificationServices() { - console.log('šŸ”” Running all notification services...\n'); - - try { - // Run due tasks service - console.log('šŸ“‹ Checking due tasks...'); - const dueTasksResult = await checkDueTasks(); - console.log(' Result:', JSON.stringify(dueTasksResult, null, 2)); - - // Run deferred tasks service - console.log('\nā° Checking deferred tasks...'); - const deferredTasksResult = await checkDeferredTasks(); - console.log(' Result:', JSON.stringify(deferredTasksResult, null, 2)); - - // Run due projects service - console.log('\nšŸ“ Checking due projects...'); - const dueProjectsResult = await checkDueProjects(); - console.log(' Result:', JSON.stringify(dueProjectsResult, null, 2)); - - console.log('\nāœ… All notification services completed!'); - console.log('\nšŸ“Š Summary:'); - console.log( - ` • Due tasks: ${dueTasksResult.notificationsCreated} notifications created` - ); - console.log( - ` • Deferred tasks: ${deferredTasksResult.notificationsCreated} notifications created` - ); - console.log( - ` • Due projects: ${dueProjectsResult.notificationsCreated} notifications created` - ); - console.log( - ` • Total: ${dueTasksResult.notificationsCreated + deferredTasksResult.notificationsCreated + dueProjectsResult.notificationsCreated} notifications created\n` - ); - - process.exit(0); - } catch (error) { - console.error('\nāŒ Error running notification services:', error); - process.exit(1); - } -} - -// Run the services -if (require.main === module) { - runAllNotificationServices(); -} - -module.exports = { runAllNotificationServices }; diff --git a/backend/scripts/seed-notification-test-data.js b/backend/scripts/seed-notification-test-data.js deleted file mode 100644 index 5bec6cb..0000000 --- a/backend/scripts/seed-notification-test-data.js +++ /dev/null @@ -1,234 +0,0 @@ -const { User, Task, Project } = require('../models'); - -/** - * Seed script to create test tasks and projects for notification testing - * Run with: NODE_ENV=development node backend/scripts/seed-notification-test-data.js - */ - -async function seedNotificationTestData() { - try { - console.log('🌱 Starting to seed notification test data...'); - - // Get the first user (or create one if none exists) - let user = await User.findOne(); - - if (!user) { - console.log('šŸ“ No users found, creating test user...'); - const bcrypt = require('bcrypt'); - const passwordHash = await bcrypt.hash('password123', 10); - - user = await User.create({ - email: 'test@tududi.com', - password_digest: passwordHash, - name: 'Test', - surname: 'User', - appearance: 'light', - language: 'en', - timezone: 'UTC', - }); - console.log(`āœ… Created test user: ${user.email}`); - } else { - console.log( - `šŸ‘¤ Using existing user: ${user.email} (ID: ${user.id})` - ); - } - - const now = new Date(); - - // Helper to create date offsets - const hoursAgo = (hours) => - new Date(now.getTime() - hours * 60 * 60 * 1000); - const hoursFromNow = (hours) => - new Date(now.getTime() + hours * 60 * 60 * 1000); - const daysAgo = (days) => - new Date(now.getTime() - days * 24 * 60 * 60 * 1000); - const daysFromNow = (days) => - new Date(now.getTime() + days * 24 * 60 * 60 * 1000); - - console.log('\nšŸ“‹ Creating test tasks...'); - - const tasks = [ - // Overdue tasks - { - name: '🚨 Very overdue task', - user_id: user.id, - status: 0, - due_date: daysAgo(5), - description: 'This task is 5 days overdue', - }, - { - name: 'āš ļø Overdue yesterday', - user_id: user.id, - status: 0, - due_date: daysAgo(1), - description: 'This task was due yesterday', - }, - { - name: 'šŸ”“ Overdue today', - user_id: user.id, - status: 0, - due_date: hoursAgo(6), - description: 'This task was due 6 hours ago', - }, - - // Due soon tasks - { - name: '🟔 Due in 2 hours', - user_id: user.id, - status: 0, - due_date: hoursFromNow(2), - description: 'This task is due soon', - }, - { - name: '🟢 Due in 12 hours', - user_id: user.id, - status: 0, - due_date: hoursFromNow(12), - description: 'This task is due within 24 hours', - }, - { - name: 'šŸ“… Due tomorrow', - user_id: user.id, - status: 0, - due_date: daysFromNow(1), - description: 'This task is due tomorrow', - }, - - // Deferred tasks - { - name: 'ā° Defer until now (should be active)', - user_id: user.id, - status: 0, - defer_until: hoursAgo(1), - description: 'This task was deferred but is now available', - }, - { - name: 'ā³ Defer until in 2 hours', - user_id: user.id, - status: 0, - defer_until: hoursFromNow(2), - description: 'This task will be available in 2 hours', - }, - { - name: 'šŸ“† Defer until tomorrow', - user_id: user.id, - status: 0, - defer_until: daysFromNow(1), - description: 'This task will be available tomorrow', - }, - - // Tasks with no due date (should not trigger notifications) - { - name: '✨ No due date', - user_id: user.id, - status: 0, - description: 'This task has no due date', - }, - - // Completed task (should not trigger notifications) - { - name: 'āœ… Completed overdue task', - user_id: user.id, - status: 2, - due_date: daysAgo(3), - description: 'This task is completed so no notification', - completed_at: new Date(), - }, - ]; - - for (const taskData of tasks) { - const task = await Task.create(taskData); - console.log(` āœ“ Created: ${task.name}`); - } - - console.log('\nšŸ“ Creating test projects...'); - - const projects = [ - // Overdue projects - { - name: '🚨 Very overdue project', - user_id: user.id, - state: 'active', - due_date_at: daysAgo(7), - description: 'This project is 7 days overdue', - }, - { - name: 'āš ļø Project overdue yesterday', - user_id: user.id, - state: 'active', - due_date_at: daysAgo(1), - description: 'This project was due yesterday', - }, - - // Due soon projects - { - name: '🟔 Project due in 6 hours', - user_id: user.id, - state: 'active', - due_date_at: hoursFromNow(6), - description: 'This project is due soon', - }, - { - name: 'šŸ“… Project due tomorrow', - user_id: user.id, - state: 'active', - due_date_at: daysFromNow(1), - description: 'This project is due within 24 hours', - }, - - // Projects with no due date - { - name: '✨ Project with no due date', - user_id: user.id, - state: 'active', - description: 'This project has no due date', - }, - - // Completed project (should not trigger notifications) - { - name: 'āœ… Completed overdue project', - user_id: user.id, - state: 'completed', - due_date_at: daysAgo(5), - description: 'This project is completed so no notification', - }, - ]; - - for (const projectData of projects) { - const project = await Project.create(projectData); - console.log(` āœ“ Created: ${project.name}`); - } - - console.log('\nāœ… Seeding complete!'); - console.log('\nšŸ“Š Summary:'); - console.log(` • Created ${tasks.length} tasks`); - console.log(` • Created ${projects.length} projects`); - console.log(` • For user: ${user.email}\n`); - - console.log( - 'šŸ”” To generate notifications, run the notification services:' - ); - console.log( - ' • Due tasks: NODE_ENV=development node -e "require(\'./services/dueTaskService\').checkDueTasks().then(console.log)"' - ); - console.log( - ' • Deferred tasks: NODE_ENV=development node -e "require(\'./services/deferredTaskService\').checkDeferredTasks().then(console.log)"' - ); - console.log( - ' • Due projects: NODE_ENV=development node -e "require(\'./services/dueProjectService\').checkDueProjects().then(console.log)"' - ); - console.log(''); - - process.exit(0); - } catch (error) { - console.error('āŒ Error seeding data:', error); - process.exit(1); - } -} - -// Run the seeder -if (require.main === module) { - seedNotificationTestData(); -} - -module.exports = { seedNotificationTestData }; diff --git a/backend/services/deferredTaskService.js b/backend/services/deferredTaskService.js index 44f2c8f..0c17849 100644 --- a/backend/services/deferredTaskService.js +++ b/backend/services/deferredTaskService.js @@ -3,17 +3,9 @@ const { Op } = require('sequelize'); const { logError } = require('./logService'); const { shouldSendInAppNotification, + shouldSendTelegramNotification, } = require('../utils/notificationPreferences'); -/** - * Service to check for deferred tasks that are now active - * and create notifications for users - */ - -/** - * Check for tasks that have a defer_until date that has passed - * and create notifications for the task owners - */ async function checkDeferredTasks() { try { const now = new Date(); @@ -55,13 +47,10 @@ async function checkDeferredTasks() { for (const task of deferredTasks) { try { - // Check if user wants defer until notifications if (!shouldSendInAppNotification(task.User, 'deferUntil')) { continue; } - // Check for existing notifications (including dismissed ones) - // If a notification was dismissed, don't create it again const recentNotifications = await Notification.findAll({ where: { user_id: task.user_id, @@ -79,17 +68,20 @@ async function checkDeferredTasks() { ); if (existingNotification) { - // Skip if notification exists, even if it was dismissed - // This prevents re-notifying users about tasks they've already dismissed 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: [], + sources, data: { taskUid: task.uid, taskName: task.name, @@ -119,15 +111,11 @@ async function checkDeferredTasks() { } } -/** - * Get statistics about deferred tasks - */ async function getDeferredTaskStats() { try { const now = new Date(); const [totalDeferred, activeNow, activeSoon] = await Promise.all([ - // Total tasks with defer_until set Task.count({ where: { defer_until: { @@ -139,7 +127,6 @@ async function getDeferredTaskStats() { }, }), - // Tasks that should be active now Task.count({ where: { defer_until: { @@ -152,7 +139,6 @@ async function getDeferredTaskStats() { }, }), - // Tasks that will be active in the next hour Task.count({ where: { defer_until: { diff --git a/backend/services/dueProjectService.js b/backend/services/dueProjectService.js index 1f897bb..002adac 100644 --- a/backend/services/dueProjectService.js +++ b/backend/services/dueProjectService.js @@ -3,6 +3,7 @@ const { Op } = require('sequelize'); const { logError } = require('./logService'); const { shouldSendInAppNotification, + shouldSendTelegramNotification, } = require('../utils/notificationPreferences'); /** @@ -102,13 +103,24 @@ async function checkDueProjects() { 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: [], + sources, data: { projectUid: project.uid, projectName: project.name, diff --git a/backend/services/dueTaskService.js b/backend/services/dueTaskService.js index 10e5101..a669ba9 100644 --- a/backend/services/dueTaskService.js +++ b/backend/services/dueTaskService.js @@ -3,6 +3,7 @@ const { Op } = require('sequelize'); const { logError } = require('./logService'); const { shouldSendInAppNotification, + shouldSendTelegramNotification, } = require('../utils/notificationPreferences'); /** @@ -100,13 +101,21 @@ async function checkDueTasks() { isOverdue ); + // Build sources array based on user preferences + const sources = []; + if ( + shouldSendTelegramNotification(task.User, notificationType) + ) { + sources.push('telegram'); + } + await Notification.createNotification({ userId: task.user_id, type: notificationType, title, message, level, - sources: [], + sources, data: { taskUid: task.uid, taskName: task.name, diff --git a/backend/services/telegramNotificationService.js b/backend/services/telegramNotificationService.js new file mode 100644 index 0000000..9fc722b --- /dev/null +++ b/backend/services/telegramNotificationService.js @@ -0,0 +1,70 @@ +const { sendTelegramMessage } = require('./telegramPoller'); + +/** + * Check if user has Telegram properly configured + * @param {Object} user - User model instance + * @returns {boolean} - True if user has both bot token and chat ID + */ +function isTelegramConfigured(user) { + return !!(user && user.telegram_bot_token && user.telegram_chat_id); +} + +/** + * Format notification into a user-friendly Telegram message + * @param {Object} user - User model instance with name/username + * @param {Object} notification - Notification object with title, message, level, data + * @returns {string} - Formatted message string + */ +function formatNotificationMessage(user, notification) { + const { title, message } = notification; + + // Get user's name (use name field, fallback to 'there') + const userName = user.name || 'there'; + + // Build the message with user name + let formattedMessage = `${userName}, ${message || title}`; + + return formattedMessage; +} + +/** + * Send a notification to the user via Telegram + * @param {Object} user - User model instance with telegram_bot_token and telegram_chat_id + * @param {Object} notification - Notification object with title, message, level, data + * @returns {Promise} - { success: boolean, error?: string } + */ +async function sendTelegramNotification(user, notification) { + try { + // Check if Telegram is configured + if (!isTelegramConfigured(user)) { + return { + success: false, + error: 'Telegram not configured for user', + }; + } + + // Format the notification message + const formattedMessage = formatNotificationMessage(user, notification); + + // Send the message via Telegram + await sendTelegramMessage( + user.telegram_bot_token, + user.telegram_chat_id, + formattedMessage + ); + + return { success: true }; + } catch (error) { + console.error('Failed to send Telegram notification:', error); + return { + success: false, + error: error.message || 'Unknown error', + }; + } +} + +module.exports = { + isTelegramConfigured, + formatNotificationMessage, + sendTelegramNotification, +}; diff --git a/backend/tests/integration/notification-preferences.test.js b/backend/tests/integration/notification-preferences.test.js index b1fdd0a..0890eca 100644 --- a/backend/tests/integration/notification-preferences.test.js +++ b/backend/tests/integration/notification-preferences.test.js @@ -32,11 +32,36 @@ describe('Notification Preferences', () => { expect(response.status).toBe(200); expect(response.body.notification_preferences).toEqual({ - dueTasks: { inApp: true, email: false, push: false }, - overdueTasks: { inApp: true, email: false, push: false }, - dueProjects: { inApp: true, email: false, push: false }, - overdueProjects: { inApp: true, email: false, push: false }, - deferUntil: { inApp: true, email: false, push: false }, + dueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + dueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { + inApp: true, + email: false, + push: false, + telegram: false, + }, }); }); diff --git a/backend/tests/unit/utils/notificationPreferences.test.js b/backend/tests/unit/utils/notificationPreferences.test.js index de64d5c..855c953 100644 --- a/backend/tests/unit/utils/notificationPreferences.test.js +++ b/backend/tests/unit/utils/notificationPreferences.test.js @@ -10,11 +10,36 @@ describe('notificationPreferences utils', () => { const defaults = getDefaultNotificationPreferences(); expect(defaults).toEqual({ - dueTasks: { inApp: true, email: false, push: false }, - overdueTasks: { inApp: true, email: false, push: false }, - dueProjects: { inApp: true, email: false, push: false }, - overdueProjects: { inApp: true, email: false, push: false }, - deferUntil: { inApp: true, email: false, push: false }, + dueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueTasks: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + dueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { + inApp: true, + email: false, + push: false, + telegram: false, + }, }); }); diff --git a/backend/utils/notificationPreferences.js b/backend/utils/notificationPreferences.js index 7039387..c26a0fa 100644 --- a/backend/utils/notificationPreferences.js +++ b/backend/utils/notificationPreferences.js @@ -3,11 +3,16 @@ */ const DEFAULT_PREFERENCES = { - dueTasks: { inApp: true, email: false, push: false }, - overdueTasks: { inApp: true, email: false, push: false }, - dueProjects: { inApp: true, email: false, push: false }, - overdueProjects: { inApp: true, email: false, push: false }, - deferUntil: { inApp: true, email: false, push: false }, + dueTasks: { inApp: true, email: false, push: false, telegram: false }, + overdueTasks: { inApp: true, email: false, push: false, telegram: false }, + dueProjects: { inApp: true, email: false, push: false, telegram: false }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { inApp: true, email: false, push: false, telegram: false }, }; /** @@ -47,6 +52,33 @@ function shouldSendInAppNotification(user, notificationType) { return prefs[prefKey].inApp !== false; } +/** + * Check if user has enabled Telegram notifications for a specific type + * @param {Object} user - User model instance with notification_preferences field + * @param {string} notificationType - Backend notification type (e.g., 'task_due_soon', 'task_overdue') + * @returns {boolean} - True if Telegram notifications are enabled for this type + */ +function shouldSendTelegramNotification(user, notificationType) { + // If no user or no preferences set, default to disabled for Telegram + if (!user || !user.notification_preferences) { + return false; + } + + const prefs = user.notification_preferences; + + // Map notification type to preference key + const prefKey = + NOTIFICATION_TYPE_MAPPING[notificationType] || notificationType; + + // If notification type not configured, default to disabled + if (!prefs[prefKey]) { + return false; + } + + // Check if telegram channel is enabled (default to false if not set) + return prefs[prefKey].telegram === true; +} + /** * Get default notification preferences * @returns {Object} - Default preferences object @@ -57,6 +89,7 @@ function getDefaultNotificationPreferences() { module.exports = { shouldSendInAppNotification, + shouldSendTelegramNotification, getDefaultNotificationPreferences, NOTIFICATION_TYPE_MAPPING, }; diff --git a/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index 972fa2e..51a4fd2 100644 --- a/frontend/components/Profile/ProfileSettings.tsx +++ b/frontend/components/Profile/ProfileSettings.tsx @@ -1051,166 +1051,199 @@ const ProfileSettings: React.FC = ({ return ( <>

{t('profile.title')}

- setActiveTab(id)} - /> +
+ {/* Left Sidebar */} + -
- - setFormData((prev) => ({ ...prev, appearance })) - } - onLanguageChange={(languageCode) => { - const localeFirstDay = - getLocaleFirstDayOfWeek(languageCode); - setFormData((prev) => ({ - ...prev, - language: languageCode, - first_day_of_week: localeFirstDay, - })); - }} - onTimezoneChange={(timezone) => - setFormData((prev) => ({ ...prev, timezone })) - } - onFirstDayChange={(value) => - setFormData((prev) => ({ - ...prev, - first_day_of_week: value, - })) - } - avatarPreview={avatarPreview} - onAvatarSelect={handleAvatarSelect} - onAvatarRemove={handleAvatarRemove} - timezonesByRegion={timezonesByRegion} - getRegionDisplayName={getRegionDisplayName} - /> + {/* Main Content */} +
+
+ + + setFormData((prev) => ({ + ...prev, + appearance, + })) + } + onLanguageChange={(languageCode) => { + const localeFirstDay = + getLocaleFirstDayOfWeek( + languageCode + ); + setFormData((prev) => ({ + ...prev, + language: languageCode, + first_day_of_week: localeFirstDay, + })); + }} + onTimezoneChange={(timezone) => + setFormData((prev) => ({ + ...prev, + timezone, + })) + } + onFirstDayChange={(value) => + setFormData((prev) => ({ + ...prev, + first_day_of_week: value, + })) + } + avatarPreview={avatarPreview} + onAvatarSelect={handleAvatarSelect} + onAvatarRemove={handleAvatarRemove} + timezonesByRegion={timezonesByRegion} + getRegionDisplayName={getRegionDisplayName} + /> - - setShowCurrentPassword((prev) => !prev) - } - onToggleNewPassword={() => - setShowNewPassword((prev) => !prev) - } - onToggleConfirmPassword={() => - setShowConfirmPassword((prev) => !prev) - } - /> + + setShowCurrentPassword((prev) => !prev) + } + onToggleNewPassword={() => + setShowNewPassword((prev) => !prev) + } + onToggleConfirmPassword={() => + setShowConfirmPassword((prev) => !prev) + } + /> - setApiKeyToDelete(apiKey)} - onUpdateNewName={setNewApiKeyName} - onUpdateNewExpiration={setNewApiKeyExpiration} - getApiKeyStatus={getApiKeyStatus} - formatDateTime={formatDateTime} - isCreatingApiKey={isCreatingApiKey} - /> + + setApiKeyToDelete(apiKey) + } + onUpdateNewName={setNewApiKeyName} + onUpdateNewExpiration={ + setNewApiKeyExpiration + } + getApiKeyStatus={getApiKeyStatus} + formatDateTime={formatDateTime} + isCreatingApiKey={isCreatingApiKey} + /> - - setFormData((prev) => ({ - ...prev, - pomodoro_enabled: !prev.pomodoro_enabled, - })) - } - /> + + setFormData((prev) => ({ + ...prev, + pomodoro_enabled: + !prev.pomodoro_enabled, + })) + } + /> - - setFormData((prev) => ({ - ...prev, - notification_preferences: preferences, - })) - } - /> + + setFormData((prev) => ({ + ...prev, + notification_preferences: + preferences, + })) + } + /> - - setFormData((prev) => ({ - ...prev, - task_summary_enabled: - !prev.task_summary_enabled, - })) - } - onSelectFrequency={(frequency) => - setFormData((prev) => ({ - ...prev, - task_summary_frequency: frequency, - })) - } - onSendTestSummary={handleSendTestSummary} - formatFrequency={formatFrequency} - /> + + setFormData((prev) => ({ + ...prev, + task_summary_enabled: + !prev.task_summary_enabled, + })) + } + onSelectFrequency={(frequency) => + setFormData((prev) => ({ + ...prev, + task_summary_frequency: frequency, + })) + } + onSendTestSummary={handleSendTestSummary} + formatFrequency={formatFrequency} + /> - - setFormData((prev) => ({ - ...prev, - [field]: !prev[field], - })) - } - /> + + setFormData((prev) => ({ + ...prev, + [field]: !prev[field], + })) + } + /> -
- +
+ +
+ +
- +
{apiKeyToDelete && ( = ({ isActive, formData, onToggle }) => { if (!isActive) return null; return ( -
+

{t( diff --git a/frontend/components/Profile/tabs/ApiKeysTab.tsx b/frontend/components/Profile/tabs/ApiKeysTab.tsx index 636878c..3ee755c 100644 --- a/frontend/components/Profile/tabs/ApiKeysTab.tsx +++ b/frontend/components/Profile/tabs/ApiKeysTab.tsx @@ -56,7 +56,7 @@ const ApiKeysTab: React.FC = ({ if (!isActive) return null; return ( -
+

{t('profile.apiKeys.title', 'API Keys')} diff --git a/frontend/components/Profile/tabs/GeneralTab.tsx b/frontend/components/Profile/tabs/GeneralTab.tsx index f3e8648..121ff1f 100644 --- a/frontend/components/Profile/tabs/GeneralTab.tsx +++ b/frontend/components/Profile/tabs/GeneralTab.tsx @@ -51,7 +51,7 @@ const GeneralTab: React.FC = ({ if (!isActive) return null; return ( -
+

{t('profile.accountSettings', 'Account & Preferences')} diff --git a/frontend/components/Profile/tabs/NotificationsTab.tsx b/frontend/components/Profile/tabs/NotificationsTab.tsx index 61c0617..9d80745 100644 --- a/frontend/components/Profile/tabs/NotificationsTab.tsx +++ b/frontend/components/Profile/tabs/NotificationsTab.tsx @@ -17,19 +17,33 @@ interface NotificationsTabProps { } const DEFAULT_PREFERENCES: NotificationPreferences = { - dueTasks: { inApp: true, email: false, push: false }, - overdueTasks: { inApp: true, email: false, push: false }, - dueProjects: { inApp: true, email: false, push: false }, - overdueProjects: { inApp: true, email: false, push: false }, - deferUntil: { inApp: true, email: false, push: false }, + dueTasks: { inApp: true, email: false, push: false, telegram: false }, + overdueTasks: { inApp: true, email: false, push: false, telegram: false }, + dueProjects: { inApp: true, email: false, push: false, telegram: false }, + overdueProjects: { + inApp: true, + email: false, + push: false, + telegram: false, + }, + deferUntil: { inApp: true, email: false, push: false, telegram: false }, }; interface NotificationTypeRowProps { icon: React.ComponentType<{ className?: string }>; label: string; description: string; - preferences: { inApp: boolean; email: boolean; push: boolean }; - onToggle: (channel: 'inApp' | 'email' | 'push', value: boolean) => void; + preferences: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; + onToggle: ( + channel: 'inApp' | 'email' | 'push' | 'telegram', + value: boolean + ) => void; + telegramConfigured: boolean; } const NotificationTypeRow: React.FC = ({ @@ -38,9 +52,10 @@ const NotificationTypeRow: React.FC = ({ description, preferences, onToggle, + telegramConfigured, }) => { const renderToggle = ( - channel: 'inApp' | 'email' | 'push', + channel: 'inApp' | 'email' | 'push' | 'telegram', isEnabled: boolean, isAvailable: boolean ) => ( @@ -90,6 +105,13 @@ const NotificationTypeRow: React.FC = ({ {renderToggle('push', preferences.push, false)} + + {renderToggle( + 'telegram', + preferences.telegram, + telegramConfigured + )} + ); }; @@ -100,6 +122,21 @@ const NotificationsTab: React.FC = ({ onChange, }) => { const { t } = useTranslation(); + const [profile, setProfile] = React.useState(null); + const [selectedTestType, setSelectedTestType] = + React.useState('task_due_soon'); + const [testLoading, setTestLoading] = React.useState(false); + const [testMessage, setTestMessage] = React.useState(''); + + // Fetch profile data to check telegram configuration + React.useEffect(() => { + if (isActive) { + fetch('/api/profile') + .then((res) => res.json()) + .then((data) => setProfile(data)) + .catch((err) => console.error('Failed to fetch profile', err)); + } + }, [isActive]); if (!isActive) return null; @@ -109,9 +146,14 @@ const NotificationsTab: React.FC = ({ ...notificationPreferences, }; + // Check if Telegram is configured + const telegramConfigured = !!( + profile?.telegram_bot_token && profile?.telegram_chat_id + ); + const handleToggle = ( notificationType: keyof NotificationPreferences, - channel: 'inApp' | 'email' | 'push', + channel: 'inApp' | 'email' | 'push' | 'telegram', value: boolean ) => { const updatedPreferences = { @@ -124,8 +166,42 @@ const NotificationsTab: React.FC = ({ onChange(updatedPreferences); }; + const handleTestNotification = async () => { + setTestLoading(true); + setTestMessage(''); + + try { + const response = await fetch('/api/test-notifications/trigger', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ type: selectedTestType }), + }); + + const data = await response.json(); + + if (response.ok) { + const sources = data.notification.sources; + const sourcesList = + sources.length > 0 ? sources.join(', ') : 'in-app only'; + setTestMessage(`āœ… Test notification sent! (${sourcesList})`); + } else { + setTestMessage(`āŒ Failed: ${data.error}`); + } + } catch (error) { + setTestMessage( + `āŒ Error: ${error.message || 'Failed to send test'}` + ); + } finally { + setTestLoading(false); + // Clear message after 5 seconds + setTimeout(() => setTestMessage(''), 5000); + } + }; + return ( -
+

{t('profile.tabs.notifications', 'Notification Preferences')} @@ -137,6 +213,24 @@ const NotificationsTab: React.FC = ({ )}

+ {/* Telegram Not Configured Warning */} + {!telegramConfigured && ( +
+

+ + {t( + 'notifications.telegram.notConfigured.title', + 'Telegram Not Configured:' + )} + {' '} + {t( + 'notifications.telegram.notConfigured.message', + 'To receive Telegram notifications, please configure your Telegram bot in the Telegram tab.' + )} +

+
+ )} + {/* Notifications Table */}
@@ -169,6 +263,12 @@ const NotificationsTab: React.FC = ({ + @@ -186,6 +286,7 @@ const NotificationsTab: React.FC = ({ onToggle={(channel, value) => handleToggle('dueTasks', channel, value) } + telegramConfigured={telegramConfigured} /> = ({ onToggle={(channel, value) => handleToggle('overdueTasks', channel, value) } + telegramConfigured={telegramConfigured} /> = ({ onToggle={(channel, value) => handleToggle('deferUntil', channel, value) } + telegramConfigured={telegramConfigured} /> = ({ onToggle={(channel, value) => handleToggle('dueProjects', channel, value) } + telegramConfigured={telegramConfigured} /> = ({ onToggle={(channel, value) => handleToggle('overdueProjects', channel, value) } + telegramConfigured={telegramConfigured} />
+ {t( + 'notifications.channels.telegram', + 'Telegram' + )} +
+ {/* Test Notifications Section */} +
+

+ + {t('notifications.test.title', 'Test Notifications')} +

+

+ {t( + 'notifications.test.description', + 'Send a test notification to see how it appears in-app and on enabled channels (Telegram, etc.)' + )} +

+
+ + +
+ {testMessage && ( +
+ {testMessage} +
+ )} +
+ {/* Help Text */}

@@ -259,7 +448,7 @@ const NotificationsTab: React.FC = ({ {' '} {t( 'notifications.info.message', - 'Email and Push notifications are coming soon. In-app notifications are currently available.' + 'Email and Push notifications are coming soon. In-app and Telegram notifications are currently available.' )}

diff --git a/frontend/components/Profile/tabs/ProductivityTab.tsx b/frontend/components/Profile/tabs/ProductivityTab.tsx index 26a2243..b7adc87 100644 --- a/frontend/components/Profile/tabs/ProductivityTab.tsx +++ b/frontend/components/Profile/tabs/ProductivityTab.tsx @@ -18,7 +18,7 @@ const ProductivityTab: React.FC = ({ if (!isActive) return null; return ( -
+

{t('profile.productivityFeatures', 'Productivity Features')} diff --git a/frontend/components/Profile/tabs/SecurityTab.tsx b/frontend/components/Profile/tabs/SecurityTab.tsx index 26d5e71..39c40e6 100644 --- a/frontend/components/Profile/tabs/SecurityTab.tsx +++ b/frontend/components/Profile/tabs/SecurityTab.tsx @@ -37,7 +37,7 @@ const SecurityTab: React.FC = ({ if (!isActive) return null; return ( -
+

{t('profile.security', 'Security Settings')} diff --git a/frontend/components/Profile/tabs/TabsNav.tsx b/frontend/components/Profile/tabs/TabsNav.tsx index ab9d41e..f453382 100644 --- a/frontend/components/Profile/tabs/TabsNav.tsx +++ b/frontend/components/Profile/tabs/TabsNav.tsx @@ -13,27 +13,23 @@ interface TabsNavProps { } const TabsNav: React.FC = ({ tabs, activeTab, onChange }) => ( -
-
- -
-
+ ); export type { TabConfig }; diff --git a/frontend/components/Profile/tabs/TelegramTab.tsx b/frontend/components/Profile/tabs/TelegramTab.tsx index 49ac7ca..fc3ab42 100644 --- a/frontend/components/Profile/tabs/TelegramTab.tsx +++ b/frontend/components/Profile/tabs/TelegramTab.tsx @@ -46,7 +46,7 @@ const TelegramTab: React.FC = ({ if (!isActive) return null; return ( -
+

{t('profile.telegramIntegration', 'Telegram Integration')} diff --git a/frontend/components/Profile/types.ts b/frontend/components/Profile/types.ts index 9557b63..68cd267 100644 --- a/frontend/components/Profile/types.ts +++ b/frontend/components/Profile/types.ts @@ -5,11 +5,36 @@ export interface ProfileSettingsProps { } export interface NotificationPreferences { - dueTasks: { inApp: boolean; email: boolean; push: boolean }; - overdueTasks: { inApp: boolean; email: boolean; push: boolean }; - dueProjects: { inApp: boolean; email: boolean; push: boolean }; - overdueProjects: { inApp: boolean; email: boolean; push: boolean }; - deferUntil: { inApp: boolean; email: boolean; push: boolean }; + dueTasks: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; + overdueTasks: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; + dueProjects: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; + overdueProjects: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; + deferUntil: { + inApp: boolean; + email: boolean; + push: boolean; + telegram: boolean; + }; } export interface Profile { diff --git a/package-lock.json b/package-lock.json index 3ad0b29..892f170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tududi", - "version": "v0.87", + "version": "v0.88.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tududi", - "version": "v0.87", + "version": "v0.88.0-dev.1", "license": "ISC", "dependencies": { "@playwright/test": "^1.57.0",