From 0d4c26c7f730f16355031b4e6c411977ff6f2abc Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:10:33 +0000 Subject: [PATCH 01/12] add push subscription model --- .../20251227000001-add-push-subscriptions.js | 57 +++++++++++++++++++ backend/models/push_subscription.js | 43 ++++++++++++++ backend/scripts/db-migrate.js | 11 ++++ 3 files changed, 111 insertions(+) create mode 100644 backend/migrations/20251227000001-add-push-subscriptions.js create mode 100644 backend/models/push_subscription.js diff --git a/backend/migrations/20251227000001-add-push-subscriptions.js b/backend/migrations/20251227000001-add-push-subscriptions.js new file mode 100644 index 000000000..0d3f6df24 --- /dev/null +++ b/backend/migrations/20251227000001-add-push-subscriptions.js @@ -0,0 +1,57 @@ +'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/push_subscription.js b/backend/models/push_subscription.js new file mode 100644 index 000000000..922b2a0b8 --- /dev/null +++ b/backend/models/push_subscription.js @@ -0,0 +1,43 @@ +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/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); From 138d949241bd4740f7fb06615338a75e005bc762 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:11:19 +0000 Subject: [PATCH 02/12] Document VAPID setup --- Dockerfile | 5 ++++- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ backend/.env.example | 8 ++++++++ docker-compose.yml | 4 ++++ package.json | 1 + 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3de2b1c74..5a9fcb8df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,7 +126,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 b448618b7..67824267e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,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/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/package.json b/package.json index c2fc70dfd..bfe84b4b2 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "db:seed": "cd backend && node scripts/seed-dev-data.js", "db:reset-and-seed": "cd backend && NODE_ENV=development node scripts/reset-and-seed.js", "user:create": "cd backend && node scripts/user-create.js", + "vapid:generate": "cd backend && node scripts/vapid-generate.js", "migration:create": "cd backend && node scripts/migration-create.js", "migration:run": "cd backend && npx sequelize-cli db:migrate", "migration:undo": "cd backend && npx sequelize-cli db:migrate:undo", From 99559a7b05046b912fe411f7cd03a8e90a060eeb Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:15:07 +0000 Subject: [PATCH 03/12] Add PWA service worker with notification, cache, offline page. --- package.json | 2 +- public/index.html | 5 + public/offline.html | 158 +++++++++++++++++++++++ public/pwa/sw.js | 280 +++++++++++++++++++++++++++++++++++++++++ scripts/generate-sw.js | 65 ++++++++++ scripts/sw-template.js | 31 +++++ 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 public/offline.html create mode 100644 public/pwa/sw.js create mode 100644 scripts/generate-sw.js create mode 100644 scripts/sw-template.js diff --git a/package.json b/package.json index bfe84b4b2..eef1cd0e4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:coverage": "npm run frontend:test:coverage && npm run backend:test:coverage", "frontend:dev": "webpack serve --config webpack.config.js --hot", "frontend:start": "tsc --noEmit && webpack serve --config webpack.config.js", - "frontend:build": "npm run clean && tsc --noEmit && webpack --config webpack.config.js", + "frontend:build": "npm run clean && tsc --noEmit && webpack --config webpack.config.js && node scripts/generate-sw.js", "frontend:test": "jest", "frontend:test:watch": "jest --watch", "frontend:test:coverage": "jest --coverage", diff --git a/public/index.html b/public/index.html index 4b2fd5839..b52f49be6 100644 --- a/public/index.html +++ b/public/index.html @@ -33,6 +33,11 @@ document.head.insertBefore(baseTag, document.head.firstChild); } baseTag.setAttribute('href', baseHref); + + // Register service worker for PWA support + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register("/pwa/sw.js", { scope: '/' }); + } })(); tududi diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 000000000..fc7147b00 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,158 @@ + + + + + + Offline - tududi + + + + + + + + + +
+ +
+ tududi logo + tududi +
+ + +
+ + + +
+ + +

+ You're offline +

+ + +

+ It looks like you've lost your internet connection. Don't worry, your data is safe! +

+ + +
+
+ + + +
+

What you can do:

+
    +
  • • Check your internet connection
  • +
  • • Try turning your WiFi off and on
  • +
  • • Check if airplane mode is enabled
  • +
  • • Wait a moment and try again
  • +
