Feat telegram notifications (#692)
* Add telegram notifications * fixup! Add telegram notifications * Cleanup
This commit is contained in:
parent
25bdae9ee0
commit
819faf0d18
25 changed files with 1057 additions and 523 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
200
backend/routes/test-notifications.js
Normal file
200
backend/routes/test-notifications.js
Normal 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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
70
backend/services/telegramNotificationService.js
Normal file
70
backend/services/telegramNotificationService.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue