diff --git a/Dockerfile b/Dockerfile index 8e63d3c33..17da21758 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,7 +128,10 @@ ENV NODE_ENV=production \ SWAGGER_ENABLED=false \ FF_ENABLE_BACKUPS=false \ FF_ENABLE_CALENDAR=false \ - FF_ENABLE_HABITS=false + FF_ENABLE_HABITS=false \ + VAPID_PUBLIC_KEY="" \ + VAPID_PRIVATE_KEY="" \ + VAPID_SUBJECT="mailto:admin@localhost" HEALTHCHECK --interval=60s --timeout=3s --start-period=10s --retries=2 \ CMD ["wget", "-q", "--spider", "http://127.0.0.1:3002/api/health"] diff --git a/README.md b/README.md index da04f3f1e..face17443 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,49 @@ curl -X POST \ For full API documentation, visit `/api-docs` after authentication or check the Swagger schema definitions in [`backend/config/swagger.js`](backend/config/swagger.js). +## šŸ”” Push Notifications + +Tududi supports browser push notifications (PWA) for tasks and project updates. + +### Setup + +**1. Generate VAPID keys:** +```bash +npm run vapid:generate +``` + +**2. Development:** Add to `backend/.env`: +```bash +VAPID_PUBLIC_KEY=your_public_key_here +VAPID_PRIVATE_KEY=your_private_key_here +VAPID_SUBJECT=mailto:your-email@example.com +``` + +**3. Production:** Set environment variables based on your deployment: + +**Docker Compose:** Edit `docker-compose.yml`: +```yaml +environment: + - VAPID_PUBLIC_KEY=your_public_key_here + - VAPID_PRIVATE_KEY=your_private_key_here + - VAPID_SUBJECT=mailto:your-email@example.com +``` + +**Docker CLI:** +```bash +docker run \ + -e VAPID_PUBLIC_KEY=your_public_key_here \ + -e VAPID_PRIVATE_KEY=your_private_key_here \ + -e VAPID_SUBJECT=mailto:your-email@example.com \ + # ... other options +``` + +**Cloud/Kubernetes:** Use platform secrets management + +### Usage + +Once configured, users can enable push notifications in **Settings → Notifications → Browser Push Notifications**. + ## šŸ¤ Contributing Contributions to tududi are welcome! Whether it's bug fixes, new features, documentation improvements, or translations, we appreciate your help. diff --git a/backend/.env.example b/backend/.env.example index e8986a1c0..e094985bc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,14 @@ TUDUDI_USER_EMAIL=admin@example.com TUDUDI_USER_PASSWORD=change-me-to-secure-password TUDUDI_SESSION_SECRET=your-random-64-character-hex-string-here +# VAPID keys: generate with "npm run vapid:generate" +# VAPID Public Key (safe to expose to frontend) +VAPID_PUBLIC_KEY= +# VAPID Private Key (KEEP SECRET - never commit to repo) +VAPID_PRIVATE_KEY= +# VAPID Subject (your contact email or website URL) +VAPID_SUBJECT=mailto:admin@example.com + ENABLE_EMAIL=false EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 diff --git a/backend/app.js b/backend/app.js index 8b5cf3dbc..38bc15dbb 100644 --- a/backend/app.js +++ b/backend/app.js @@ -72,6 +72,12 @@ app.use( }) ); +// Service Worker scope header - allow SW at /pwa/sw.js to control root scope +app.get('/pwa/sw.js', (req, res, next) => { + res.set('Service-Worker-Allowed', '/'); + next(); +}); + // Static files if (config.production) { app.use(express.static(path.join(__dirname, 'dist'))); diff --git a/backend/migrations/20251227000001-add-push-subscriptions.js b/backend/migrations/20251227000001-add-push-subscriptions.js new file mode 100644 index 000000000..6cade2bd4 --- /dev/null +++ b/backend/migrations/20251227000001-add-push-subscriptions.js @@ -0,0 +1,56 @@ +'use strict'; + +const { safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('push_subscriptions', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + endpoint: { + type: Sequelize.TEXT, + allowNull: false, + }, + keys: { + type: Sequelize.JSON, + allowNull: false, + comment: 'Contains p256dh and auth keys for encryption', + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'push_subscriptions', ['user_id'], { + name: 'push_subscriptions_user_id_idx', + }); + + await safeAddIndex(queryInterface, 'push_subscriptions', ['endpoint'], { + name: 'push_subscriptions_endpoint_idx', + unique: true, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('push_subscriptions'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 0b0439899..1a5982c13 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -68,6 +68,7 @@ const Notification = require('./notification')(sequelize); const RecurringCompletion = require('./recurringCompletion')(sequelize); const TaskAttachment = require('./task_attachment')(sequelize); const Backup = require('./backup')(sequelize); +const PushSubscription = require('./push_subscription')(sequelize); User.hasMany(Area, { foreignKey: 'user_id' }); Area.belongsTo(User, { foreignKey: 'user_id' }); @@ -188,6 +189,13 @@ TaskAttachment.belongsTo(Task, { foreignKey: 'task_id' }); User.hasMany(Backup, { foreignKey: 'user_id', as: 'Backups' }); Backup.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +// PushSubscription associations +User.hasMany(PushSubscription, { + foreignKey: 'user_id', + as: 'PushSubscriptions', +}); +PushSubscription.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); + module.exports = { sequelize, User, @@ -208,4 +216,5 @@ module.exports = { RecurringCompletion, TaskAttachment, Backup, + PushSubscription, }; diff --git a/backend/models/notification.js b/backend/models/notification.js index c5a29702f..58c996a48 100644 --- a/backend/models/notification.js +++ b/backend/models/notification.js @@ -73,7 +73,12 @@ module.exports = (sequelize) => { if (!Array.isArray(value)) { throw new Error('Sources must be an array'); } - const validSources = ['telegram', 'mobile', 'email']; + const validSources = [ + 'telegram', + 'mobile', + 'email', + 'push', + ]; const invalidSources = value.filter( (s) => !validSources.includes(s) ); @@ -168,9 +173,29 @@ module.exports = (sequelize) => { ); } + if (sources.includes('push')) { + await sendWebPushNotification(userId, { + title, + message, + data, + type, + }); + } + return notification; }; + async function sendWebPushNotification(userId, notification) { + try { + const webPushService = require('../services/webPushService'); + if (webPushService.isWebPushConfigured()) { + await webPushService.sendPushNotification(userId, notification); + } + } catch (error) { + console.error('Failed to send Web Push notification:', error); + } + } + async function sendEmailNotification( userId, title, diff --git a/backend/models/push_subscription.js b/backend/models/push_subscription.js new file mode 100644 index 000000000..2325cec03 --- /dev/null +++ b/backend/models/push_subscription.js @@ -0,0 +1,42 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const PushSubscription = sequelize.define( + 'PushSubscription', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + endpoint: { + type: DataTypes.TEXT, + allowNull: false, + }, + keys: { + type: DataTypes.JSON, + allowNull: false, + }, + }, + { + tableName: 'push_subscriptions', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { fields: ['user_id'] }, + { fields: ['endpoint'], unique: true }, + ], + } + ); + + return PushSubscription; +}; diff --git a/backend/modules/notifications/controller.js b/backend/modules/notifications/controller.js index dddc5f21f..2d69c1c1d 100644 --- a/backend/modules/notifications/controller.js +++ b/backend/modules/notifications/controller.js @@ -81,6 +81,70 @@ const notificationsController = { next(error); } }, + + async getVapidKey(req, res, next) { + try { + const result = await notificationsService.getVapidKey(); + res.json(result); + } catch (error) { + next(error); + } + }, + + async subscribe(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.subscribe( + userId, + req.body.subscription + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async unsubscribe(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.unsubscribe( + userId, + req.body.endpoint + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async triggerTest(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.triggerTest( + userId, + req.body.type + ); + res.json(result); + } catch (error) { + // Handle ValidationError with availableTypes + if (error.availableTypes) { + return res.status(error.statusCode || 400).json({ + error: error.message, + availableTypes: error.availableTypes, + }); + } + next(error); + } + }, + + async getTestTypes(req, res, next) { + try { + const result = await notificationsService.getTestTypes(); + res.json(result); + } catch (error) { + next(error); + } + }, }; module.exports = notificationsController; diff --git a/backend/modules/notifications/routes.js b/backend/modules/notifications/routes.js index d1b35706d..ea17680e7 100644 --- a/backend/modules/notifications/routes.js +++ b/backend/modules/notifications/routes.js @@ -16,5 +16,16 @@ router.post( notificationsController.markAllAsRead ); router.delete('/notifications/:id', notificationsController.dismiss); +router.get( + '/notifications/push/vapid-key', + notificationsController.getVapidKey +); +router.post('/notifications/push/subscribe', notificationsController.subscribe); +router.delete( + '/notifications/push/unsubscribe', + notificationsController.unsubscribe +); +router.post('/notifications/test/trigger', notificationsController.triggerTest); +router.get('/notifications/test/types', notificationsController.getTestTypes); module.exports = router; diff --git a/backend/modules/notifications/service.js b/backend/modules/notifications/service.js index 625f2ca4e..4892050a5 100644 --- a/backend/modules/notifications/service.js +++ b/backend/modules/notifications/service.js @@ -1,7 +1,14 @@ 'use strict'; const notificationsRepository = require('./repository'); -const { NotFoundError } = require('../../shared/errors'); +const { + NotFoundError, + ValidationError, + AppError, +} = require('../../shared/errors'); +const webPushService = require('../../services/webPushService'); +const { Notification, User } = require('../../models'); +const { v4: uuid } = require('uuid'); class NotificationsService { async getAll(userId, options) { @@ -60,6 +67,220 @@ class NotificationsService { await notification.dismiss(); return { message: 'Notification dismissed successfully' }; } + + async getVapidKey() { + const publicKey = webPushService.getVapidPublicKey(); + if (!publicKey) { + throw new AppError( + 'Push notifications not configured', + 503, + 'SERVICE_UNAVAILABLE' + ); + } + return { publicKey }; + } + + async subscribe(userId, subscription) { + if (!subscription || !subscription.endpoint || !subscription.keys) { + throw new ValidationError('Invalid subscription object'); + } + + const result = await webPushService.subscribe(userId, subscription); + if (!result.success) { + throw new Error(result.error || 'Failed to subscribe'); + } + + return { success: true, created: result.created }; + } + + async unsubscribe(userId, endpoint) { + if (!endpoint) { + throw new ValidationError('Endpoint is required'); + } + + const result = await webPushService.unsubscribe(userId, endpoint); + return { success: true, deleted: result.deleted }; + } + + async triggerTest(userId, type) { + const templates = this.getNotificationTemplates(); + const availableTypes = Object.keys(templates); + + if (!type) { + const error = new ValidationError('Notification type is required'); + error.availableTypes = availableTypes; + throw error; + } + + const template = templates[type]; + if (!template) { + const error = new ValidationError('Invalid notification type'); + error.availableTypes = availableTypes; + throw error; + } + + // 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) { + throw new NotFoundError('User not found'); + } + + // Get sources based on user preferences + const sources = this.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(), + }); + + return { + success: true, + notification: { + id: notification.id, + type: type, + title: template.title, + message: template.message, + sources: sources, + }, + }; + } + + async getTestTypes() { + const templates = this.getNotificationTemplates(); + const types = Object.keys(templates).map((key) => ({ + type: key, + title: templates[key].title, + level: templates[key].level, + })); + + return { types }; + } + + getNotificationTemplates() { + return { + 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', + }, + }, + }; + } + + 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'); + } + + // Add push to sources if enabled + if (prefs[prefKey].push === true) { + sources.push('push'); + } + + return sources; + } } module.exports = new NotificationsService(); diff --git a/backend/modules/projects/dueProjectService.js b/backend/modules/projects/dueProjectService.js index 920b44f9b..75212201e 100644 --- a/backend/modules/projects/dueProjectService.js +++ b/backend/modules/projects/dueProjectService.js @@ -4,7 +4,8 @@ const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, -} = require('../../utils/notificationPreferences'); + shouldSendPushNotification, +} = require('../../../utils/notificationPreferences'); /** * Service to check for due and overdue projects @@ -113,6 +114,11 @@ async function checkDueProjects() { ) { sources.push('telegram'); } + if ( + shouldSendPushNotification(project.User, notificationType) + ) { + sources.push('push'); + } await Notification.createNotification({ userId: project.user_id, diff --git a/backend/modules/tasks/deferredTaskService.js b/backend/modules/tasks/deferredTaskService.js index 03d9231c3..11236c608 100644 --- a/backend/modules/tasks/deferredTaskService.js +++ b/backend/modules/tasks/deferredTaskService.js @@ -4,6 +4,7 @@ const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, + shouldSendPushNotification, } = require('../../utils/notificationPreferences'); async function checkDeferredTasks() { @@ -75,6 +76,9 @@ async function checkDeferredTasks() { if (shouldSendTelegramNotification(task.User, 'deferUntil')) { sources.push('telegram'); } + if (shouldSendPushNotification(task.User, 'deferUntil')) { + sources.push('push'); + } await Notification.createNotification({ userId: task.user_id, diff --git a/backend/modules/tasks/dueTaskService.js b/backend/modules/tasks/dueTaskService.js index 04b5f6203..099821b2f 100644 --- a/backend/modules/tasks/dueTaskService.js +++ b/backend/modules/tasks/dueTaskService.js @@ -4,6 +4,7 @@ const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, + shouldSendPushNotification, } = require('../../utils/notificationPreferences'); /** @@ -108,6 +109,9 @@ async function checkDueTasks() { ) { sources.push('telegram'); } + if (shouldSendPushNotification(task.User, notificationType)) { + sources.push('push'); + } await Notification.createNotification({ userId: task.user_id, diff --git a/backend/scripts/db-migrate.js b/backend/scripts/db-migrate.js index ba1aa54cf..292b2c60b 100755 --- a/backend/scripts/db-migrate.js +++ b/backend/scripts/db-migrate.js @@ -13,8 +13,19 @@ async function migrateDatabase() { console.log('Migrating database...'); console.log('This will alter existing tables to match current models'); + // SQLite requires disabling foreign keys for ALTER TABLE operations + // (Sequelize uses backup-drop-recreate approach which fails with FK constraints) + const isSqlite = sequelize.getDialect() === 'sqlite'; + if (isSqlite) { + await sequelize.query('PRAGMA foreign_keys = OFF;'); + } + await sequelize.sync({ alter: true }); + if (isSqlite) { + await sequelize.query('PRAGMA foreign_keys = ON;'); + } + console.log('āœ… Database migrated successfully'); console.log('All tables have been updated to match current models'); process.exit(0); diff --git a/backend/scripts/vapid-generate.js b/backend/scripts/vapid-generate.js new file mode 100644 index 000000000..9ba186f42 --- /dev/null +++ b/backend/scripts/vapid-generate.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +/** + * VAPID Key Generator + * Generates VAPID keys for Web Push Notifications + * + * Usage: npm run vapid:generate + */ + +const webpush = require('web-push'); + +console.log('šŸ” Generating VAPID keys for Web Push Notifications...\n'); + +const vapidKeys = webpush.generateVAPIDKeys(); + +console.log('═══════════════════════════════════════════════════════'); +console.log('Public Key (safe to expose to frontend):'); +console.log('═══════════════════════════════════════════════════════'); +console.log(vapidKeys.publicKey); + +console.log('\n═══════════════════════════════════════════════════════'); +console.log('Private Key (āš ļø KEEP SECRET - never commit):'); +console.log('═══════════════════════════════════════════════════════'); +console.log(vapidKeys.privateKey); + +console.log('\n═══════════════════════════════════════════════════════'); +console.log('Add these to your environment:'); +console.log('═══════════════════════════════════════════════════════\n'); + +console.log('# Development (backend/.env)'); +console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`); +console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`); +console.log(`VAPID_SUBJECT=mailto:admin@example.com\n`); + +console.log('# Production (use secrets management)'); +console.log('Docker:'); +console.log(` -e VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`); +console.log(` -e VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`); +console.log(` -e VAPID_SUBJECT=mailto:your-email@example.com`); + +console.log('\nšŸ”’ Security Notes:'); +console.log('- Use different keys for development and production'); +console.log('- Never commit VAPID keys to version control'); +console.log( + '- Store production keys in secrets management (Docker secrets, K8s, etc.)' +); +console.log('- Change VAPID_SUBJECT to your contact email or website URL\n'); diff --git a/backend/services/webPushService.js b/backend/services/webPushService.js new file mode 100644 index 000000000..e4a970aab --- /dev/null +++ b/backend/services/webPushService.js @@ -0,0 +1,129 @@ +const webPush = require('web-push'); +const { PushSubscription } = require('../models'); +const { logError } = require('./logService'); + +// Initialize VAPID keys from environment variables +const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; +const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; +const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@localhost'; + +let isConfigured = false; + +if (vapidPublicKey && vapidPrivateKey) { + webPush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey); + isConfigured = true; +} + +/** + * Check if Web Push is configured + */ +function isWebPushConfigured() { + return isConfigured; +} + +/** + * Get the public VAPID key for client subscription + */ +function getVapidPublicKey() { + return vapidPublicKey || null; +} + +/** + * Subscribe a user to push notifications + */ +async function subscribe(userId, subscription) { + try { + // Upsert: update if endpoint exists, create if not + const [sub, created] = await PushSubscription.findOrCreate({ + where: { endpoint: subscription.endpoint }, + defaults: { + user_id: userId, + keys: subscription.keys, + }, + }); + + if (!created) { + await sub.update({ user_id: userId, keys: subscription.keys }); + } + + return { success: true, created }; + } catch (error) { + logError('Error subscribing to push:', error); + return { success: false, error: error.message }; + } +} + +/** + * Unsubscribe a user from push notifications + */ +async function unsubscribe(userId, endpoint) { + try { + const deleted = await PushSubscription.destroy({ + where: { user_id: userId, endpoint }, + }); + return { success: true, deleted: deleted > 0 }; + } catch (error) { + logError('Error unsubscribing from push:', error); + return { success: false, error: error.message }; + } +} + +/** + * Send push notification to all of a user's subscriptions + */ +async function sendPushNotification(userId, notification) { + if (!isConfigured) { + return { success: false, error: 'Web Push not configured' }; + } + + try { + const subscriptions = await PushSubscription.findAll({ + where: { user_id: userId }, + }); + + if (subscriptions.length === 0) { + return { success: true, sent: 0 }; + } + + const payload = JSON.stringify({ + title: notification.title, + body: notification.message, + icon: '/icon-logo.png', + badge: '/favicon-32.png', + data: notification.data || {}, + tag: notification.type || 'default', + }); + + const results = await Promise.allSettled( + subscriptions.map(async (sub) => { + try { + await webPush.sendNotification( + { endpoint: sub.endpoint, keys: sub.keys }, + payload + ); + return { success: true }; + } catch (error) { + // Remove invalid subscriptions (expired or unsubscribed) + if (error.statusCode === 404 || error.statusCode === 410) { + await sub.destroy(); + } + throw error; + } + }) + ); + + const sent = results.filter((r) => r.status === 'fulfilled').length; + return { success: true, sent, total: subscriptions.length }; + } catch (error) { + logError('Error sending push notification:', error); + return { success: false, error: error.message }; + } +} + +module.exports = { + isWebPushConfigured, + getVapidPublicKey, + subscribe, + unsubscribe, + sendPushNotification, +}; diff --git a/backend/tests/integration/push-notifications.test.js b/backend/tests/integration/push-notifications.test.js new file mode 100644 index 000000000..2f2234231 --- /dev/null +++ b/backend/tests/integration/push-notifications.test.js @@ -0,0 +1,425 @@ +const request = require('supertest'); +const app = require('../../app'); +const { PushSubscription, User } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); +const webpush = require('web-push'); + +describe('Push Notifications API', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ + email: `test_${Date.now()}@example.com`, + }); + + agent = request.agent(app); + await agent.post('/api/login').send({ + email: user.email, + password: 'password123', + }); + }); + + describe('GET /api/notifications/push/vapid-key', () => { + it('should return VAPID public key when configured', async () => { + // Skip if VAPID not configured in test environment + if (!process.env.VAPID_PUBLIC_KEY) { + console.log('Skipping: VAPID keys not configured in test env'); + return; + } + + const response = await agent.get( + '/api/notifications/push/vapid-key' + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('publicKey'); + expect(typeof response.body.publicKey).toBe('string'); + expect(response.body.publicKey.length).toBeGreaterThan(0); + }); + + it('should return 503 when VAPID not configured', async () => { + // Only run if VAPID is not configured + if (process.env.VAPID_PUBLIC_KEY) { + console.log('Skipping: VAPID keys are configured'); + return; + } + + const response = await agent.get( + '/api/notifications/push/vapid-key' + ); + + expect(response.status).toBe(503); + expect(response.body.error).toContain('not configured'); + }); + }); + + describe('POST /api/notifications/push/subscribe', () => { + it('should create new push subscription', async () => { + const subscription = { + endpoint: `https://fcm.googleapis.com/fcm/send/test_${Date.now()}`, + keys: { + p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=', + auth: 'tBHItJI5svbpez7KI4CCXg==', + }, + }; + + const response = await agent + .post('/api/notifications/push/subscribe') + .send({ subscription }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify in database + const saved = await PushSubscription.findOne({ + where: { endpoint: subscription.endpoint }, + }); + + expect(saved).toBeTruthy(); + expect(saved.user_id).toBe(user.id); + expect(saved.keys).toEqual(subscription.keys); + }); + + it('should update existing subscription for same endpoint', async () => { + const endpoint = `https://fcm.googleapis.com/fcm/send/test_${Date.now()}`; + const oldKeys = { + p256dh: 'OLD_KEY', + auth: 'OLD_AUTH', + }; + const newKeys = { + p256dh: 'NEW_KEY', + auth: 'NEW_AUTH', + }; + + // Create initial subscription + await agent.post('/api/notifications/push/subscribe').send({ + subscription: { + endpoint, + keys: oldKeys, + }, + }); + + // Update with new keys + const response = await agent + .post('/api/notifications/push/subscribe') + .send({ + subscription: { + endpoint, + keys: newKeys, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify updated in database + const subscriptions = await PushSubscription.findAll({ + where: { endpoint }, + }); + + expect(subscriptions.length).toBe(1); + expect(subscriptions[0].keys).toEqual(newKeys); + }); + + it('should require authentication', async () => { + const subscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }; + + const response = await request(app) + .post('/api/notifications/push/subscribe') + .send({ subscription }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Authentication required'); + }); + + it('should validate subscription format - missing endpoint', async () => { + const response = await agent + .post('/api/notifications/push/subscribe') + .send({ + subscription: { + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid subscription'); + }); + + it('should validate subscription format - missing keys', async () => { + const response = await agent + .post('/api/notifications/push/subscribe') + .send({ + subscription: { + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid subscription'); + }); + }); + + describe('DELETE /api/notifications/push/unsubscribe', () => { + it('should remove push subscription', async () => { + const endpoint = `https://fcm.googleapis.com/fcm/send/test_${Date.now()}`; + + // Create subscription first + await agent.post('/api/notifications/push/subscribe').send({ + subscription: { + endpoint, + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }, + }); + + // Verify it exists + let subscription = await PushSubscription.findOne({ + where: { endpoint }, + }); + expect(subscription).toBeTruthy(); + + // Delete it + const response = await agent + .delete('/api/notifications/push/unsubscribe') + .send({ endpoint }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + // Verify removed from database + subscription = await PushSubscription.findOne({ + where: { endpoint }, + }); + expect(subscription).toBeNull(); + }); + + it("should only remove user's own subscription", async () => { + // Create another user + const otherUser = await createTestUser({ + email: `other_${Date.now()}@example.com`, + }); + + const endpoint = `https://fcm.googleapis.com/fcm/send/test_${Date.now()}`; + + // Create subscription for other user + await PushSubscription.create({ + user_id: otherUser.id, + endpoint, + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }); + + // Try to delete with current user's session + const response = await agent + .delete('/api/notifications/push/unsubscribe') + .send({ endpoint }); + + expect(response.status).toBe(200); + expect(response.body.deleted).toBe(false); + + // Verify other user's subscription still exists + const subscription = await PushSubscription.findOne({ + where: { endpoint }, + }); + expect(subscription).toBeTruthy(); + expect(subscription.user_id).toBe(otherUser.id); + }); + + it('should require authentication', async () => { + const response = await request(app) + .delete('/api/notifications/push/unsubscribe') + .send({ endpoint: 'https://test.endpoint' }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Authentication required'); + }); + + it('should require endpoint parameter', async () => { + const response = await agent + .delete('/api/notifications/push/unsubscribe') + .send({}); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('required'); + }); + }); + + describe('Integration with test-notifications', () => { + it('should include push in sources when enabled in preferences and subscribed', async () => { + // Enable push notification preference for due tasks + await agent.patch('/api/profile').send({ + notification_preferences: { + dueTasks: { + inApp: true, + email: false, + push: true, + 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, + }, + }, + }); + + // Subscribe to push + await agent.post('/api/notifications/push/subscribe').send({ + subscription: { + endpoint: `https://test.endpoint/${Date.now()}`, + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }, + }); + + // Trigger test notification + const response = await agent + .post('/api/notifications/test/trigger') + .send({ type: 'task_due_soon' }); + + expect(response.status).toBe(200); + expect(response.body.notification.sources).toContain('push'); + }); + + it('should not include push when disabled in preferences', async () => { + // Disable push notification preference + await agent.patch('/api/profile').send({ + notification_preferences: { + 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, + }, + }, + }); + + // Subscribe to push (but disabled in preferences) + await agent.post('/api/notifications/push/subscribe').send({ + subscription: { + endpoint: `https://test.endpoint/${Date.now()}`, + keys: { + p256dh: 'test_key', + auth: 'test_auth', + }, + }, + }); + + // Trigger test notification + const response = await agent + .post('/api/notifications/test/trigger') + .send({ type: 'task_due_soon' }); + + expect(response.status).toBe(200); + expect(response.body.notification.sources).not.toContain('push'); + }); + + it('should not include push when not subscribed', async () => { + // Enable push preference but don't subscribe + await agent.patch('/api/profile').send({ + notification_preferences: { + dueTasks: { + inApp: true, + email: false, + push: true, + 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, + }, + }, + }); + + // Don't subscribe to push + + // Trigger test notification + const response = await agent + .post('/api/notifications/test/trigger') + .send({ type: 'task_due_soon' }); + + expect(response.status).toBe(200); + // Push preference is on but no subscription exists, so still sent + // The webPushService will just not send anything (0 subscriptions) + expect(response.body.notification.sources).toContain('push'); + }); + }); +}); diff --git a/backend/tests/unit/services/webPushService.test.js b/backend/tests/unit/services/webPushService.test.js new file mode 100644 index 000000000..6215b1e03 --- /dev/null +++ b/backend/tests/unit/services/webPushService.test.js @@ -0,0 +1,329 @@ +// Set VAPID keys before requiring the service so it initializes as configured +process.env.VAPID_PUBLIC_KEY = 'test-public-key'; +process.env.VAPID_PRIVATE_KEY = 'test-private-key'; +process.env.VAPID_SUBJECT = 'mailto:test@example.com'; + +const webpush = require('web-push'); + +// Mock web-push module +jest.mock('web-push'); + +// Mock PushSubscription model +jest.mock('../../../models', () => ({ + PushSubscription: { + findOrCreate: jest.fn(), + destroy: jest.fn(), + findAll: jest.fn(), + }, +})); + +// Mock logService +jest.mock('../../../services/logService', () => ({ + logError: jest.fn(), +})); + +// Import after mocks are set up +const { PushSubscription } = require('../../../models'); +const webPushService = require('../../../services/webPushService'); + +describe('webPushService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isWebPushConfigured', () => { + it('should return boolean based on VAPID configuration', () => { + const result = webPushService.isWebPushConfigured(); + expect(typeof result).toBe('boolean'); + }); + }); + + describe('getVapidPublicKey', () => { + it('should return the VAPID public key or null', () => { + const result = webPushService.getVapidPublicKey(); + expect(result === null || typeof result === 'string').toBe(true); + }); + }); + + describe('subscribe', () => { + it('should create new subscription', async () => { + const userId = 1; + const subscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/test123', + keys: { + p256dh: 'test_p256dh_key', + auth: 'test_auth_key', + }, + }; + + const mockSubscription = { + id: 1, + user_id: userId, + endpoint: subscription.endpoint, + keys: subscription.keys, + }; + + PushSubscription.findOrCreate.mockResolvedValue([ + mockSubscription, + true, + ]); + + const result = await webPushService.subscribe(userId, subscription); + + expect(result.success).toBe(true); + expect(result.created).toBe(true); + expect(PushSubscription.findOrCreate).toHaveBeenCalledWith({ + where: { endpoint: subscription.endpoint }, + defaults: { + user_id: userId, + keys: subscription.keys, + }, + }); + }); + + it('should update existing subscription', async () => { + const userId = 1; + const subscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/test123', + keys: { + p256dh: 'new_key', + auth: 'new_auth', + }, + }; + + const mockSubscription = { + id: 1, + user_id: userId, + endpoint: subscription.endpoint, + keys: { p256dh: 'old_key', auth: 'old_auth' }, + update: jest.fn().mockResolvedValue(true), + }; + + PushSubscription.findOrCreate.mockResolvedValue([ + mockSubscription, + false, + ]); + + const result = await webPushService.subscribe(userId, subscription); + + expect(result.success).toBe(true); + expect(result.created).toBe(false); + expect(mockSubscription.update).toHaveBeenCalledWith({ + user_id: userId, + keys: subscription.keys, + }); + }); + + it('should handle errors gracefully', async () => { + const userId = 1; + const subscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/test123', + keys: { p256dh: 'key', auth: 'auth' }, + }; + + PushSubscription.findOrCreate.mockRejectedValue( + new Error('Database error') + ); + + const result = await webPushService.subscribe(userId, subscription); + + expect(result.success).toBe(false); + expect(result.error).toBe('Database error'); + }); + }); + + describe('unsubscribe', () => { + it('should remove subscription', async () => { + const userId = 1; + const endpoint = 'https://fcm.googleapis.com/fcm/send/test123'; + + PushSubscription.destroy.mockResolvedValue(1); + + const result = await webPushService.unsubscribe(userId, endpoint); + + expect(result.success).toBe(true); + expect(result.deleted).toBe(true); + expect(PushSubscription.destroy).toHaveBeenCalledWith({ + where: { user_id: userId, endpoint }, + }); + }); + + it('should return deleted false when subscription not found', async () => { + const userId = 1; + const endpoint = 'https://fcm.googleapis.com/fcm/send/test123'; + + PushSubscription.destroy.mockResolvedValue(0); + + const result = await webPushService.unsubscribe(userId, endpoint); + + expect(result.success).toBe(true); + expect(result.deleted).toBe(false); + }); + }); + + describe('sendPushNotification', () => { + it('should send to all user subscriptions', async () => { + const userId = 1; + const notification = { + title: 'Test Notification', + message: 'This is a test', + data: { taskId: 123 }, + type: 'task_due_soon', + }; + + const mockSubscriptions = [ + { + endpoint: 'https://fcm.googleapis.com/fcm/send/sub1', + keys: { p256dh: 'key1', auth: 'auth1' }, + }, + { + endpoint: 'https://fcm.googleapis.com/fcm/send/sub2', + keys: { p256dh: 'key2', auth: 'auth2' }, + }, + ]; + + PushSubscription.findAll.mockResolvedValue(mockSubscriptions); + webpush.sendNotification.mockResolvedValue({ statusCode: 201 }); + + const result = await webPushService.sendPushNotification( + userId, + notification + ); + + expect(result.success).toBe(true); + expect(result.sent).toBe(2); + expect(result.total).toBe(2); + expect(webpush.sendNotification).toHaveBeenCalledTimes(2); + }); + + it('should remove invalid subscriptions (410 Gone)', async () => { + const userId = 1; + const notification = { title: 'Test', message: 'Test' }; + + const expiredSubscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/expired', + keys: { p256dh: 'key', auth: 'auth' }, + destroy: jest.fn().mockResolvedValue(true), + }; + + PushSubscription.findAll.mockResolvedValue([expiredSubscription]); + + const error = new Error('Subscription has expired'); + error.statusCode = 410; + webpush.sendNotification.mockRejectedValue(error); + + const result = await webPushService.sendPushNotification( + userId, + notification + ); + + // Promise.allSettled catches all rejections, so success is still true + // but sent count is 0 because the notification failed + expect(result.success).toBe(true); + expect(result.sent).toBe(0); + expect(result.total).toBe(1); + + // Wait a bit for async destroy to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(expiredSubscription.destroy).toHaveBeenCalled(); + }); + + it('should remove invalid subscriptions (404 Not Found)', async () => { + const userId = 1; + const notification = { title: 'Test', message: 'Test' }; + + const notFoundSubscription = { + endpoint: 'https://fcm.googleapis.com/fcm/send/notfound', + keys: { p256dh: 'key', auth: 'auth' }, + destroy: jest.fn().mockResolvedValue(true), + }; + + PushSubscription.findAll.mockResolvedValue([notFoundSubscription]); + + const error = new Error('Subscription not found'); + error.statusCode = 404; + webpush.sendNotification.mockRejectedValue(error); + + const result = await webPushService.sendPushNotification( + userId, + notification + ); + + expect(result.success).toBe(true); + expect(result.sent).toBe(0); + expect(result.total).toBe(1); + expect(notFoundSubscription.destroy).toHaveBeenCalled(); + }); + + it('should handle partial failures', async () => { + const userId = 1; + const notification = { title: 'Test', message: 'Test' }; + + const mockSubscriptions = [ + { + endpoint: 'https://fcm.googleapis.com/fcm/send/sub1', + keys: { p256dh: 'key1', auth: 'auth1' }, + destroy: jest.fn(), + }, + { + endpoint: 'https://fcm.googleapis.com/fcm/send/sub2', + keys: { p256dh: 'key2', auth: 'auth2' }, + destroy: jest.fn(), + }, + ]; + + PushSubscription.findAll.mockResolvedValue(mockSubscriptions); + + // First succeeds, second fails with non-410/404 error + webpush.sendNotification + .mockResolvedValueOnce({ statusCode: 201 }) + .mockRejectedValueOnce(new Error('Network error')); + + const result = await webPushService.sendPushNotification( + userId, + notification + ); + + expect(result.success).toBe(true); + expect(result.sent).toBe(1); + expect(result.total).toBe(2); + expect(mockSubscriptions[1].destroy).not.toHaveBeenCalled(); // Non-410/404 error, don't delete + }); + + it('should return error when not configured', () => { + // Create a fresh instance of the service with no VAPID keys + // Temporarily clear env vars + const originalVapidPublic = process.env.VAPID_PUBLIC_KEY; + const originalVapidPrivate = process.env.VAPID_PRIVATE_KEY; + + delete process.env.VAPID_PUBLIC_KEY; + delete process.env.VAPID_PRIVATE_KEY; + + // Need to clear the module cache and re-require to pick up new env vars + jest.resetModules(); + const unconfiguredService = require('../../../services/webPushService'); + + const userId = 1; + const notification = { title: 'Test', message: 'Test' }; + + // Test that service is unconfigured + expect(unconfiguredService.isWebPushConfigured()).toBe(false); + + // Test the error response + const result = unconfiguredService.sendPushNotification( + userId, + notification + ); + + // Restore env vars + process.env.VAPID_PUBLIC_KEY = originalVapidPublic; + process.env.VAPID_PRIVATE_KEY = originalVapidPrivate; + + // Return the promise so Jest waits for it + return result.then((res) => { + expect(res.success).toBe(false); + expect(res.error).toContain('not configured'); + }); + }); + }); +}); diff --git a/backend/utils/notificationPreferences.js b/backend/utils/notificationPreferences.js index c26a0fa11..06a887adb 100644 --- a/backend/utils/notificationPreferences.js +++ b/backend/utils/notificationPreferences.js @@ -53,13 +53,13 @@ function shouldSendInAppNotification(user, notificationType) { } /** - * Check if user has enabled Telegram notifications for a specific type + * Check if user has enabled a specific notification channel for a 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 + * @param {string} notificationType - Backend notification type + * @param {string} channel - Channel to check ('telegram', 'push', 'email') + * @returns {boolean} - True if channel is enabled for this type */ -function shouldSendTelegramNotification(user, notificationType) { - // If no user or no preferences set, default to disabled for Telegram +function shouldSendToChannel(user, notificationType, channel) { if (!user || !user.notification_preferences) { return false; } @@ -75,8 +75,22 @@ function shouldSendTelegramNotification(user, notificationType) { return false; } - // Check if telegram channel is enabled (default to false if not set) - return prefs[prefKey].telegram === true; + // Check if channel is enabled (default to false if not set) + return prefs[prefKey][channel] === true; +} + +/** + * Check if user has enabled Telegram notifications for a specific type + */ +function shouldSendTelegramNotification(user, notificationType) { + return shouldSendToChannel(user, notificationType, 'telegram'); +} + +/** + * Check if user has enabled Push notifications for a specific type + */ +function shouldSendPushNotification(user, notificationType) { + return shouldSendToChannel(user, notificationType, 'push'); } /** @@ -90,6 +104,7 @@ function getDefaultNotificationPreferences() { module.exports = { shouldSendInAppNotification, shouldSendTelegramNotification, + shouldSendPushNotification, getDefaultNotificationPreferences, NOTIFICATION_TYPE_MAPPING, }; diff --git a/docker-compose.yml b/docker-compose.yml index 9ff6600d3..297b0565d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: - TUDUDI_SESSION_SECRET=changeme-please-use-openssl - TUDUDI_ALLOWED_ORIGINS=http://localhost:3002 - TUDUDI_UPLOAD_PATH=/app/backend/uploads + # Push Notifications (optional) - generate with: npm run vapid:generate + - VAPID_PUBLIC_KEY= + - VAPID_PRIVATE_KEY= + - VAPID_SUBJECT=mailto:admin@example.com # Runtime UID/GID configuration - set these to match your host user/group - PUID=1001 - PGID=1001 diff --git a/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index b1525add0..985c68a22 100644 --- a/frontend/components/Profile/ProfileSettings.tsx +++ b/frontend/components/Profile/ProfileSettings.tsx @@ -4,6 +4,7 @@ import React, { ChangeEvent, FormEvent, useCallback, + useRef, } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -136,6 +137,7 @@ const ProfileSettings: React.FC = ({ useState(null); const [apiKeys, setApiKeys] = useState([]); const [apiKeysLoading, setApiKeysLoading] = useState(false); + const notificationsResetRef = useRef<(() => void) | null>(null); const [apiKeysLoaded, setApiKeysLoaded] = useState(false); const [newApiKeyName, setNewApiKeyName] = useState(''); const [newApiKeyExpiration, setNewApiKeyExpiration] = useState(''); @@ -1066,6 +1068,11 @@ const ProfileSettings: React.FC = ({ : t('profile.successMessage', 'Profile updated successfully!'); showSuccessToast(successMessage); + // Reset unsaved changes in NotificationsTab after successful save + if (notificationsResetRef.current) { + notificationsResetRef.current(); + } + if (avatarFile || removeAvatar) { setTimeout(() => { window.location.reload(); @@ -1266,6 +1273,9 @@ const ProfileSettings: React.FC = ({ preferences, })) } + onSave={(resetFn) => { + notificationsResetRef.current = resetFn; + }} /> void; + onSave?: (resetFn: () => void) => void; // Child registers reset function for parent to call after save +} + +// Convert VAPID key from base64 to Uint8Array +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array( + rawData.length + ) as Uint8Array; + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; } const DEFAULT_PREFERENCES: NotificationPreferences = { @@ -44,6 +61,7 @@ interface NotificationTypeRowProps { value: boolean ) => void; telegramConfigured: boolean; + pushConfigured: boolean; } const NotificationTypeRow: React.FC = ({ @@ -53,6 +71,7 @@ const NotificationTypeRow: React.FC = ({ preferences, onToggle, telegramConfigured, + pushConfigured, }) => { const renderToggle = ( channel: 'inApp' | 'email' | 'push' | 'telegram', @@ -103,7 +122,7 @@ const NotificationTypeRow: React.FC = ({ {renderToggle('email', preferences.email, false)} - {renderToggle('push', preferences.push, false)} + {renderToggle('push', preferences.push, pushConfigured)} {renderToggle( @@ -120,6 +139,7 @@ const NotificationsTab: React.FC = ({ isActive, notificationPreferences, onChange, + onSave, }) => { const { t } = useTranslation(); const [profile, setProfile] = React.useState(null); @@ -127,6 +147,169 @@ const NotificationsTab: React.FC = ({ React.useState('task_due_soon'); const [testLoading, setTestLoading] = React.useState(false); const [testMessage, setTestMessage] = React.useState(''); + const [pushSupported, setPushSupported] = React.useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = + React.useState(false); + const testMessageTimeoutRef = React.useRef(null); + + // Register reset function with parent on mount + React.useEffect(() => { + if (onSave) { + onSave(() => setHasUnsavedChanges(false)); + } + }, [onSave]); + const [pushSubscribed, setPushSubscribed] = React.useState(false); + const [pushLoading, setPushLoading] = React.useState(false); + + // Check push notification support and subscription status + React.useEffect(() => { + const checkPushStatus = async () => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + setPushSupported(false); + return; + } + setPushSupported(true); + + try { + // Try to get registration with timeout fallback + const registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise( + (resolve) => + setTimeout(async () => { + const reg = + await navigator.serviceWorker.getRegistration( + '/' + ); + resolve(reg); + }, 2000) + ), + ]); + if (registration) { + const subscription = + await registration.pushManager.getSubscription(); + setPushSubscribed(!!subscription); + } + } catch (err) { + console.error('Error checking push subscription:', err); + } + }; + if (isActive) checkPushStatus(); + }, [isActive]); + + // Subscribe to push notifications + const subscribeToPush = async () => { + setPushLoading(true); + try { + // Get VAPID public key from server + const keyResponse = await fetch( + '/api/notifications/push/vapid-key' + ); + if (!keyResponse.ok) { + throw new Error('Push notifications not configured on server'); + } + const { publicKey } = await keyResponse.json(); + + // Request permission + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error('Notification permission denied'); + } + + // Get SW registration - try ready first, fall back to getRegistration + let registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((resolve) => + setTimeout(async () => { + // Fallback: try to get registration directly + const reg = + await navigator.serviceWorker.getRegistration('/'); + resolve(reg); + }, 3000) + ), + ]); + + if (!registration) { + // Try to register SW if not found + registration = await navigator.serviceWorker.register( + '/pwa/sw.js', + { scope: '/' } + ); + await registration.update(); + // Wait for it to activate + await new Promise((resolve) => { + if (registration!.active) { + resolve(); + } else { + registration!.addEventListener('updatefound', () => { + registration!.installing?.addEventListener( + 'statechange', + (e) => { + if ( + (e.target as ServiceWorker).state === + 'activated' + ) { + resolve(); + } + } + ); + }); + setTimeout(resolve, 5000); // Max wait 5s + } + }); + } + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + // Send subscription to server + const subResponse = await fetch( + '/api/notifications/push/subscribe', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscription: subscription.toJSON(), + }), + } + ); + if (!subResponse.ok) { + const err = await subResponse.json(); + throw new Error(err.error || 'Failed to save subscription'); + } + + setPushSubscribed(true); + } catch (err) { + console.error('Failed to subscribe to push:', err); + alert(err.message || 'Failed to enable push notifications'); + } finally { + setPushLoading(false); + } + }; + + // Unsubscribe from push notifications + const unsubscribeFromPush = async () => { + setPushLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const subscription = + await registration.pushManager.getSubscription(); + if (subscription) { + await fetch('/api/notifications/push/unsubscribe', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + await subscription.unsubscribe(); + } + setPushSubscribed(false); + } catch (err) { + console.error('Failed to unsubscribe from push:', err); + } finally { + setPushLoading(false); + } + }; // Fetch profile data to check telegram configuration React.useEffect(() => { @@ -164,14 +347,31 @@ const NotificationsTab: React.FC = ({ }, }; onChange(updatedPreferences); + setHasUnsavedChanges(true); }; const handleTestNotification = async () => { + // Clear any existing timeout + if (testMessageTimeoutRef.current) { + clearTimeout(testMessageTimeoutRef.current); + } + + if (hasUnsavedChanges) { + setTestMessage( + 'āš ļø You have unsaved changes. Save your preferences first to test with the new settings.' + ); + testMessageTimeoutRef.current = setTimeout( + () => setTestMessage(''), + 5000 + ); + return; + } + setTestLoading(true); setTestMessage(''); try { - const response = await fetch('/api/test-notifications/trigger', { + const response = await fetch('/api/notifications/test/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -196,7 +396,10 @@ const NotificationsTab: React.FC = ({ } finally { setTestLoading(false); // Clear message after 5 seconds - setTimeout(() => setTestMessage(''), 5000); + testMessageTimeoutRef.current = setTimeout( + () => setTestMessage(''), + 5000 + ); } }; @@ -213,6 +416,52 @@ const NotificationsTab: React.FC = ({ )}