+
+
+
+ + + + + +
+
+ Connection lost +
+
+ + + + + diff --git a/public/pwa/sw.js b/public/pwa/sw.js new file mode 100644 index 000000000..e0922af59 --- /dev/null +++ b/public/pwa/sw.js @@ -0,0 +1,280 @@ +// 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) + .then(() => { + console.log(`āœ“ Cached: ${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) + .then(() => { + console.log(`āœ“ Cached: ${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); + console.log(`šŸ’¾ Cached API response: ${pathname}`); + }); + } + return response; + }) + .catch(() => { + // Network failed, try cache + return caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + console.log(`šŸ“¦ Serving cached API response: ${pathname}`); + 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; +}; + From 3d0bfc4565b73aca9802115ab4a10621c11ef939 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:16:17 +0000 Subject: [PATCH 04/12] webpack upgrade for dynamic service working cache. --- webpack.config.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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; }, }, From 318eda4b792ba6425e36066721a47d0152a02ec5 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:17:53 +0000 Subject: [PATCH 05/12] Notification API and services --- backend/models/index.js | 9 ++ backend/models/notification.js | 27 ++++- backend/routes/notifications.js | 56 ++++++++++ backend/routes/test-notifications.js | 5 + backend/services/deferredTaskService.js | 4 + backend/services/dueProjectService.js | 6 ++ backend/services/dueTaskService.js | 4 + backend/services/webPushService.js | 130 ++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 backend/services/webPushService.js diff --git a/backend/models/index.js b/backend/models/index.js index d42d7a60d..8506decff 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -37,6 +37,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' }); @@ -157,6 +158,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, @@ -177,4 +185,5 @@ module.exports = { RecurringCompletion, TaskAttachment, Backup, + PushSubscription, }; diff --git a/backend/models/notification.js b/backend/models/notification.js index 1f52648ea..a1c6afe1e 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/routes/notifications.js b/backend/routes/notifications.js index 958620f1f..91f908ffe 100644 --- a/backend/routes/notifications.js +++ b/backend/routes/notifications.js @@ -1,6 +1,7 @@ const express = require('express'); const { Notification } = require('../models'); const { logError } = require('../services/logService'); +const webPushService = require('../services/webPushService'); const router = express.Router(); const { getAuthenticatedUserId } = require('../utils/request-utils'); @@ -156,4 +157,59 @@ router.delete('/:id', async (req, res) => { } }); +// GET /notifications/push/vapid-key - Get public VAPID key +router.get('/push/vapid-key', (req, res) => { + const publicKey = webPushService.getVapidPublicKey(); + if (!publicKey) { + return res + .status(503) + .json({ error: 'Push notifications not configured' }); + } + res.json({ publicKey }); +}); + +// POST /notifications/push/subscribe - Subscribe to push notifications +router.post('/push/subscribe', async (req, res) => { + try { + const { subscription } = req.body; + if (!subscription || !subscription.endpoint || !subscription.keys) { + return res + .status(400) + .json({ error: 'Invalid subscription object' }); + } + + const result = await webPushService.subscribe( + req.authUserId, + subscription + ); + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + res.json({ success: true, created: result.created }); + } catch (error) { + logError('Error subscribing to push:', error); + res.status(500).json({ error: 'Failed to subscribe' }); + } +}); + +// DELETE /notifications/push/unsubscribe - Unsubscribe from push notifications +router.delete('/push/unsubscribe', async (req, res) => { + try { + const { endpoint } = req.body; + if (!endpoint) { + return res.status(400).json({ error: 'Endpoint is required' }); + } + + const result = await webPushService.unsubscribe( + req.authUserId, + endpoint + ); + res.json({ success: true, deleted: result.deleted }); + } catch (error) { + logError('Error unsubscribing from push:', error); + res.status(500).json({ error: 'Failed to unsubscribe' }); + } +}); + module.exports = router; diff --git a/backend/routes/test-notifications.js b/backend/routes/test-notifications.js index 820f99b88..1658c5363 100644 --- a/backend/routes/test-notifications.js +++ b/backend/routes/test-notifications.js @@ -97,6 +97,11 @@ function getSources(user, notificationType) { sources.push('email'); } + // Add push to sources if enabled + if (prefs[prefKey].push === true) { + sources.push('push'); + } + return sources; } diff --git a/backend/services/deferredTaskService.js b/backend/services/deferredTaskService.js index 0c1784936..66b2ab5b6 100644 --- a/backend/services/deferredTaskService.js +++ b/backend/services/deferredTaskService.js @@ -4,6 +4,7 @@ const { logError } = require('./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/services/dueProjectService.js b/backend/services/dueProjectService.js index 002adac32..235708d38 100644 --- a/backend/services/dueProjectService.js +++ b/backend/services/dueProjectService.js @@ -4,6 +4,7 @@ const { logError } = require('./logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, + shouldSendPushNotification, } = require('../utils/notificationPreferences'); /** @@ -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/services/dueTaskService.js b/backend/services/dueTaskService.js index a669ba9f2..1bcb716c4 100644 --- a/backend/services/dueTaskService.js +++ b/backend/services/dueTaskService.js @@ -4,6 +4,7 @@ const { logError } = require('./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/services/webPushService.js b/backend/services/webPushService.js new file mode 100644 index 000000000..46f554664 --- /dev/null +++ b/backend/services/webPushService.js @@ -0,0 +1,130 @@ +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, +}; + From e9329fe12cab1978eb1d3057a5d530716ed03459 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:24:11 +0000 Subject: [PATCH 06/12] Vapid generate script git. Complements #138d949 --- backend/scripts/vapid-generate.js | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 backend/scripts/vapid-generate.js 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'); From a7bd7f9ea786ed6bcf94b13b76919c49cf939119 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:24:51 +0000 Subject: [PATCH 07/12] Remove dev code invalidating SW --- frontend/index.tsx | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/frontend/index.tsx b/frontend/index.tsx index 35c65eaec..7d3013c86 100644 --- a/frontend/index.tsx +++ b/frontend/index.tsx @@ -10,28 +10,6 @@ import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; // Import the i18n instance with its configuration import { getBasePath } from './config/paths'; -const isDevelopment = process.env.NODE_ENV !== 'production'; - -// Clear out any lingering service workers/caches from other branches (e.g. PWA) -if (isDevelopment && 'serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then((registrations) => { - registrations.forEach((registration) => { - registration.unregister().catch(() => { - // Non-fatal during development cleanup - }); - }); - }); - - if ('caches' in window) { - caches.keys().then((cacheNames) => { - cacheNames.forEach((cacheName) => { - caches.delete(cacheName).catch(() => { - // Ignore cache cleanup failures during dev - }); - }); - }); - } -} const storedPreference = localStorage.getItem('isDarkMode'); const prefersDarkMode = window.matchMedia( From 24f984b7bb834f9ad6e2deebe632f27c064ede72 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 04:25:13 +0000 Subject: [PATCH 08/12] Add preferences for push notifications --- backend/utils/notificationPreferences.js | 29 ++- .../components/Profile/ProfileSettings.tsx | 10 + .../Profile/tabs/NotificationsTab.tsx | 226 +++++++++++++++++- 3 files changed, 246 insertions(+), 19 deletions(-) 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/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index bf603c781..527a4ffcd 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'; @@ -131,6 +132,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(''); @@ -1059,6 +1061,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(); @@ -1254,6 +1261,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 +57,7 @@ interface NotificationTypeRowProps { value: boolean ) => void; telegramConfigured: boolean; + pushConfigured: boolean; } const NotificationTypeRow: React.FC = ({ @@ -53,6 +67,7 @@ const NotificationTypeRow: React.FC = ({ preferences, onToggle, telegramConfigured, + pushConfigured, }) => { const renderToggle = ( channel: 'inApp' | 'email' | 'push' | 'telegram', @@ -103,7 +118,7 @@ const NotificationTypeRow: React.FC = ({ {renderToggle('email', preferences.email, false)} - {renderToggle('push', preferences.push, false)} + {renderToggle('push', preferences.push, pushConfigured)} {renderToggle( @@ -120,6 +135,7 @@ const NotificationsTab: React.FC = ({ isActive, notificationPreferences, onChange, + onSave, }) => { const { t } = useTranslation(); const [profile, setProfile] = React.useState(null); @@ -127,6 +143,145 @@ 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,9 +319,21 @@ 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(''); @@ -196,7 +363,7 @@ const NotificationsTab: React.FC = ({ } finally { setTestLoading(false); // Clear message after 5 seconds - setTimeout(() => setTestMessage(''), 5000); + testMessageTimeoutRef.current = setTimeout(() => setTestMessage(''), 5000); } }; @@ -213,6 +380,41 @@ 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 +457,7 @@ const NotificationsTab: React.FC = ({
-
- {t('notifications.channels.push', 'Push')} - - ({t('common.comingSoon', 'Coming Soon')} - ) - -
+ {t('notifications.channels.push', 'Push')} {t( @@ -276,17 +472,18 @@ const NotificationsTab: React.FC = ({ icon={BellAlertIcon} label={t( 'notifications.types.dueTasks', - 'Due Tasks' + 'Due Tasks' )} description={t( 'notifications.descriptions.dueTasks', 'Tasks that are due within 24 hours' )} preferences={preferences.dueTasks} - onToggle={(channel, value) => + onToggle={(channel, value) => 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 +601,7 @@ const NotificationsTab: React.FC = ({ @@ -472,14 +519,14 @@ const NotificationsTab: React.FC = ({ icon={BellAlertIcon} label={t( 'notifications.types.dueTasks', - 'Due Tasks' + 'Due Tasks' )} description={t( 'notifications.descriptions.dueTasks', 'Tasks that are due within 24 hours' )} preferences={preferences.dueTasks} - onToggle={(channel, value) => + onToggle={(channel, value) => handleToggle('dueTasks', channel, value) } telegramConfigured={telegramConfigured} diff --git a/frontend/index.tsx b/frontend/index.tsx index 7d3013c86..b71cdb7a8 100644 --- a/frontend/index.tsx +++ b/frontend/index.tsx @@ -10,7 +10,6 @@ import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; // Import the i18n instance with its configuration import { getBasePath } from './config/paths'; - const storedPreference = localStorage.getItem('isDarkMode'); const prefersDarkMode = window.matchMedia( '(prefers-color-scheme: dark)' From cbd6a6f6f23a6e7918d1f24558fa60a164593051 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 21:54:22 +0000 Subject: [PATCH 10/12] Fix service error issue in production --- backend/app.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/app.js b/backend/app.js index a48772b10..df78ef582 100644 --- a/backend/app.js +++ b/backend/app.js @@ -70,6 +70,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'))); From b9dc6cab9269b31b84e8d77f4e477a08cc223868 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sat, 27 Dec 2025 21:56:55 +0000 Subject: [PATCH 11/12] some backend tests --- .../integration/push-notifications.test.js | 425 ++++++++++++++++++ .../unit/services/webPushService.test.js | 329 ++++++++++++++ 2 files changed, 754 insertions(+) create mode 100644 backend/tests/integration/push-notifications.test.js create mode 100644 backend/tests/unit/services/webPushService.test.js diff --git a/backend/tests/integration/push-notifications.test.js b/backend/tests/integration/push-notifications.test.js new file mode 100644 index 000000000..1ce4ccac7 --- /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/test-notifications/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/test-notifications/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/test-notifications/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'); + }); + }); + }); +}); From af5ecdd129bef0dc36d516e4e1d30de4a76f4df4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 6 Jan 2026 12:41:46 +0000 Subject: [PATCH 12/12] Remove trailing debug code. --- public/pwa/sw.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/public/pwa/sw.js b/public/pwa/sw.js index e0922af59..279d6dcc4 100644 --- a/public/pwa/sw.js +++ b/public/pwa/sw.js @@ -16,9 +16,6 @@ self.addEventListener("install", (event) => { // Cache offline page first (critical) const offlinePromise = cache .add(OFFLINE_PAGE) - .then(() => { - console.log(`āœ“ Cached: ${OFFLINE_PAGE}`); - }) .catch((error) => { console.warn(`āœ— Failed to cache ${OFFLINE_PAGE}:`, error.message); }); @@ -28,9 +25,6 @@ self.addEventListener("install", (event) => { staticAssets.map((url) => { return cache .add(url) - .then(() => { - console.log(`āœ“ Cached: ${url}`); - }) .catch((error) => { console.warn(`āœ— Failed to cache ${url}:`, error.message); // Continue even if this asset fails @@ -135,7 +129,6 @@ self.addEventListener("fetch", (event) => { const responseToCache = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseToCache); - console.log(`šŸ’¾ Cached API response: ${pathname}`); }); } return response; @@ -144,7 +137,6 @@ self.addEventListener("fetch", (event) => { // Network failed, try cache return caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { - console.log(`šŸ“¦ Serving cached API response: ${pathname}`); return cachedResponse; } // Both network and cache failed - return error