Feat telegram notifications (#692)

* Add telegram notifications

* fixup! Add telegram notifications

* Cleanup
This commit is contained in:
Chris 2025-12-09 20:26:53 +02:00 committed by GitHub
parent 25bdae9ee0
commit 819faf0d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1057 additions and 523 deletions

View file

@ -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

View file

@ -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',
}
);
},
};

View file

@ -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();

View file

@ -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: {

View file

@ -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;

View file

@ -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 };

View file

@ -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 };

View file

@ -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: {

View file

@ -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,

View file

@ -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,

View file

@ -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<Object>} - { 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,
};

View file

@ -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,
},
});
});

View file

@ -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,
},
});
});

View file

@ -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,
};

View file

@ -1051,166 +1051,199 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
return (
<>
<div
className="max-w-5xl mx-auto p-6"
className="max-w-7xl mx-auto p-6"
key={`profile-settings-${updateKey}`}
>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
{t('profile.title')}
</h2>
<TabsNav
tabs={tabs}
activeTab={activeTab}
onChange={(id) => setActiveTab(id)}
/>
<div className="flex gap-8">
{/* Left Sidebar */}
<aside className="w-64 flex-shrink-0">
<div className="sticky top-6 bg-white dark:bg-gray-950 rounded-lg shadow-md p-4">
<TabsNav
tabs={tabs}
activeTab={activeTab}
onChange={(id) => setActiveTab(id)}
/>
</div>
</aside>
<form onSubmit={handleSubmit} className="space-y-8">
<GeneralTab
isActive={activeTab === 'general'}
formData={formData}
onChange={handleChange}
onAppearanceChange={(appearance) =>
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 */}
<div className="flex-1 min-w-0">
<div className="bg-white dark:bg-gray-950 rounded-lg shadow-md p-6">
<form onSubmit={handleSubmit} className="space-y-8">
<GeneralTab
isActive={activeTab === 'general'}
formData={formData}
onChange={handleChange}
onAppearanceChange={(appearance) =>
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}
/>
<SecurityTab
isActive={activeTab === 'security'}
formData={formData}
showCurrentPassword={showCurrentPassword}
showNewPassword={showNewPassword}
showConfirmPassword={showConfirmPassword}
onChange={handleChange}
onToggleCurrentPassword={() =>
setShowCurrentPassword((prev) => !prev)
}
onToggleNewPassword={() =>
setShowNewPassword((prev) => !prev)
}
onToggleConfirmPassword={() =>
setShowConfirmPassword((prev) => !prev)
}
/>
<SecurityTab
isActive={activeTab === 'security'}
formData={formData}
showCurrentPassword={showCurrentPassword}
showNewPassword={showNewPassword}
showConfirmPassword={showConfirmPassword}
onChange={handleChange}
onToggleCurrentPassword={() =>
setShowCurrentPassword((prev) => !prev)
}
onToggleNewPassword={() =>
setShowNewPassword((prev) => !prev)
}
onToggleConfirmPassword={() =>
setShowConfirmPassword((prev) => !prev)
}
/>
<ApiKeysTab
isActive={activeTab === 'apiKeys'}
apiKeys={apiKeys}
apiKeysLoading={apiKeysLoading}
generatedApiToken={generatedApiToken}
newApiKeyName={newApiKeyName}
newApiKeyExpiration={newApiKeyExpiration}
revokeInFlightId={revokeInFlightId}
deleteInFlightId={deleteInFlightId}
pendingDeleteId={apiKeyToDelete?.id ?? null}
onCreateApiKey={handleCreateApiKey}
onCopyGeneratedToken={handleCopyGeneratedToken}
onRevokeApiKey={handleRevokeApiKey}
onRequestDelete={(apiKey) => setApiKeyToDelete(apiKey)}
onUpdateNewName={setNewApiKeyName}
onUpdateNewExpiration={setNewApiKeyExpiration}
getApiKeyStatus={getApiKeyStatus}
formatDateTime={formatDateTime}
isCreatingApiKey={isCreatingApiKey}
/>
<ApiKeysTab
isActive={activeTab === 'apiKeys'}
apiKeys={apiKeys}
apiKeysLoading={apiKeysLoading}
generatedApiToken={generatedApiToken}
newApiKeyName={newApiKeyName}
newApiKeyExpiration={newApiKeyExpiration}
revokeInFlightId={revokeInFlightId}
deleteInFlightId={deleteInFlightId}
pendingDeleteId={apiKeyToDelete?.id ?? null}
onCreateApiKey={handleCreateApiKey}
onCopyGeneratedToken={
handleCopyGeneratedToken
}
onRevokeApiKey={handleRevokeApiKey}
onRequestDelete={(apiKey) =>
setApiKeyToDelete(apiKey)
}
onUpdateNewName={setNewApiKeyName}
onUpdateNewExpiration={
setNewApiKeyExpiration
}
getApiKeyStatus={getApiKeyStatus}
formatDateTime={formatDateTime}
isCreatingApiKey={isCreatingApiKey}
/>
<ProductivityTab
isActive={activeTab === 'productivity'}
pomodoroEnabled={Boolean(formData.pomodoro_enabled)}
onTogglePomodoro={() =>
setFormData((prev) => ({
...prev,
pomodoro_enabled: !prev.pomodoro_enabled,
}))
}
/>
<ProductivityTab
isActive={activeTab === 'productivity'}
pomodoroEnabled={Boolean(
formData.pomodoro_enabled
)}
onTogglePomodoro={() =>
setFormData((prev) => ({
...prev,
pomodoro_enabled:
!prev.pomodoro_enabled,
}))
}
/>
<NotificationsTab
isActive={activeTab === 'notifications'}
notificationPreferences={
formData.notification_preferences
}
onChange={(preferences) =>
setFormData((prev) => ({
...prev,
notification_preferences: preferences,
}))
}
/>
<NotificationsTab
isActive={activeTab === 'notifications'}
notificationPreferences={
formData.notification_preferences
}
onChange={(preferences) =>
setFormData((prev) => ({
...prev,
notification_preferences:
preferences,
}))
}
/>
<TelegramTab
isActive={activeTab === 'telegram'}
formData={formData}
profile={profile}
telegramBotInfo={telegramBotInfo}
isPolling={isPolling}
telegramSetupStatus={telegramSetupStatus}
onChange={handleChange}
onSetup={handleSetupTelegram}
onStartPolling={handleStartPolling}
onStopPolling={handleStopPolling}
onToggleSummary={() =>
setFormData((prev) => ({
...prev,
task_summary_enabled:
!prev.task_summary_enabled,
}))
}
onSelectFrequency={(frequency) =>
setFormData((prev) => ({
...prev,
task_summary_frequency: frequency,
}))
}
onSendTestSummary={handleSendTestSummary}
formatFrequency={formatFrequency}
/>
<TelegramTab
isActive={activeTab === 'telegram'}
formData={formData}
profile={profile}
telegramBotInfo={telegramBotInfo}
isPolling={isPolling}
telegramSetupStatus={telegramSetupStatus}
onChange={handleChange}
onSetup={handleSetupTelegram}
onStartPolling={handleStartPolling}
onStopPolling={handleStopPolling}
onToggleSummary={() =>
setFormData((prev) => ({
...prev,
task_summary_enabled:
!prev.task_summary_enabled,
}))
}
onSelectFrequency={(frequency) =>
setFormData((prev) => ({
...prev,
task_summary_frequency: frequency,
}))
}
onSendTestSummary={handleSendTestSummary}
formatFrequency={formatFrequency}
/>
<AiTab
isActive={activeTab === 'ai'}
formData={formData}
onToggle={(field) =>
setFormData((prev) => ({
...prev,
[field]: !prev[field],
}))
}
/>
<AiTab
isActive={activeTab === 'ai'}
formData={formData}
onToggle={(field) =>
setFormData((prev) => ({
...prev,
[field]: !prev[field],
}))
}
/>
<div className="flex justify-end dark:border-gray-700">
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors duration-200 flex items-center space-x-2"
>
<CheckIcon className="w-5 h-5" />
<span>
{t('profile.saveChanges', 'Save Changes')}
</span>
</button>
<div className="flex justify-end dark:border-gray-700">
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors duration-200 flex items-center space-x-2"
>
<CheckIcon className="w-5 h-5" />
<span>
{t(
'profile.saveChanges',
'Save Changes'
)}
</span>
</button>
</div>
</form>
</div>
</div>
</form>
</div>
</div>
{apiKeyToDelete && (
<ConfirmDialog

View file

@ -63,7 +63,7 @@ const AiTab: React.FC<AiTabProps> = ({ isActive, formData, onToggle }) => {
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<LightBulbIcon className="w-6 h-6 mr-3 text-blue-500" />
{t(

View file

@ -56,7 +56,7 @@ const ApiKeysTab: React.FC<ApiKeysTabProps> = ({
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<KeyIcon className="w-6 h-6 mr-3 text-indigo-500" />
{t('profile.apiKeys.title', 'API Keys')}

View file

@ -51,7 +51,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<UserIcon className="w-6 h-6 mr-3 text-blue-500" />
{t('profile.accountSettings', 'Account & Preferences')}

View file

@ -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<NotificationTypeRowProps> = ({
@ -38,9 +52,10 @@ const NotificationTypeRow: React.FC<NotificationTypeRowProps> = ({
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<NotificationTypeRowProps> = ({
<td className="py-4 px-4 text-center">
{renderToggle('push', preferences.push, false)}
</td>
<td className="py-4 px-4 text-center">
{renderToggle(
'telegram',
preferences.telegram,
telegramConfigured
)}
</td>
</tr>
);
};
@ -100,6 +122,21 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onChange,
}) => {
const { t } = useTranslation();
const [profile, setProfile] = React.useState<any>(null);
const [selectedTestType, setSelectedTestType] =
React.useState<string>('task_due_soon');
const [testLoading, setTestLoading] = React.useState<boolean>(false);
const [testMessage, setTestMessage] = React.useState<string>('');
// 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<NotificationsTabProps> = ({
...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<NotificationsTabProps> = ({
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 (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
<BellIcon className="w-6 h-6 mr-3 text-purple-500" />
{t('profile.tabs.notifications', 'Notification Preferences')}
@ -137,6 +213,24 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
)}
</p>
{/* Telegram Not Configured Warning */}
{!telegramConfigured && (
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<span className="font-medium">
{t(
'notifications.telegram.notConfigured.title',
'Telegram Not Configured:'
)}
</span>{' '}
{t(
'notifications.telegram.notConfigured.message',
'To receive Telegram notifications, please configure your Telegram bot in the Telegram tab.'
)}
</p>
</div>
)}
{/* Notifications Table */}
<div className="overflow-x-auto">
<table className="w-full">
@ -169,6 +263,12 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
</span>
</div>
</th>
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
{t(
'notifications.channels.telegram',
'Telegram'
)}
</th>
</tr>
</thead>
<tbody>
@ -186,6 +286,7 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onToggle={(channel, value) =>
handleToggle('dueTasks', channel, value)
}
telegramConfigured={telegramConfigured}
/>
<NotificationTypeRow
icon={ExclamationTriangleIcon}
@ -201,6 +302,7 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onToggle={(channel, value) =>
handleToggle('overdueTasks', channel, value)
}
telegramConfigured={telegramConfigured}
/>
<NotificationTypeRow
icon={ClockIcon}
@ -216,6 +318,7 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onToggle={(channel, value) =>
handleToggle('deferUntil', channel, value)
}
telegramConfigured={telegramConfigured}
/>
<NotificationTypeRow
icon={FolderIcon}
@ -231,6 +334,7 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onToggle={(channel, value) =>
handleToggle('dueProjects', channel, value)
}
telegramConfigured={telegramConfigured}
/>
<NotificationTypeRow
icon={FolderOpenIcon}
@ -246,11 +350,96 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
onToggle={(channel, value) =>
handleToggle('overdueProjects', channel, value)
}
telegramConfigured={telegramConfigured}
/>
</tbody>
</table>
</div>
{/* Test Notifications Section */}
<div className="mt-6 p-6 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h4 className="text-sm font-semibold text-purple-900 dark:text-purple-100 mb-3 flex items-center">
<ClockIcon className="w-4 h-4 mr-2" />
{t('notifications.test.title', 'Test Notifications')}
</h4>
<p className="text-xs text-purple-700 dark:text-purple-300 mb-4">
{t(
'notifications.test.description',
'Send a test notification to see how it appears in-app and on enabled channels (Telegram, etc.)'
)}
</p>
<div className="flex items-center gap-3">
<select
value={selectedTestType}
onChange={(e) => setSelectedTestType(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-purple-300 dark:border-purple-700 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="task_due_soon">
{t('notifications.types.dueTasks', 'Due Tasks')}
</option>
<option value="task_overdue">
{t(
'notifications.types.overdueTasks',
'Overdue Tasks'
)}
</option>
<option value="defer_until">
{t('notifications.types.deferUntil', 'Defer Until')}
</option>
<option value="project_due_soon">
{t(
'notifications.types.dueProjects',
'Due Projects'
)}
</option>
<option value="project_overdue">
{t(
'notifications.types.overdueProjects',
'Overdue Projects'
)}
</option>
</select>
<button
onClick={handleTestNotification}
disabled={testLoading}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-colors"
>
{testLoading ? (
<span className="flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('notifications.test.sending', 'Sending...')}
</span>
) : (
t('notifications.test.send', 'Send Test')
)}
</button>
</div>
{testMessage && (
<div className="mt-3 p-2 text-sm text-purple-900 dark:text-purple-100 bg-purple-100 dark:bg-purple-900/40 rounded">
{testMessage}
</div>
)}
</div>
{/* Help Text */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
@ -259,7 +448,7 @@ const NotificationsTab: React.FC<NotificationsTabProps> = ({
</span>{' '}
{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.'
)}
</p>
</div>

View file

@ -18,7 +18,7 @@ const ProductivityTab: React.FC<ProductivityTabProps> = ({
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<ClockIcon className="w-6 h-6 mr-3 text-green-500" />
{t('profile.productivityFeatures', 'Productivity Features')}

View file

@ -37,7 +37,7 @@ const SecurityTab: React.FC<SecurityTabProps> = ({
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<ShieldCheckIcon className="w-6 h-6 mr-3 text-red-500" />
{t('profile.security', 'Security Settings')}

View file

@ -13,27 +13,23 @@ interface TabsNavProps {
}
const TabsNav: React.FC<TabsNavProps> = ({ tabs, activeTab, onChange }) => (
<div className="mb-8">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-2 sm:space-x-8 overflow-x-auto scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`group inline-flex items-center py-2 px-1 sm:px-2 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<span className="mr-1 sm:mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</nav>
</div>
</div>
<nav className="space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700/50'
}`}
>
<span className="mr-3 flex-shrink-0">{tab.icon}</span>
<span className="text-left">{tab.name}</span>
</button>
))}
</nav>
);
export type { TabConfig };

View file

@ -46,7 +46,7 @@ const TelegramTab: React.FC<TelegramTabProps> = ({
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-blue-300 dark:border-blue-700 mb-8">
<div className="mb-8">
<h3 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-6 flex items-center">
<TelegramIcon className="w-6 h-6 mr-3 text-blue-500" />
{t('profile.telegramIntegration', 'Telegram Integration')}

View file

@ -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 {

4
package-lock.json generated
View file

@ -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",