+ {/* Push Notification Setup */} + {pushSupported && ( +
+
+
+

+ {t( + 'notifications.push.title', + 'Browser Push Notifications' + )} +

+

+ {pushSubscribed + ? t( + 'notifications.push.enabled', + 'Push notifications are enabled for this browser.' + ) + : t( + 'notifications.push.disabled', + 'Enable to receive notifications even when the app is closed.' + )} +

+
+ +
+
+ )} + {/* Telegram Not Configured Warning */} {!telegramConfigured && (
@@ -255,13 +504,7 @@ const NotificationsTab: React.FC = ({
-
- {t('notifications.channels.push', 'Push')} - - ({t('common.comingSoon', 'Coming Soon')} - ) - -
+ {t('notifications.channels.push', 'Push')} {t( @@ -287,6 +530,7 @@ const NotificationsTab: React.FC = ({ handleToggle('dueTasks', channel, value) } telegramConfigured={telegramConfigured} + pushConfigured={pushSubscribed} /> = ({ handleToggle('overdueTasks', channel, value) } telegramConfigured={telegramConfigured} + pushConfigured={pushSubscribed} /> = ({ handleToggle('deferUntil', channel, value) } telegramConfigured={telegramConfigured} + pushConfigured={pushSubscribed} /> = ({ handleToggle('dueProjects', channel, value) } telegramConfigured={telegramConfigured} + pushConfigured={pushSubscribed} /> = ({ handleToggle('overdueProjects', channel, value) } telegramConfigured={telegramConfigured} + pushConfigured={pushSubscribed} /> @@ -400,6 +648,7 @@ const NotificationsTab: React.FC = ({ + + +
+
+ Connection lost +
+ + + + + + diff --git a/public/pwa/sw.js b/public/pwa/sw.js new file mode 100644 index 000000000..279d6dcc4 --- /dev/null +++ b/public/pwa/sw.js @@ -0,0 +1,272 @@ +// Service Worker for tududi PWA +// This is the source template - placeholders are replaced during build +// CACHE_NAME and STATIC_ASSETS are injected by scripts/sw-template.js + +/* global clients */ +/* eslint-disable no-undef */ +const CACHE_NAME = "__CACHE_NAME__"; +const staticAssets = __STATIC_ASSETS__; +const OFFLINE_PAGE = "/offline.html"; +/* eslint-enable no-undef */ + +// Installation event - cache static assets +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + // Cache offline page first (critical) + const offlinePromise = cache + .add(OFFLINE_PAGE) + .catch((error) => { + console.warn(`āœ— Failed to cache ${OFFLINE_PAGE}:`, error.message); + }); + + // Cache other assets individually to identify failures + const assetsPromise = Promise.all( + staticAssets.map((url) => { + return cache + .add(url) + .catch((error) => { + console.warn(`āœ— Failed to cache ${url}:`, error.message); + // Continue even if this asset fails + return Promise.resolve(); + }); + }) + ); + + return Promise.all([offlinePromise, assetsPromise]); + }) + ); + self.skipWaiting(); +}); + +// Activation event - clean up old caches +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + }) + ); + }) + .then(() => self.clients.claim()) + ); +}); + +// Fetch event - different strategies for different resource types +self.addEventListener("fetch", (event) => { + // Skip non-GET requests + if (event.request.method !== "GET") { + return; + } + + const url = new URL(event.request.url); + const pathname = url.pathname; + + // Static files that should be cached aggressively + // - /api/uploads/* : User uploaded files (images, attachments) + // - /locales/* : Translation files + if (pathname.startsWith("/api/uploads/") || pathname.startsWith("/locales/")) { + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(event.request) + .then((response) => { + if (response && response.status === 200) { + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + } + return response; + }) + .catch((error) => { + console.error(`āŒ Failed to fetch static file: ${pathname}`, error); + // Return 404 for missing static files when offline + return new Response("File not found", { + status: 404, + statusText: "Not Found", + headers: { + "Content-Type": "text/plain", + }, + }); + }); + }) + ); + return; + } + + // Dynamic API requests - always try network first + // Excludes uploads and locales which are handled above + if (pathname.startsWith("/api/")) { + // List of endpoints that should NOT be cached (auth, sessions, etc.) + const noCacheEndpoints = [ + "/api/login", + "/api/logout", + "/api/register", + "/api/current_user", + ]; + const shouldCache = !noCacheEndpoints.some((endpoint) => + pathname.startsWith(endpoint) + ); + + event.respondWith( + fetch(event.request) + .then((response) => { + // Cache successful GET responses for offline use (except sensitive endpoints) + if ( + shouldCache && + response && + response.status === 200 && + event.request.method === "GET" + ) { + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + } + return response; + }) + .catch(() => { + // Network failed, try cache + return caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + // Both network and cache failed - return error + console.error(`āŒ API call failed (offline, no cache): ${pathname}`); + return new Response( + JSON.stringify({ + error: "Offline", + message: "You are offline and this data is not cached.", + path: pathname, + }), + { + status: 503, + statusText: "Service Unavailable", + headers: { + "Content-Type": "application/json", + }, + } + ); + }); + }) + ); + return; + } + + // For all other requests (static assets, SPA routes) + // Try cache first, then network + event.respondWith( + caches.match(event.request).then((response) => { + if (response) { + return response; + } + + return fetch(event.request) + .then((response) => { + // Cache successful responses + if ( + !response || + response.status !== 200 || + response.type === "error" + ) { + return response; + } + + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }) + .catch((error) => { + console.error("Fetch failed:", error); + + // For navigation requests (HTML pages), show offline page + if (event.request.mode === "navigate") { + return caches.match(OFFLINE_PAGE).then((offlineResponse) => { + if (offlineResponse) { + return offlineResponse; + } + // Fallback if offline page isn't cached + return new Response("Offline - Resource not available", { + status: 503, + statusText: "Service Unavailable", + headers: { "Content-Type": "text/plain" }, + }); + }); + } + + // For non-navigation requests, return error response + return new Response("Offline - Resource not available", { + status: 503, + statusText: "Service Unavailable", + headers: { "Content-Type": "text/plain" }, + }); + }); + }) + ); +}); + +// Push notification event - show browser notification +self.addEventListener("push", (event) => { + if (!event.data) { + event.waitUntil( + self.registration.showNotification("tududi", { body: "New notification" }) + ); + return; + } + + try { + const data = event.data.json(); + const title = data.title || "tududi"; + const options = { + body: data.body || data.message || "", + icon: data.icon || "/icon-logo.png", + badge: data.badge || "/favicon-32.png", + tag: data.tag || `notification-${Date.now()}`, + data: data.data || {}, + }; + + event.waitUntil(self.registration.showNotification(title, options)); + } catch (error) { + console.error("Error showing push notification:", error); + } +}); + +// Notification click event - open app and navigate to relevant page +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const data = event.notification.data || {}; + let targetUrl = "/"; + + // Navigate to relevant page based on notification data + if (data.taskUid) { + targetUrl = `/tasks/${data.taskUid}`; + } else if (data.projectUid) { + targetUrl = `/projects/${data.projectUid}`; + } + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + // Focus existing window if found + for (const client of clientList) { + if (client.url.includes(self.location.origin) && "focus" in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + // Open new window if no existing window + return clients.openWindow(targetUrl); + }) + ); +}); \ No newline at end of file diff --git a/scripts/generate-sw.js b/scripts/generate-sw.js new file mode 100644 index 000000000..dfa2f026e --- /dev/null +++ b/scripts/generate-sw.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +/** + * Generate Service Worker with actual webpack bundle names + * This script reads the built dist/index.html and extracts the actual + * bundle filenames, then generates a service worker that caches them. + */ + +const fs = require('fs'); +const path = require('path'); +const swTemplate = require('./sw-template'); + +const regex = /]*src=["']([^"']*\.js)["']/g; + +const distDir = path.join(__dirname, '../dist'); +const indexPath = path.join(distDir, 'index.html'); +const swPath = path.join(distDir, 'pwa/sw.js'); + +if (!fs.existsSync(indexPath)) { + console.error('āŒ dist/index.html not found. Run `npm run frontend:build` first.'); + process.exit(1); +} + +// Read index.html and extract script filenames +const htmlContent = fs.readFileSync(indexPath, 'utf-8'); +const bundleFiles = []; +let match; + +while ((match = regex.exec(htmlContent)) !== null) { + bundleFiles.push('/' + match[1]); +} + +if (bundleFiles.length === 0) { + console.warn('āš ļø No bundle files found in index.html'); +} + +console.log(`šŸ“¦ Found ${bundleFiles.length} bundle(s):`, bundleFiles); + +// Static assets to cache +const staticAssets = [ + "/index.html", + "/offline.html", + "/manifest.json", + "/favicon.png", + "/favicon-32.png", + "/favicon-16.png", + "/icon-logo.png", + "/wide-logo-dark.png", + "/wide-logo-light.png", + ...bundleFiles +]; + +// Generate the service worker content using template +const swContent = swTemplate('pwa-assets-v1', staticAssets); + +// Create pwa directory if it doesn't exist +const swDir = path.dirname(swPath); +if (!fs.existsSync(swDir)) { + fs.mkdirSync(swDir, { recursive: true }); +} + +// Write the service worker +fs.writeFileSync(swPath, swContent); +console.log(`āœ… Generated ${swPath}`); + diff --git a/scripts/sw-template.js b/scripts/sw-template.js new file mode 100644 index 000000000..d52222aeb --- /dev/null +++ b/scripts/sw-template.js @@ -0,0 +1,31 @@ +/** + * Service Worker Template Generator + * Reads public/pwa/sw.js and replaces placeholders with actual values + * + * Placeholders in source file: + * - __CACHE_NAME__: The cache version identifier + * - __STATIC_ASSETS__: JSON array of static asset URLs to cache + */ + +const fs = require('fs'); +const path = require('path'); + +const swSourcePath = path.join(__dirname, '../public/pwa/sw.js'); + +module.exports = (cacheName, staticAssets) => { + // Read the source service worker file + let swContent = fs.readFileSync(swSourcePath, 'utf-8'); + + // Replace placeholders with actual values + swContent = swContent.replace( + '"__CACHE_NAME__"', + JSON.stringify(cacheName) + ); + swContent = swContent.replace( + '__STATIC_ASSETS__', + JSON.stringify(staticAssets, null, 2) + ); + + return swContent; +}; + diff --git a/webpack.config.js b/webpack.config.js index 29de4e9d8..2137d146c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,6 +3,7 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin' const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const webpack = require('webpack'); +const swTemplate = require('./scripts/sw-template'); const isDevelopment = process.env.NODE_ENV !== 'production'; const frontendPort = parseInt(process.env.FRONTEND_PORT || '8080', 10); @@ -69,6 +70,23 @@ module.exports = { next(); }); + // In development, serve a SW that caches the dev bundle + devServer.app.get('/pwa/sw.js', (req, res) => { + const staticAssets = [ + "/index.html", + "/offline.html", + "/manifest.json", + "/favicon.png", + "/icon-logo.png", + "/wide-logo-dark.png", + "/wide-logo-light.png" + ]; + const swContent = swTemplate('pwa-assets-dev', staticAssets); + // Allow SW to control root scope even though it's served from /pwa/ + res.set('Service-Worker-Allowed', '/'); + res.type('application/javascript').send(swContent); + }); + return middlewares; }, },