From 01f37e60be715de6b1283e7485aac8fea43cd6a1 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Mon, 23 Mar 2026 19:34:13 -0400 Subject: [PATCH] v0.12.8 FCM bug fix --- backend/package.json | 2 +- backend/src/routes/push.js | 89 +- build.sh | 2 +- fcm-app/.dockerignore | 15 + fcm-app/.env | 18 + fcm-app/.env.example | 15 + fcm-app/Dockerfile | 33 + fcm-app/FCM_IMPLEMENTATION_NOTES.md | 311 +++++++ fcm-app/README.md | 209 +++++ fcm-app/docker-compose.yml | 22 + fcm-app/fcm_details.txt | 1013 +++++++++++++++++++++ fcm-app/nginx.conf | 57 ++ fcm-app/package.json | 23 + fcm-app/public/app.js | 334 +++++++ fcm-app/public/firebase-messaging-sw.js | 48 + fcm-app/public/icon-192.png | Bin 0 -> 14690 bytes fcm-app/public/icon-512.png | Bin 0 -> 68275 bytes fcm-app/public/index.html | 111 +++ fcm-app/public/manifest.json | 28 + fcm-app/public/sw.js | 82 ++ fcm-app/server.js | 244 +++++ frontend/package.json | 2 +- frontend/public/sw.js | 15 +- frontend/src/components/SettingsModal.jsx | 122 +++ frontend/src/utils/api.js | 3 +- 25 files changed, 2769 insertions(+), 29 deletions(-) create mode 100644 fcm-app/.dockerignore create mode 100644 fcm-app/.env create mode 100644 fcm-app/.env.example create mode 100644 fcm-app/Dockerfile create mode 100644 fcm-app/FCM_IMPLEMENTATION_NOTES.md create mode 100644 fcm-app/README.md create mode 100644 fcm-app/docker-compose.yml create mode 100644 fcm-app/fcm_details.txt create mode 100644 fcm-app/nginx.conf create mode 100644 fcm-app/package.json create mode 100644 fcm-app/public/app.js create mode 100644 fcm-app/public/firebase-messaging-sw.js create mode 100644 fcm-app/public/icon-192.png create mode 100644 fcm-app/public/icon-512.png create mode 100644 fcm-app/public/index.html create mode 100644 fcm-app/public/manifest.json create mode 100644 fcm-app/public/sw.js create mode 100644 fcm-app/server.js diff --git a/backend/package.json b/backend/package.json index d57bdcd..55c1b6c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.7", + "version": "0.12.8", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js index fab0568..f918718 100644 --- a/backend/src/routes/push.js +++ b/backend/src/routes/push.js @@ -40,18 +40,35 @@ async function sendPushToUser(schema, userId, payload) { try { await messaging.send({ token: sub.fcm_token, + // Top-level notification ensures FCM/Chrome can display even if the SW + // onBackgroundMessage handler has trouble — mirrors the working fcm-app pattern. + notification: { + title: payload.title || 'New Message', + body: payload.body || '', + }, + // Extra fields for SW click-routing (url, groupId) data: { - title: payload.title || 'New Message', - body: payload.body || '', url: payload.url || '/', groupId: payload.groupId ? String(payload.groupId) : '', }, - android: { priority: 'high' }, + android: { + priority: 'high', + notification: { sound: 'default' }, + }, apns: { headers: { 'apns-priority': '10' }, - payload: { aps: { contentAvailable: true } }, + payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } }, + }, + webpush: { + headers: { Urgency: 'high' }, + notification: { + icon: '/icons/icon-192.png', + badge: '/icons/icon-192-maskable.png', + tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message', + renotify: true, + }, + fcm_options: { link: payload.url || '/' }, }, - webpush: { headers: { Urgency: 'high' } }, }); } catch (err) { // Remove stale tokens @@ -117,9 +134,9 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => { }); // Send a test push to the requesting user's own device — for diagnosing FCM setup. -// mode=data (default): data-only message handled by the service worker onBackgroundMessage. -// mode=browser: webpush.notification message handled by Chrome directly (bypasses SW). -// Use mode=browser to check if FCM delivery itself works when the SW is not involved. +// mode=notification (default): notification+data message — same path as real messages. +// mode=browser: webpush.notification only — Chrome shows it directly, SW not involved. +// Use mode=browser to verify FCM delivery works independently of the service worker. router.post('/test', authMiddleware, async (req, res) => { try { const subs = await query(req.schema, @@ -137,38 +154,46 @@ router.post('/test', authMiddleware, async (req, res) => { return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' }); } - const mode = req.query.mode === 'browser' ? 'browser' : 'data'; + const mode = req.query.mode === 'browser' ? 'browser' : 'notification'; const results = []; for (const sub of subs) { try { const message = { token: sub.fcm_token, - android: { priority: 'high' }, + android: { + priority: 'high', + notification: { sound: 'default' }, + }, apns: { headers: { 'apns-priority': '10' }, - payload: { aps: { contentAvailable: true } }, + payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } }, + }, + webpush: { + headers: { Urgency: 'high' }, + notification: { + icon: '/icons/icon-192.png', + badge: '/icons/icon-192-maskable.png', + tag: 'rosterchirp-test', + }, }, - webpush: { headers: { Urgency: 'high' } }, }; if (mode === 'browser') { // Chrome displays the notification directly — onBackgroundMessage does NOT fire. // Use this to verify FCM delivery works independently of the service worker. - message.webpush.notification = { - title: 'RosterChirp Test (browser)', - body: 'FCM delivery confirmed — Chrome handled this directly.', - icon: '/icons/icon-192.png', - }; + message.webpush.notification.title = 'RosterChirp Test (browser)'; + message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.'; message.webpush.fcm_options = { link: '/' }; } else { - // data-only — service worker onBackgroundMessage must show the notification. - message.data = { - title: 'RosterChirp Test', - body: 'Push notifications are working!', - url: '/', - groupId: '', + // notification+data — same structure as real messages. + // SW onBackgroundMessage fires and shows the notification. + message.notification = { + title: 'RosterChirp Test', + body: 'Push notifications are working!', }; + message.data = { url: '/', groupId: '' }; + message.webpush.fcm_options = { link: '/' }; } await messaging.send(message); @@ -183,4 +208,22 @@ router.post('/test', authMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); +// Debug endpoint (admin-only) — lists all FCM subscriptions for this schema +router.get('/debug', authMiddleware, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); + try { + const subs = await query(req.schema, ` + SELECT ps.id, ps.user_id, ps.device, ps.fcm_token, + u.name, u.email + FROM push_subscriptions ps + JOIN users u ON u.id = ps.user_id + WHERE ps.fcm_token IS NOT NULL + ORDER BY u.name, ps.device + `); + const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT); + const firebaseAdminReady = !!getMessaging(); + res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + module.exports = { router, sendPushToUser }; diff --git a/build.sh b/build.sh index dd14fd8..01e5ee6 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.7}" +VERSION="${1:-0.12.8}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/fcm-app/.dockerignore b/fcm-app/.dockerignore new file mode 100644 index 0000000..dc4303e --- /dev/null +++ b/fcm-app/.dockerignore @@ -0,0 +1,15 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +.env.*.local +.nyc_output +coverage +.vscode +.idea +*.log +ssl/ +icon-*.png diff --git a/fcm-app/.env b/fcm-app/.env new file mode 100644 index 0000000..bb2824b --- /dev/null +++ b/fcm-app/.env @@ -0,0 +1,18 @@ +# Firebase Configuration +FIREBASE_PROJECT_ID=fcmtest-push +FIREBASE_PRIVATE_KEY_ID=ac38f0122d21b6db2e7cfae4ed2120d848afcb13 +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7S59WBylwnzgq\nYUpbwj4vzoLa6MtC7K/ZrB2Uxj1QuqdbnMsFid9RkWs+z86FUH/DgGyABnhhuBxO\nK8yQ+f1WR6deM7v1xFLrmYVDLk/7VGNGtn/xmQ7yjJPLFLqNplPWxjz8StJDiRRh\nFjPewGFrk/afDy0garsJTP6tK1IRGIf/dvIdBiCHQ1xpmWwkNDb1xNFSWx3JpN9m\nEbsMZBo5Af2jL044Z4jLEO+y32freiRoZBG4KG6Jb4+xo2qwjxFATmychpc9xEsf\nrMyOaV7omuhqOmjK3PfSotZnYyYAat8kerATe/EZsRtlTh1UHsiN+1FNy/RPV5s8\nTFYWf7a/AgMBAAECggEAJ7Ce01wxK+yRumljmI5RH1Bj6n/qkwQVP8t5eU2JMNJd\nJMzVORc+e8qVL3paCWZFrOhKFddJK2wYk3g0oYRYazBEB3JvImW4LLUbyGDIEjqP\nzyxdcJU+1ad0qlR6NApLOfhIdC5m4GjsKKbL1yhtfJ6eZJaSuYvkltP6JDhJ69Uq\nLdtA2dA5RGr1W1I8G3Yw4tNw5ImrfxbD7sO1y7A2aI5ZRL4/fOK0QCjbu8dznqPg\n8qT4dqabIRWTdM70ixEqfojQwNmL1w4wVajX470jn8iJZau0QMpJVfm2PtBxzXcM\nuQU+kP6b7BrFvKJ4LD0UOweiDQncfnKiNamMZKQgAQKBgQDcobi+lhkYxekvztq/\nv0d3RqgpmnABg1dPvNYbFV1WPjzCy/Pv87HFROb0LA/xNQKjA+2ss+LDEZXgSRuV\n7ovEQ2Zib/TyN10ihYGpIbXlbxz9rEtsatIuynKvYFlWm/v1S5LnPkCXlkHLi+cO\n2Z6DniGjCLqB4w5ZqkYzWVnSfwKBgQDZUdh5VRAR/ge1Vi5QtpQKuaZRvxjS+GZH\nmJNuIfm/+9zKakOMXgieT1wyTFr6I7955h967BrfO/djtvAQca+7l68hlyTgS4bf\n+nEVCTd3wwAbcEXOubpgnyLzQeaztRTFkcpyTZ2eVGraoAjijsElOtbJBbu9xaqS\nOoH4Adt7wQKBgQDNppSMWV41QCx2Goq9li6oGB0hAkoKrwEQWwT7I7PncoWyUOck\nr3LxXKMlz3hgrbeyeTPt+ZKRnu+jqqFi5II0w1pIwPCBYWeXiPftzXU90Y8lSJbZ\nDMyzPpMds2Iyn5x/7RyWHOmaIj1b3CDYL7JYHmpeDAHElf7HRza+IDfgQwKBgBTQ\nfwBYAlsGzqwynesDIbjJQUHRIMqMGhe/aFeDD42wzNviQ6f9Faw8A6OZppkQtXUy\nck9ur8Az2SUGz4VzrhY0mASKmnCVK0zmitAt+s8QsUDvhvAe39gDRfCwni0WKfAm\nX5KFFpSklztrWo6Ah8VOFmZYkzvA4+5vhiU/4ErBAoGAboI2WX/JNd8A5KQgRRpT\n5RkNLbhgg1TaBBEdfCkpuCJbpghAnfpvg/2lTtbLJ7SbAijmldrT5nNbhVNxAgYM\nZgOcoZJPBGi1AB1HzlkcGO/C9/H1tnEBB6ECbQ3yaz0n8TLUuJqHGwsomJJVPACT\n2FSNbfQ0TqCs1ba+Hx9iQBQ=\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@fcmtest-push.iam.gserviceaccount.com +FIREBASE_CLIENT_ID=103917424542871804597 +FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth +FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token +FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs +FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40fcmtest-push.iam.gserviceaccount.com + +# VAPID Key for Web Push +VAPID_KEY=BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw + +# Server Configuration +PORT=3000 +NODE_ENV=production +TZ=America/Toronto diff --git a/fcm-app/.env.example b/fcm-app/.env.example new file mode 100644 index 0000000..0d19f7e --- /dev/null +++ b/fcm-app/.env.example @@ -0,0 +1,15 @@ +# Firebase Configuration +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_PRIVATE_KEY_ID=your-private-key-id +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com +FIREBASE_CLIENT_ID=your-client-id +FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth +FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token +FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs +FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-...%40your-project-id.iam.gserviceaccount.com + +# Server Configuration +PORT=3000 +NODE_ENV=production +TZ=America/Toronto diff --git a/fcm-app/Dockerfile b/fcm-app/Dockerfile new file mode 100644 index 0000000..618d7c0 --- /dev/null +++ b/fcm-app/Dockerfile @@ -0,0 +1,33 @@ +# Use Node.js 18 LTS +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files first (for better layer caching) +COPY package*.json ./ + +# Install dependencies and wget +RUN npm install --omit=dev && apk add --no-cache wget + +# Create non-root user and a writable data directory before copying app code +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 && \ + mkdir -p /app/data && \ + chown nodejs:nodejs /app/data + +# Copy application code (exclude node_modules via .dockerignore) +COPY --chown=nodejs:nodejs . . + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" + +# Start the application +CMD ["npm", "start"] diff --git a/fcm-app/FCM_IMPLEMENTATION_NOTES.md b/fcm-app/FCM_IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..f4dc7d8 --- /dev/null +++ b/fcm-app/FCM_IMPLEMENTATION_NOTES.md @@ -0,0 +1,311 @@ +# FCM PWA Implementation Notes +_Reference for applying FCM fixes to other projects_ + +--- + +## Part 1 — Guide Key Points (fcm_details.txt) + +### How FCM works (the correct flow) +1. User grants notification permission +2. Firebase generates a unique FCM token for the device +3. Token is stored on your server for targeting +4. Server sends push requests to Firebase +5. Firebase delivers notifications to the device +6. Service worker handles display and click interactions + +### Common vibe-coding failures with FCM + +**1. Service worker confusion** +Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail. + +**2. Deprecated API usage** +Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is: +```javascript +const token = await messaging.getToken({ + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration +}); +``` + +**3. Token generation without durable storage** +Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart. + +**4. Poor permission flow** +Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit. + +**5. Missing notificationclick handler** +Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app. + +**6. Silent failures** +Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback. + +**7. iOS blind spots** +iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop. + +### Correct `getToken()` pattern (from guide) +```javascript +// Register SW first, then pass it directly to getToken +const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); +const token = await getToken(messaging, { + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration +}); +if (!token) throw new Error('getToken() returned empty — check VAPID key and SW'); +``` + +### Correct `firebase-messaging-sw.js` pattern (from guide) +```javascript +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'); + +firebase.initializeApp({ /* config */ }); +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage((payload) => { + self.registration.showNotification(payload.notification.title, { + body: payload.notification.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'fcm-notification', + data: payload.data + }); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + if (event.action === 'close') return; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) return client.focus(); + } + if (clients.openWindow) return clients.openWindow('/'); + }) + ); +}); +``` + +--- + +## Part 2 — Code Fixes Applied to fcm-app + +### app.js fixes + +**Fix: `showUserInfo()` missing** +Function was called on login and session restore but never defined — crashed immediately on login. +```javascript +function showUserInfo() { + document.getElementById('loginForm').style.display = 'none'; + document.getElementById('userInfo').style.display = 'block'; + document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser; +} +``` + +**Fix: `setupApp()` wrong element IDs** +`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML. +```javascript +// Wrong +document.getElementById('sendNotification').addEventListener('click', sendNotification); +// Fixed +document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification); +// Also added id="logoutBtn" to the logout button in index.html +``` + +**Fix: `logout()` not clearing localStorage** +Session was restored on next page load even after logout. +```javascript +function logout() { + currentUser = null; + fcmToken = null; + localStorage.removeItem('currentUser'); // was missing + // ... +} +``` + +**Fix: Race condition in messaging initialization** +`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise: +```javascript +let initPromise = null; +function initializeFirebase() { + if (initPromise) return initPromise; + initPromise = navigator.serviceWorker.register('/sw.js') + .then((registration) => { + swRegistration = registration; + messaging = firebase.messaging(); + }) + .catch((error) => { initPromise = null; throw error; }); + return initPromise; +} +// In login(): +await initializeFirebase(); // ensures messaging is ready before getToken() +``` + +**Fix: `deleteToken()` invalidating tokens on every page load** +`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0. +Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`. + +**Fix: Session restore without re-registering token** +When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token. +```javascript +// In setupApp(), after restoring session: +if (Notification.permission === 'granted') { + initializeFirebase() + .then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration })) + .then(token => { if (token) return registerToken(currentUser, token); }) + .catch(err => console.error('Token refresh on session restore failed:', err)); +} +``` + +**Fix: Deprecated VAPID/SW API replaced** +```javascript +// Removed (deprecated): +messaging.usePublicVapidKey(VAPID_KEY); +messaging.useServiceWorker(registration); +const token = await messaging.getToken(); + +// Replaced with: +const VAPID_KEY = 'your-vapid-key'; +let swRegistration = null; +// swRegistration set inside initializeFirebase() .then() +const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }); +``` + +**Fix: Null token guard** +`getToken()` can return null — passing null to the server produced a confusing 400 error. +```javascript +if (!token) { + throw new Error('getToken() returned empty — check VAPID key and service worker'); +} +``` + +**Fix: Error message included server response** +```javascript +// Before: throw new Error('Failed to register token'); +// After: +throw new Error(`Server returned ${response.status}: ${errorText}`); +``` + +**Fix: Duplicate foreground message handlers** +`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners. +```javascript +let foregroundHandlerSetup = false; +function handleForegroundMessages() { + if (foregroundHandlerSetup) return; + foregroundHandlerSetup = true; + messaging.onMessage(/* ... */); +} +``` + +**Fix: `login()` event.preventDefault() crash** +Button called `login()` with no argument, so `event.preventDefault()` threw on undefined. +```javascript +async function login(event) { + if (event) event.preventDefault(); // guard added +``` + +**Fix: `firebase-messaging-sw.js` redirect stub replaced** +File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above). + +**Fix: `notificationclick` handler added to `sw.js`** +Clicking a background notification did nothing. Handler added to focus existing window or open a new one. + +**Fix: CDN URLs removed from `urlsToCache` in `sw.js`** +External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install. +```javascript +// Removed from urlsToCache: +// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js', +// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js' +``` + +### server.js fixes + +**Fix: `icon`/`badge`/`tag` in wrong notification object** +These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`). +```javascript +// Wrong: +notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' } +// Fixed: +notification: { title, body }, +webpush: { + notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }, + // ... +} +``` + +**Fix: `saveTokens()` in route handler not crash-safe** +```javascript +try { + saveTokens(); +} catch (saveError) { + console.error('Failed to persist tokens to disk:', saveError); +} +``` + +**Fix: `setInterval(saveTokens)` uncaught exception crashed the server** +An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state. +```javascript +setInterval(() => { + try { saveTokens(); } + catch (error) { console.error('Auto-save tokens failed:', error); } +}, 30000); +``` + +--- + +## Part 3 — Docker / Infrastructure Fixes + +### Root cause of "no other users" bug +The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain: +1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`) +2. This propagated out of `setInterval` as an uncaught exception +3. Node.js exited the process +4. Docker restarted the container with empty state +5. Tokens were never on disk, so restart = all tokens lost + +### Dockerfile fix +```dockerfile +# Create non-root user AND a writable data directory (while still root) +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 && \ + mkdir -p /app/data && \ + chown nodejs:nodejs /app/data +``` +`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it. + +### docker-compose.yml fix +```yaml +services: + your-app: + volumes: + - app_data:/app/data # named volume survives container rebuilds + +volumes: + app_data: +``` +Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`. + +### server.js path fix +```javascript +// Changed from: +const TOKENS_FILE = './tokens.json'; +// To: +const TOKENS_FILE = './data/tokens.json'; +``` + +--- + +## Checklist for applying to another project + +- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub) +- [ ] `notificationclick` handler present in service worker +- [ ] CDN URLs NOT in `urlsToCache` in any service worker +- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()` +- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker` +- [ ] `deleteToken()` is NOT called on page load +- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'` +- [ ] Null/empty token check before sending to server +- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification` +- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval` +- [ ] Docker: data directory created with correct user ownership in Dockerfile +- [ ] Docker: named volume mounted for data directory in docker-compose.yml +- [ ] Duplicate foreground message handler registration is guarded diff --git a/fcm-app/README.md b/fcm-app/README.md new file mode 100644 index 0000000..a02cdec --- /dev/null +++ b/fcm-app/README.md @@ -0,0 +1,209 @@ +# FCM Test PWA + +A Progressive Web App for testing Firebase Cloud Messaging (FCM) notifications across desktop and mobile devices. + +## Features + +- PWA with install capability +- Firebase Cloud Messaging integration +- Multi-user support (pwau1, pwau2, pwau3) +- SSL/HTTPS ready +- Docker deployment +- Real-time notifications + +## Quick Start + +### 1. Firebase Setup + +1. **Create Firebase Project** + - Go to [Firebase Console](https://console.firebase.google.com/) + - Click "Add project" + - Enter project name (e.g., "fcm-test-pwa") + - Enable Google Analytics (optional) + - Click "Create project" + +2. **Enable Cloud Messaging** + - In your project dashboard, go to "Build" → "Cloud Messaging" + - Click "Get started" + - Cloud Messaging is now enabled for your project + +3. **Get Firebase Configuration** + - Go to Project Settings (⚙️ icon) + - Under "Your apps", click "Web app" ( icon) + - Register app with nickname "FCM Test PWA" + - Copy the Firebase config object (you'll need this later) + +4. **Generate Service Account Key** + - In Project Settings, go to "Service accounts" + - Click "Generate new private key" + - Save the JSON file (you'll need this for the server) + +5. **Get Web Push Certificate** + - In Cloud Messaging settings, click "Web Push certificates" + - Generate and save the key pair + +### 2. Server Configuration + +1. **Copy environment template** + ```bash + cp .env.example .env + ``` + +2. **Update .env file** with your Firebase credentials: + ```env + FIREBASE_PROJECT_ID=your-project-id + FIREBASE_PRIVATE_KEY_ID=your-private-key-id + FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com + FIREBASE_CLIENT_ID=your-client-id + # ... other fields from service account JSON + ``` + +3. **Update Firebase config in client files**: + - Edit `public/app.js` - replace Firebase config + - Edit `public/sw.js` - replace Firebase config + +### 3. Local Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +Open http://localhost:3000 in your browser. + +### 4. Docker Deployment + +```bash +# Build and run with Docker Compose +docker-compose up -d + +# View logs +docker-compose logs -f +``` + +## User Accounts + +| Username | Password | Purpose | +|----------|----------|---------| +| pwau1 | test123 | Desktop user | +| pwau2 | test123 | Mobile user 1 | +| pwau3 | test123 | Mobile user 2 | + +## Usage + +1. **Install as PWA** + - Open the app in Chrome/Firefox + - Click the install icon in the address bar + - Install as a desktop app + +2. **Enable Notifications** + - Login with any user account + - Grant notification permissions when prompted + - FCM token will be automatically registered + +3. **Send Notifications** + - Click "Send Notification" button + - All other logged-in users will receive the notification + - Check both desktop and mobile devices + +## Deployment on Ubuntu LXC + HAProxy + +### Prerequisites + +```bash +# Update system +sudo apt update && sudo apt upgrade -y + +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER + +# Install Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose +``` + +### SSL Certificate Setup + +```bash +# Create SSL directory +mkdir -p ssl + +# Generate self-signed certificate (for testing) +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout ssl/key.pem \ + -out ssl/cert.pem \ + -subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com" + +# OR use Let's Encrypt for production +sudo apt install certbot +sudo certbot certonly --standalone -d your-domain.com +sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem +sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem +``` + +### HAProxy Configuration + +Add to your `/etc/haproxy/haproxy.cfg`: + +```haproxy +frontend fcm_test_frontend + bind *:80 + bind *:443 ssl crt /etc/ssl/certs/your-cert.pem + redirect scheme https if !{ ssl_fc } + default_backend fcm_test_backend + +backend fcm_test_backend + balance roundrobin + server fcm_test 127.0.0.1:3000 check +``` + +### Deploy + +```bash +# Clone and setup +git clone +cd fcm-test-pwa +cp .env.example .env +# Edit .env with your Firebase config + +# Deploy +docker-compose up -d + +# Check status +docker-compose ps +docker-compose logs +``` + +## Testing + +1. **Desktop Testing** + - Open app in Chrome + - Install as PWA + - Login as pwau1 + - Send test notifications + +2. **Mobile Testing** + - Open app on mobile browsers + - Install as PWA + - Login as pwau2 and pwau3 on different devices + - Test cross-device notifications + +## Troubleshooting + +- **Notifications not working**: Check Firebase configuration and service worker +- **PWA not installing**: Ensure site is served over HTTPS +- **Docker issues**: Check logs with `docker-compose logs` +- **HAProxy issues**: Verify SSL certificates and backend connectivity + +## Security Notes + +- Change default passwords in production +- Use proper SSL certificates +- Implement rate limiting for notifications +- Consider using a database for token storage in production diff --git a/fcm-app/docker-compose.yml b/fcm-app/docker-compose.yml new file mode 100644 index 0000000..1f447c3 --- /dev/null +++ b/fcm-app/docker-compose.yml @@ -0,0 +1,22 @@ +services: + fcm-test-app: + build: . + ports: + - "3066:3000" + environment: + - NODE_ENV=production + - TZ=${TZ:-UTC} + env_file: + - .env + volumes: + - fcm_data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + fcm_data: diff --git a/fcm-app/fcm_details.txt b/fcm-app/fcm_details.txt new file mode 100644 index 0000000..f92d2b7 --- /dev/null +++ b/fcm-app/fcm_details.txt @@ -0,0 +1,1013 @@ +How It Works +At a high level, push notifications follow a predictable sequence. + +User grants notification permission +Firebase generates a unique FCM token for the device +Token is stored on your server for targeting +Server sends push requests to Firebase +Firebase delivers notifications to the user’s device +Service worker handles notification display and user interactions +Where vibe coding commonly fails with Firebase +Recently, when I was building out a web app with Claude Code, I got great results until it came to FCM integration. My conversations with Claude started entering a loop and I realized that part of the reason was the version of docs it was following. There were few other issues I was able to identify: + +1. Service worker confusion +Auto generated setups often register multiple service workers or place Firebase logic in the wrong file. Sometimes the dedicated firebase-messaging-sw.js is not served from the root scope, or it gets replaced during a build. The result is predictable: FCM tokens appear to work, yet background notifications never arrive. + +2. Misconfig in development and production environments +The local configuration is different from that of production. Common patterns include using the wrong VAPID key, mixing Firebase projects, or forgetting to update service worker credentials after deployment. Notifications may work locally over HTTPS, then break entirely once the app is live. + +3. Token generation without durable storage +I also noticed that without a persistent storage, I was able to trigger notifications, but tokens disappear when users switch devices, clear storage, or reinstall the PWA. Worse, sometimes, the token is rarely tied to a user identity, making real notification targeting unreliable. + +4. Poor permission flow +Generated interfaces tend to request notification permission immediately on page load. Users often deny it, and the app provides no clear way to recover or explain the value of notifications. This permanently reduces deliverability. + +5. iOS blind spots +Most testing I did was in Chrome on Android or desktop. On iOS, notifications require the PWA to be added to the home screen, strict HTTPS configuration, and a correctly structured manifest. This was not clear until I went live. + +6. Backend mismatch for real notifications +Manual sending from the Firebase console always worked well. But when trying to get the backend to actually send messages, there are a number of additional steps of config need.. + +7. Silent failures instead of clear errors +Perhaps the biggest issue is invisibility. Tokens may be null, service workers may not register, or VAPID keys may be wrong, yet nothing clearly surfaces in the UI. This guide explicitly shows how to inspect each layer so failures become visible and fixable. + +Prerequisites +Before starting, ensure you have: + +HTTPS Website: Push notifications require secure connections +Service Worker: Essential for background notification handling +Web App Manifest: Defines how your PWA appears when installed +Node.js: For development and build tools +Firebase Account: For FCM services +Modern Browser: Chrome, Firefox, Safari, or Edge +Setting up a Project in Firebase Console +Step 1: Create Firebase Project +You begin by creating a Firebase project in the Firebase Console and registering your web application. + +Navigate to Firebase Console +Go to Firebase Console +Sign in with your Google account + +Create New Project +Click “Add project” → Enter project name → Continue + +Configure Google Analytics (Optional but recommended) +Choose existing account or create new → Select region → Accept terms + +Project Creation +Click “Create project” → Wait for setup completion + +Step 2: Add Web App to Project +Register Web App (Project Overview → Add app → Web () icon) + +App Configuration (App nickname: “Your PWA Name” → Firebase Hosting: ☑️ (optional) → Click “Register app”) + +Save Firebase Configuration + +// Copy this configuration object +const firebaseConfig = { + apiKey: "your-api-key", + authDomain: "your-project.firebaseapp.com", + projectId: "your-project-id", + storageBucket: "your-project.appspot.com", + messagingSenderId: "123456789012", + appId: "1:123456789012:web:abcdef123456" +}; +Step 3: Enable Cloud Messaging +Navigate to Cloud Messaging (Project settings → Cloud Messaging tab) + +Get Vijay’s stories in your inbox +Join Medium for free to get updates from this writer. + +Enter your email +Subscribe + +Remember me for faster sign in + +Generate VAPID Keys (Web configuration → Web Push certificates → Generate key pair) + +Important: Save the generated VAPID key — you’ll need it for client-side implementation. + +Setting up Service Accounts +If you do need service account setup, here is how to create one: + +Step 1: Create Service Account +Access Service Accounts in the Google Cloud Console: (Project settings → Service accounts tab) +Generate Private Key: (Firebase Admin SDK → Generate new private key → Generate key) +Download JSON File: A serviceAccountKey.json file will be downloaded +Security Warning: Never commit this file to version control! + +Step 2: Service Account JSON Structure +Your downloaded file will look like this: + +# json +{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com", + "client_id": "123456789012345678901", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-xxxxx%40your-project.iam.gserviceaccount.com" +} +Step 3: Secure Storage for Different Environments +For Supabase Edge Functions: + +sql +-- Store as encrypted secrets in Supabase +-- Dashboard → Project Settings → Edge Functions → Environment Variables + +FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account",...} +FIREBASE_PROJECT_ID=your-project-id +For Client-Side API Routes (Next.js): + +# Store in environment variables +VERCEL_PUBLIC_FIREBASE_SERVICE_ACCOUNT_KEY='{"type":"service_account",...}' +VERCEL_PUBLIC_FIREBASE_PROJECT_ID=your-project-id + +# Add to .gitignore +serviceAccountKey.json +.env.local +For Serverless Functions (Vercel, Netlify): + +# Store as environment variables in deployment platform +FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account",...} +FIREBASE_PROJECT_ID=your-project-id +Step 4: Integration Examples +Supabase Edge Function Example: + +// supabase/functions/send-notification/index.ts +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { initializeApp, cert } from "https://deno.land/x/firebase_admin@v1.0.0/mod.ts" + +const firebaseConfig = { + credential: cert(JSON.parse(Deno.env.get('FIREBASE_SERVICE_ACCOUNT_KEY')!)), + projectId: Deno.env.get('FIREBASE_PROJECT_ID') +} +const app = initializeApp(firebaseConfig) +serve(async (req) => { + const { userToken, title, body, data } = await req.json() + + try { + const message = { + token: userToken, + notification: { title, body }, + data: data || {} + } + + const response = await app.messaging().send(message) + return new Response(JSON.stringify({ success: true, messageId: response })) + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { status: 500 }) + } +}) +Server-Side API Route Example (Next.js): + +// pages/api/send-notification.js +import admin from 'firebase-admin' + +if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY)), + projectId: process.env.FIREBASE_PROJECT_ID + }) +} +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const { token, title, body, data } = req.body + + try { + const message = { + token, + notification: { title, body }, + data: data || {}, + webpush: { + fcmOptions: { + link: data?.url || '/' + } + } + } + + const response = await admin.messaging().send(message) + res.status(200).json({ success: true, messageId: response }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +} +Environment Setup +Step 1: Install Dependencies +# For React/Vite PWA +npm install firebase vite-plugin-pwa workbox-window + +# For vanilla JavaScript +npm install firebase +Step 2: Environment Variables +Create .env file: + +# Client-side Firebase config +VITE_FIREBASE_API_KEY=your-api-key +VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=your-project-id +VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com +VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012 +VITE_FIREBASE_APP_ID=1:123456789012:web:abcdef123456 +VITE_WEBPUSH_NOTIFICATION_KEY=your-vapid-key +Implementation Steps +Step 1: Firebase Configuration +Create src/firebase/config.js: + +import { initializeApp } from 'firebase/app'; +import { getMessaging, getToken, onMessage } from 'firebase/messaging'; + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID +}; +// Initialize Firebase +const app = initializeApp(firebaseConfig); +// Initialize Firebase Cloud Messaging +const messaging = getMessaging(app); +export { messaging, getToken, onMessage }; +Step 2: Service Worker Setup +Create public/firebase-messaging-sw.js: + +// Import Firebase scripts +importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js'); + +// Initialize Firebase in service worker +firebase.initializeApp({ + apiKey: "your-api-key", + authDomain: "your-project.firebaseapp.com", + projectId: "your-project-id", + storageBucket: "your-project.appspot.com", + messagingSenderId: "123456789012", + appId: "1:123456789012:web:abcdef123456" +}); +const messaging = firebase.messaging(); +// Handle background messages +messaging.onBackgroundMessage((payload) => { + console.log('Received background message:', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192x192.png', + badge: '/badge-72x72.png', + tag: 'notification-tag', + data: payload.data, + actions: [ + { + action: 'open', + title: 'Open App' + }, + { + action: 'close', + title: 'Close' + } + ] + }; + self.registration.showNotification(notificationTitle, notificationOptions); +}); +// Handle notification clicks +self.addEventListener('notificationclick', (event) => { + console.log('Notification clicked:', event); + + event.notification.close(); + + if (event.action === 'close') { + return; + } + + // Navigate to app + event.waitUntil( + clients.matchAll({ type: 'window' }).then((clientList) => { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); +Step 3: Notification Permission Handler +Create src/utils/notificationPermission.js: + +import { messaging, getToken } from '../firebase/config'; + +const VAPID_KEY = import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY; +export function isNotificationSupported() { + return 'Notification' in window && 'serviceWorker' in navigator; +} +export async function requestNotificationPermission() { + if (!isNotificationSupported()) { + throw new Error('Notifications not supported'); + } + const permission = await Notification.requestPermission(); + + if (permission === 'granted') { + // Register service worker first + const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); + + // Get FCM token + const token = await getToken(messaging, { + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration + }); + + console.log('FCM Token:', token); + + // Send token to your server + await sendTokenToServer(token); + + return token; + } else { + throw new Error(`Permission denied: ${permission}`); + } +} +async function sendTokenToServer(token) { + try { + await fetch('/api/fcm-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, userId: getCurrentUserId() }) + }); + } catch (error) { + console.error('Failed to send token to server:', error); + } +} +function getCurrentUserId() { + // Return current user ID from your auth system + return 'user-123'; +} +Step 4: FCM Token Service +Create src/services/FCMTokenService.js: + +import { messaging, getToken, onMessage } from '../firebase/config'; +import { requestNotificationPermission } from '../utils/notificationPermission'; + +export class FCMTokenService { + static async registerFCMToken() { + try { + if (Notification.permission === 'granted') { + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY + }); + + if (token) { + await this.saveTokenToDatabase(token); + return true; + } + } + return false; + } catch (error) { + console.error('FCM token registration failed:', error); + return false; + } + } + static async saveTokenToDatabase(token) { + // Store token in your preferred storage solution + // Example with localStorage + localStorage.setItem('fcmToken', token); + localStorage.setItem('fcmTokenTimestamp', Date.now().toString()); + + // Or integrate with your database/API + console.log('FCM token saved:', token); + } + static setupForegroundNotifications() { + onMessage(messaging, (payload) => { + console.log('Foreground message received:', payload); + + // Display notification manually for foreground messages + if (Notification.permission === 'granted') { + new Notification(payload.notification.title, { + body: payload.notification.body, + icon: payload.notification.icon || '/icon-192x192.png', + data: payload.data + }); + } + }); + } + static async clearToken() { + try { + // Clear token from local storage + localStorage.removeItem('fcmToken'); + localStorage.removeItem('fcmTokenTimestamp'); + console.log('FCM token cleared'); + } catch (error) { + console.error('Failed to clear FCM token:', error); + } + } +} +function getAuthToken() { + // Return your auth token if using authentication + // For demo purposes, return a placeholder + return 'demo-auth-token'; +} +Step 5: React Component Integration +Create src/components/NotificationPermissionDialog.jsx: + +import React, { useState, useEffect } from 'react'; +import { FCMTokenService } from '../services/FCMTokenService'; +import { requestNotificationPermission, isNotificationSupported } from '../utils/notificationPermission'; + +export function NotificationPermissionDialog() { + const [permissionState, setPermissionState] = useState(Notification.permission); + const [showDialog, setShowDialog] = useState(false); + useEffect(() => { + // Show dialog if notifications are supported but not granted + if (isNotificationSupported() && Notification.permission === 'default') { + const timer = setTimeout(() => setShowDialog(true), 2000); + return () => clearTimeout(timer); + } + + // Set up foreground notifications if already granted + if (Notification.permission === 'granted') { + FCMTokenService.setupForegroundNotifications(); + FCMTokenService.registerFCMToken(); + } + }, []); + const handleEnableNotifications = async () => { + try { + await requestNotificationPermission(); + setPermissionState('granted'); + setShowDialog(false); + + // Set up notifications + FCMTokenService.setupForegroundNotifications(); + } catch (error) { + console.error('Failed to enable notifications:', error); + setPermissionState('denied'); + } + }; + const handleDismiss = () => { + setShowDialog(false); + }; + if (!showDialog || !isNotificationSupported()) { + return null; + } + return ( +
+
+

Enable Notifications

+

+ Get notified about important updates and messages. + You can change this setting anytime. +

+
+ + +
+
+
+ ); +} +Step 6: Supabase Database Integration +Since you’re using Supabase, here’s how to integrate push notifications with your database: + +Database Schema for FCM Tokens: +-- Create table for storing FCM tokens +CREATE TABLE user_fcm_tokens ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + fcm_token TEXT NOT NULL, + device_info JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, fcm_token) +); + +-- Enable RLS +ALTER TABLE user_fcm_tokens ENABLE ROW LEVEL SECURITY; +-- Policy for users to manage their own tokens +CREATE POLICY "Users can manage their own FCM tokens" ON user_fcm_tokens + FOR ALL USING (auth.uid() = user_id); +Updated FCM Token Service for Supabase: +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient( + import.meta.env.VITE_SUPABASE_URL, + import.meta.env.VITE_SUPABASE_ANON_KEY +) +export class FCMTokenService { + static async registerFCMToken() { + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error('User not authenticated') + if (Notification.permission === 'granted') { + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY + }); + + if (token) { + await this.saveTokenToSupabase(user.id, token) + return true + } + } + return false + } catch (error) { + console.error('FCM token registration failed:', error) + return false + } + } + static async saveTokenToSupabase(userId, token) { + try { + const deviceInfo = { + userAgent: navigator.userAgent, + platform: navigator.platform, + timestamp: new Date().toISOString() + } + const { error } = await supabase + .from('user_fcm_tokens') + .upsert({ + user_id: userId, + fcm_token: token, + device_info: deviceInfo, + updated_at: new Date().toISOString() + }, { + onConflict: 'user_id,fcm_token' + }) + if (error) throw error + console.log('FCM token saved to Supabase') + } catch (error) { + console.error('Failed to save FCM token to Supabase:', error) + throw error + } + } + static async clearUserTokens() { + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) return + const { error } = await supabase + .from('user_fcm_tokens') + .delete() + .eq('user_id', user.id) + if (error) throw error + console.log('FCM tokens cleared from Supabase') + } catch (error) { + console.error('Failed to clear FCM tokens:', error) + } + } + static setupForegroundNotifications() { + onMessage(messaging, (payload) => { + console.log('Foreground message received:', payload) + + if (Notification.permission === 'granted') { + new Notification(payload.notification.title, { + body: payload.notification.body, + icon: payload.notification.icon || '/icon-192x192.png', + data: payload.data + }) + } + }) + } +} +Platform-Specific Configuration +iOS PWA Setup +Requirements for iOS notifications: + +Add to Home Screen: Users must add your PWA to their home screen +HTTPS: Mandatory for all PWA features +Web App Manifest: Must include proper configuration +Update your manifest.json: + +{ + "name": "Your PWA Name", + "short_name": "PWA", + "display": "standalone", + "start_url": "/", + "theme_color": "#000000", + "background_color": "#ffffff", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} +Apple-specific meta tags in index.html: + + + + + +Android PWA Setup +Enhanced features for Android: + +Notification Channels: Better organization +Rich Notifications: Custom actions and layouts +Background Sync: Offline capability +Update service worker for Android features: + +// In firebase-messaging-sw.js +messaging.onBackgroundMessage((payload) => { + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192x192.png', + badge: '/badge-72x72.png', + tag: 'android-notification', + requireInteraction: true, // Keep notification visible + actions: [ + { + action: 'view', + title: 'View', + icon: '/action-view.png' + }, + { + action: 'dismiss', + title: 'Dismiss', + icon: '/action-dismiss.png' + } + ], + data: { + url: payload.data?.url || '/', + timestamp: Date.now() + } + }; + +self.registration.showNotification( + payload.notification.title, + notificationOptions + ); +}); +Development Testing +1. Local HTTPS Setup: +# Using Vite with HTTPS +npm run dev -- --https + +# Or use mkcert for local certificates +npm install -g mkcert +mkcert -install +mkcert localhost +2. Test Notification Flow +// Test component +function TestNotificationButton() { + const testNotification = async () => { + if (Notification.permission === 'granted') { + new Notification('Test Notification', { + body: 'This is a test notification', + icon: '/icon-192x192.png' + }); + } + }; + + return ( + + ); +} +3. Get FCM Token for Testing +// Add this to your app to copy token for Firebase Console testing +function FCMTokenDisplay() { + const [token, setToken] = useState(''); + + useEffect(() => { + const getToken = async () => { + if (Notification.permission === 'granted') { + const fcmToken = await getFCMToken(); + setToken(fcmToken); + } + }; + getToken(); + }, []); + + const copyToken = () => { + navigator.clipboard.writeText(token); + alert('Token copied to clipboard!'); + }; + + return ( +
+

FCM Token: {token}

+ +
+ ); +} +Testing with Supabase Integration +Test Database-Triggered Notifications +// Component to test Supabase integration +function TestSupabaseNotification() { + const sendViaSupabase = async () => { + try { + // Call your Supabase Edge Function + const { data, error } = await supabase.functions.invoke('send-notification', { + body: { + title: 'Test from Supabase', + body: 'This notification was sent via Supabase Edge Function', + data: { source: 'test' } + } + }); + + if (error) throw error; + console.log('Notification sent via Supabase:', data); + } catch (error) { + console.error('Failed to send notification via Supabase:', error); + } + }; + +return ( + + ); +} +Firebase Console Testing +1. Get FCM Token from Database +-- Query to get FCM tokens for testing +SELECT fcm_token, device_info +FROM user_fcm_tokens +WHERE user_id = 'your-user-id'; +2. Send Test Message via Firebase Console +Firebase Console → Cloud Messaging → Send your first message +→ Notification title and text +→ Target: Single device (paste FCM token from database) +→ Send + +Production Deployment +1. Build and Deploy +npm run build + +/* Deploy to your hosting provider + # For Firebase Hosting: firebase deploy + # For Netlify: netlify deploy --prod + # For Vercel: vercel --prod +2. Test on Real Devices +iOS: Add to home screen, test notifications +Android: Test in browser and as installed PWA +Desktop: Test in Chrome, Firefox, Edge +Firebase Console Testing +1. Send Test Message +Firebase Console → Cloud Messaging → Send your first message + +2. Target Testing +Single device (FCM token) +User segment +Topic subscribers + +Common Issues +1. Notifications Not Appearing +Symptoms: FCM token generated but notifications not received + +Solutions: + +// Check service worker registration +navigator.serviceWorker.getRegistrations().then(registrations => { + console.log('Service Workers:', registrations); +}); + +// Verify notification permission +console.log('Permission:', Notification.permission); +// Check for errors in service worker +navigator.serviceWorker.addEventListener('error', event => { + console.error('SW Error:', event); +}); +2. iOS Notifications Not Working +Symptoms: Works on Android but not iOS + +Solutions: + +Ensure PWA is added to home screen +Check Safari settings for notifications +Verify HTTPS certificate validity +Test with Safari Technology Preview +3. Service Worker Not Updating +Symptoms: Old service worker cached + +Solutions: + +// Force service worker update +if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => { + registration.update(); + }); + }); +} +4. FCM Token Not Generated +Symptoms: getToken() returns null + +Solutions: + +// Debug token generation +import { getToken } from 'firebase/messaging'; + +async function debugTokenGeneration() { + try { + console.log('VAPID Key:', import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY); + console.log('Permission:', Notification.permission); + + const registration = await navigator.serviceWorker.getRegistration(); + console.log('SW Registration:', registration); + + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY, + serviceWorkerRegistration: registration + }); + + console.log('FCM Token:', token); + } catch (error) { + console.error('Token generation failed:', error); + } +} +Performance Optimization +1. Lazy Load Firebase +// Load Firebase only when needed +async function loadFirebase() { + if (!window.firebase) { + const { initializeApp } = await import('firebase/app'); + const { getMessaging } = await import('firebase/messaging'); + + const app = initializeApp(firebaseConfig); + window.firebase = { app, messaging: getMessaging(app) }; + } + + return window.firebase; +} +2. Efficient Token Management +// Cache token in memory +let cachedToken = null; + +export async function getFCMToken() { + if (cachedToken) return cachedToken; + + cachedToken = await getToken(messaging, { + vapidKey: VAPID_KEY + }); + + return cachedToken; +} +Security Best Practices +1. Environment Variables +Never expose sensitive data in client-side code: + +// ❌ Wrong - sensitive data in client +const serviceAccount = { + privateKey: "-----BEGIN PRIVATE KEY-----...", + clientEmail: "firebase-adminsdk@project.iam.gserviceaccount.com" +}; + +// ✅ Correct - only public config in client +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID +}; +2. Token Validation +Client-side token management: + +// Validate token format +function isValidFCMToken(token) { + return token && + typeof token === 'string' && + token.length > 100 && // FCM tokens are typically 160+ characters + !token.includes(' '); // No spaces in valid tokens +} + +// Token refresh handling +async function refreshFCMTokenIfNeeded() { + try { + const storedToken = localStorage.getItem('fcmToken'); + const tokenTimestamp = localStorage.getItem('fcmTokenTimestamp'); + + // Refresh token if older than 24 hours + const twentyFourHours = 24 * 60 * 60 * 1000; + const now = Date.now(); + + if (!storedToken || !tokenTimestamp || + (now - parseInt(tokenTimestamp)) > twentyFourHours) { + + const newToken = await getToken(messaging, { + vapidKey: import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY + }); + + if (newToken) { + localStorage.setItem('fcmToken', newToken); + localStorage.setItem('fcmTokenTimestamp', now.toString()); + } + + return newToken; + } + + return storedToken; + } catch (error) { + console.error('Token refresh failed:', error); + return null; + } +} +3. Rate Limiting +Implement client-side rate limiting: + +class NotificationRateLimiter { + constructor(maxNotifications = 10, timeWindow = 60000) { // 10 notifications per minute + this.maxNotifications = maxNotifications; + this.timeWindow = timeWindow; + this.notifications = []; + } + + canSendNotification() { + const now = Date.now(); + + // Remove old notifications outside time window + this.notifications = this.notifications.filter( + timestamp => (now - timestamp) < this.timeWindow + ); + + // Check if under limit + return this.notifications.length < this.maxNotifications; + } + + recordNotification() { + this.notifications.push(Date.now()); + } +} +const rateLimiter = new NotificationRateLimiter(); + +function sendNotificationWithRateLimit(title, options) { + if (!rateLimiter.canSendNotification()) { + console.warn('Notification rate limit exceeded'); + return false; + } + + if (Notification.permission === 'granted') { + new Notification(title, options); + rateLimiter.recordNotification(); + return true; + } + + return false; +} +4. Content Sanitization +Sanitize notification content: + +function sanitizeNotification(notification) { + return { + title: notification.title?.substring(0, 100) || 'Notification', + body: notification.body?.substring(0, 300) || '', + icon: isValidUrl(notification.icon) ? notification.icon : '/default-icon.png' + }; +} + +function isValidUrl(string) { + try { + new URL(string); + return true; + } catch (_) { + return false; + } +} +Conclusion +By following this approach, you gain a clear, reliable, and production ready push notification system for your PWA. + +You get cross platform support, secure token management, structured debugging, and a foundation that scales from simple manual notifications to fully automated backend workflows. + +Vibe coding is excellent for getting started, but durable systems require understanding. This guide is meant to bridge that gap. + +Remember to: + +Test thoroughly on all target platforms +Monitor user engagement and adjust notification frequency +Respect user preferences and provide easy opt-out options +Keep dependencies updated for security and performance +Follow platform guidelines for notification best practices +For additional resources and detailed documentation, visit: + +Firebase Cloud Messaging Documentation +Web Push Protocol Specification +PWA Notification Best Practices +Service Worker Cookbook +Happy coding! 🚀 + +asking Gemin: + +I have configure an app PWA messaging app to use FCM, the mobile device can send and the desktop browsers always receives. The desktop can send, but it's hit or miss, or just very delayed if the mobile receives the notification. + +Response. + +Since it works on 5G/Wi-Fi for Desktop but is "hit or miss" on Mobile, we are likely dealing with Aggressive Power Management or Message Priority settings.On desktop browsers, the "listening" process for notifications is rarely throttled by the OS. On mobile, however, Android and iOS will both "put to sleep" or delay incoming messages to save battery unless the message is specifically flagged as urgent.Here is why your mobile delivery is delayed or missing:1. The "Priority" Problem (Backend Fix)By default, many FCM libraries send messages with Normal Priority.Normal Priority: On mobile, these are often batched and only delivered during "maintenance windows" (when the user wakes the screen). This explains your "hit or miss" experience.High Priority: These are delivered immediately and can wake the device from "Doze mode" (Android) or sleep (iOS).The Solution: You must explicitly set priority: 'high' in your FCM payload.Important: In 2026, FCM v1 requires specific headers for different platforms. If you send "High" priority to an Apple device via the standard FCM field, it may actually be rejected or ignored. You need to set the apns-priority: 10 header for iOS.2. The "Notification" vs. "Data" MessageNotification Message: Contains notification: { title, body }. These are handled by the OS and show up even if the app is closed.Data Message: Only contains a data: { ... } object. These require your Service Worker to wake up, parse the data, and manually call self.registration.showNotification().The Mobile Trap: Mobile operating systems are much more likely to kill a Service Worker that tries to do too much in the background. If you are sending Data-only messages, the mobile OS might be killing the worker before it can display the alert.3. TTL (Time to Live)If the mobile device has a momentary connection blip, FCM stores the message. If the ttl (Time to Live) is set to 0, FCM will try to deliver it once and then immediately discard it if the device is unreachable.Recommendation: Set a TTL of at least 60 seconds to account for mobile network switching (moving from Wi-Fi to 5G).4. iOS Specific: "Focus Mode" and "Summary"If you are testing on an iPhone, check if Focus Mode (Do Not Disturb) or Scheduled Summary is enabled.In 2026, iOS often moves PWA notifications into the "Non-urgent" summary by default unless the user has interacted with the app frequently. Desktop Chrome/Edge doesn't have this restrictive filtering.Comparison of Delivery LogicFeatureDesktop PWAMobile PWA (Android/iOS)Default PriorityImmediateDelayed (Maintenance Window)Service WorkerStays "Warm"Aggressively "Killed"Doze/Sleep ModeN/ADefers "Normal" messagesReliabilityHighMedium (Requires priority: high) \ No newline at end of file diff --git a/fcm-app/nginx.conf b/fcm-app/nginx.conf new file mode 100644 index 0000000..f24420f --- /dev/null +++ b/fcm-app/nginx.conf @@ -0,0 +1,57 @@ +events { + worker_connections 1024; +} + +http { + upstream app { + server fcm-test-app:3000; + } + + # HTTP to HTTPS redirect + server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; + } + + # HTTPS server + server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # PWA headers + add_header Service-Worker-Allowed "/"; + + location / { + proxy_pass http://app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Serve static files directly + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|json|webmanifest)$ { + proxy_pass http://app; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} diff --git a/fcm-app/package.json b/fcm-app/package.json new file mode 100644 index 0000000..c7c8cc5 --- /dev/null +++ b/fcm-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "fcm-test-pwa", + "version": "1.0.0", + "description": "PWA for testing Firebase Cloud Messaging", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "firebase": "^10.7.1", + "firebase-admin": "^12.0.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + }, + "keywords": ["pwa", "fcm", "firebase", "notifications"], + "author": "", + "license": "MIT" +} diff --git a/fcm-app/public/app.js b/fcm-app/public/app.js new file mode 100644 index 0000000..633d736 --- /dev/null +++ b/fcm-app/public/app.js @@ -0,0 +1,334 @@ +// Load Firebase SDK immediately +const script1 = document.createElement('script'); +script1.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js'; +script1.onload = () => { + const script2 = document.createElement('script'); + script2.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'; + script2.onload = () => { + // Initialize Firebase immediately + initializeFirebase(); + console.log('Firebase SDK and initialization complete'); + + // Now that Firebase is ready, set up the app + setupApp(); + }; + document.head.appendChild(script2); +}; +document.head.appendChild(script1); + +// Global variables +let currentUser = null; +let fcmToken = null; +let messaging = null; +let swRegistration = null; +let initPromise = null; +let foregroundHandlerSetup = false; + +const VAPID_KEY = 'BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw'; + +// Simple user authentication +const users = { + 'pwau1': { password: 'test123', name: 'Desktop User' }, + 'pwau2': { password: 'test123', name: 'Mobile User 1' }, + 'pwau3': { password: 'test123', name: 'Mobile User 2' } +}; + +// Initialize Firebase — returns a promise that resolves when messaging is ready +function initializeFirebase() { + if (initPromise) return initPromise; + + const firebaseConfig = { + apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA", + authDomain: "fcmtest-push.firebaseapp.com", + projectId: "fcmtest-push", + storageBucket: "fcmtest-push.firebasestorage.app", + messagingSenderId: "439263996034", + appId: "1:439263996034:web:9b3d52af2c402e65fdec9b" + }; + + if (firebase.apps.length === 0) { + firebase.initializeApp(firebaseConfig); + console.log('Firebase app initialized'); + } + + initPromise = navigator.serviceWorker.register('/sw.js') + .then((registration) => { + console.log('Service Worker registered:', registration); + swRegistration = registration; + messaging = firebase.messaging(); + console.log('Firebase messaging initialized successfully'); + }) + .catch((error) => { + console.error('Service Worker registration failed:', error); + initPromise = null; + throw error; + }); + + return initPromise; +} + +// Show user info panel and hide login form +function showUserInfo() { + document.getElementById('loginForm').style.display = 'none'; + document.getElementById('userInfo').style.display = 'block'; + document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser; +} + +// Setup app after Firebase is ready +function setupApp() { + // Set up event listeners + document.getElementById('loginForm').addEventListener('submit', login); + document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification); + document.getElementById('logoutBtn').addEventListener('click', logout); + + // Restore session and re-register FCM token if notifications were already granted + const savedUser = localStorage.getItem('currentUser'); + if (savedUser) { + currentUser = savedUser; + showUserInfo(); + if (Notification.permission === 'granted') { + initializeFirebase() + .then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration })) + .then(token => { if (token) return registerToken(currentUser, token); }) + .catch(err => console.error('Token refresh on session restore failed:', err)); + } + } +} + +// Request notification permission and get FCM token +async function requestNotificationPermission() { + try { + console.log('Requesting notification permission...'); + const permission = await Notification.requestPermission(); + console.log('Permission result:', permission); + + if (permission === 'granted') { + console.log('Notification permission granted.'); + showStatus('Getting FCM token...', 'info'); + + try { + const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }); + console.log('FCM Token generated:', token); + + if (!token) { + throw new Error('getToken() returned empty — check VAPID key and service worker'); + } + + fcmToken = token; + + // Send token to server + await registerToken(currentUser, token); + showStatus('Notifications enabled successfully!', 'success'); + } catch (tokenError) { + console.error('Error getting FCM token:', tokenError); + showStatus('Failed to get FCM token: ' + tokenError.message, 'error'); + } + } else { + console.log('Notification permission denied.'); + showStatus('Notification permission denied.', 'error'); + } + } catch (error) { + console.error('Error requesting notification permission:', error); + showStatus('Failed to enable notifications: ' + error.message, 'error'); + } +} + +// Register FCM token with server +async function registerToken(username, token) { + try { + console.log('Attempting to register token:', { username, token: token.substring(0, 20) + '...' }); + + const response = await fetch('/register-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, token }) + }); + + console.log('Registration response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server returned ${response.status}: ${errorText}`); + } + + const result = await response.json(); + console.log('Token registered successfully:', result); + showStatus(`Token registered for ${username}`, 'success'); + } catch (error) { + console.error('Error registering token:', error); + showStatus('Failed to register token with server: ' + error.message, 'error'); + } +} + +// Handle foreground messages (guard against duplicate registration) +function handleForegroundMessages() { + if (foregroundHandlerSetup) return; + foregroundHandlerSetup = true; + messaging.onMessage(function(payload) { + console.log('Received foreground message: ', payload); + + // Show notification in foreground + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192.png', + badge: '/icon-192.png' + }; + + new Notification(notificationTitle, notificationOptions); + showStatus(`New notification: ${payload.notification.body}`, 'info'); + }); +} + +// Login function +async function login(event) { + if (event) event.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + if (!users[username] || users[username].password !== password) { + showStatus('Invalid username or password', 'error'); + return; + } + + currentUser = username; + localStorage.setItem('currentUser', username); + showUserInfo(); + + showStatus(`Logged in as ${users[username].name}`, 'success'); + + // Initialize Firebase and request notifications + if (typeof firebase !== 'undefined') { + await initializeFirebase(); + await requestNotificationPermission(); + handleForegroundMessages(); + } else { + showStatus('Firebase not loaded. Please check your connection.', 'error'); + } +} + +// Logout function +function logout() { + currentUser = null; + fcmToken = null; + localStorage.removeItem('currentUser'); + document.getElementById('loginForm').style.display = 'block'; + document.getElementById('userInfo').style.display = 'none'; + document.getElementById('username').value = ''; + document.getElementById('password').value = ''; + showStatus('Logged out successfully.', 'info'); +} + +// Send notification function +async function sendNotification() { + if (!currentUser) { + showStatus('Please login first.', 'error'); + return; + } + + try { + // First check registered users + const usersResponse = await fetch('/users'); + const users = await usersResponse.json(); + console.log('Registered users:', users); + + const response = await fetch('/send-notification', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fromUser: currentUser, + title: 'Test Notification', + body: `Notification sent from ${currentUser} at ${new Date().toLocaleTimeString()}` + }) + }); + + if (!response.ok) { + throw new Error('Failed to send notification'); + } + + const result = await response.json(); + console.log('Send result:', result); + + if (result.recipients === 0) { + showStatus('No other users have registered tokens. Open the app on other devices and enable notifications.', 'error'); + } else { + showStatus(`Notification sent to ${result.recipients} user(s)!`, 'success'); + } + } catch (error) { + console.error('Error sending notification:', error); + showStatus('Failed to send notification.', 'error'); + } +} + +// Show status message +function showStatus(message, type) { + const statusEl = document.getElementById('status'); + statusEl.textContent = message; + statusEl.className = `status ${type}`; + statusEl.style.display = 'block'; + + setTimeout(() => { + statusEl.style.display = 'none'; + }, 5000); +} + +// Register service worker and handle PWA installation +if ('serviceWorker' in navigator) { + window.addEventListener('load', function() { + navigator.serviceWorker.register('/sw.js') + .then(function(registration) { + console.log('ServiceWorker registration successful with scope: ', registration.scope); + + // Handle PWA installation + let deferredPrompt; + + window.addEventListener('beforeinstallprompt', (e) => { + console.log('beforeinstallprompt fired'); + e.preventDefault(); + deferredPrompt = e; + + // Show install button or banner + showInstallButton(); + }); + + function showInstallButton() { + const installBtn = document.createElement('button'); + installBtn.textContent = 'Install App'; + installBtn.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: #2196F3; + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + cursor: pointer; + z-index: 1000; + font-size: 14px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + `; + + installBtn.addEventListener('click', async () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + console.log(`User response to the install prompt: ${outcome}`); + deferredPrompt = null; + installBtn.remove(); + } + }); + + document.body.appendChild(installBtn); + } + }) + .catch(function(error) { + console.log('ServiceWorker registration failed: ', error); + }); + }); +} diff --git a/fcm-app/public/firebase-messaging-sw.js b/fcm-app/public/firebase-messaging-sw.js new file mode 100644 index 0000000..7010465 --- /dev/null +++ b/fcm-app/public/firebase-messaging-sw.js @@ -0,0 +1,48 @@ +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'); + +firebase.initializeApp({ + apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA", + authDomain: "fcmtest-push.firebaseapp.com", + projectId: "fcmtest-push", + storageBucket: "fcmtest-push.firebasestorage.app", + messagingSenderId: "439263996034", + appId: "1:439263996034:web:9b3d52af2c402e65fdec9b" +}); + +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage(function(payload) { + console.log('Received background message:', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'fcm-test', + data: payload.data + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); + +self.addEventListener('notificationclick', function(event) { + console.log('Notification clicked:', event); + event.notification.close(); + + if (event.action === 'close') return; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); diff --git a/fcm-app/public/icon-192.png b/fcm-app/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9ce297bead2bd1a57375126f405ebff14983d6bd GIT binary patch literal 14690 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 z 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 + + + + + FCM Test PWA + + + + + +
+

FCM Test PWA

+ + + + + + +
+ + + + diff --git a/fcm-app/public/manifest.json b/fcm-app/public/manifest.json new file mode 100644 index 0000000..6441c4a --- /dev/null +++ b/fcm-app/public/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "FCM Test PWA", + "short_name": "FCM Test", + "description": "PWA for testing Firebase Cloud Messaging", + "start_url": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "background_color": "#ffffff", + "theme_color": "#2196F3", + "orientation": "portrait-primary", + "scope": "/", + "icons": [ + { + "purpose": "any maskable", + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "purpose": "any maskable", + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "categories": ["utilities", "productivity"], + "lang": "en-US" +} diff --git a/fcm-app/public/sw.js b/fcm-app/public/sw.js new file mode 100644 index 0000000..ce1a5d5 --- /dev/null +++ b/fcm-app/public/sw.js @@ -0,0 +1,82 @@ +const CACHE_NAME = 'fcm-test-pwa-v1'; +const urlsToCache = [ + '/', + '/index.html', + '/app.js', + '/manifest.json' +]; + +// Install event +self.addEventListener('install', function(event) { + event.waitUntil( + caches.open(CACHE_NAME) + .then(function(cache) { + return cache.addAll(urlsToCache); + }) + ); +}); + +// Fetch event +self.addEventListener('fetch', function(event) { + event.respondWith( + caches.match(event.request) + .then(function(response) { + if (response) { + return response; + } + return fetch(event.request); + }) + ); +}); + +// Background sync for FCM +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'); + +// Initialize Firebase in service worker +firebase.initializeApp({ + apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA", + authDomain: "fcmtest-push.firebaseapp.com", + projectId: "fcmtest-push", + storageBucket: "fcmtest-push.firebasestorage.app", + messagingSenderId: "439263996034", + appId: "1:439263996034:web:9b3d52af2c402e65fdec9b" +}); + +const messaging = firebase.messaging(); + +// Handle notification clicks +self.addEventListener('notificationclick', function(event) { + console.log('Notification clicked:', event); + event.notification.close(); + + if (event.action === 'close') return; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); + +// Handle background messages +messaging.onBackgroundMessage(function(payload) { + console.log('Received background message ', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'fcm-test' + }; + + return self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/fcm-app/server.js b/fcm-app/server.js new file mode 100644 index 0000000..bf11348 --- /dev/null +++ b/fcm-app/server.js @@ -0,0 +1,244 @@ +require('dotenv').config(); +const express = require('express'); +const path = require('path'); +const cors = require('cors'); +const admin = require('firebase-admin'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); +app.use(express.static('public')); + +// In-memory storage for FCM tokens (in production, use a database) +const userTokens = new Map(); + +// Load tokens from file on startup (for persistence) +const fs = require('fs'); +const TOKENS_FILE = './data/tokens.json'; + +function loadTokens() { + try { + if (fs.existsSync(TOKENS_FILE)) { + const data = fs.readFileSync(TOKENS_FILE, 'utf8'); + const tokens = JSON.parse(data); + for (const [user, tokenArray] of Object.entries(tokens)) { + userTokens.set(user, new Set(tokenArray)); + } + console.log(`Loaded tokens for ${userTokens.size} users from file`); + } + } catch (error) { + console.log('No existing tokens file found, starting fresh'); + } +} + +function saveTokens() { + const tokens = {}; + for (const [user, tokenSet] of userTokens.entries()) { + tokens[user] = Array.from(tokenSet); + } + fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2)); +} + +// Load existing tokens on startup +loadTokens(); + +// Auto-save tokens every 30 seconds +setInterval(() => { + try { + saveTokens(); + } catch (error) { + console.error('Auto-save tokens failed:', error); + } +}, 30000); + +// Initialize Firebase Admin +if (process.env.FIREBASE_PRIVATE_KEY) { + const serviceAccount = { + projectId: process.env.FIREBASE_PROJECT_ID, + privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID, + privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + clientId: process.env.FIREBASE_CLIENT_ID, + authUri: process.env.FIREBASE_AUTH_URI, + tokenUri: process.env.FIREBASE_TOKEN_URI, + authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL, + clientC509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL + }; + + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount) + }); + + console.log('Firebase Admin initialized successfully'); +} else { + console.log('Firebase Admin not configured. Please set up .env file'); +} + +// Routes +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Register FCM token +app.post('/register-token', (req, res) => { + const { username, token } = req.body; + + console.log(`Token registration request:`, { username, token: token?.substring(0, 20) + '...' }); + + if (!username || !token) { + console.log('Token registration failed: missing username or token'); + return res.status(400).json({ error: 'Username and token are required' }); + } + + // Store token for user + if (!userTokens.has(username)) { + userTokens.set(username, new Set()); + } + + const userTokenSet = userTokens.get(username); + if (userTokenSet.has(token)) { + console.log(`Token already registered for user: ${username}`); + } else { + userTokenSet.add(token); + console.log(`New token registered for user: ${username}`); + // Save immediately after new registration + try { + saveTokens(); + } catch (saveError) { + console.error('Failed to persist tokens to disk:', saveError); + } + } + + console.log(`Total tokens for ${username}: ${userTokenSet.size}`); + console.log(`Total registered users: ${userTokens.size}`); + + res.json({ success: true, message: 'Token registered successfully' }); +}); + +// Send notification to all other users +app.post('/send-notification', async (req, res) => { + const { fromUser, title, body } = req.body; + + if (!fromUser || !title || !body) { + return res.status(400).json({ error: 'fromUser, title, and body are required' }); + } + + if (!admin.apps.length) { + return res.status(500).json({ error: 'Firebase Admin not initialized' }); + } + + try { + let totalRecipients = 0; + const promises = []; + + // Send to all users except the sender + for (const [username, tokens] of userTokens.entries()) { + if (username === fromUser) continue; // Skip sender + + for (const token of tokens) { + const message = { + token: token, + notification: { + title: title, + body: body + }, + webpush: { + headers: { + 'Urgency': 'high' + }, + notification: { + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'fcm-test' + }, + fcm_options: { + link: '/' + } + }, + android: { + priority: 'high', + notification: { + sound: 'default', + click_action: '/' + } + }, + apns: { + payload: { + aps: { + sound: 'default', + badge: 1 + } + } + } + }; + + promises.push( + admin.messaging().send(message) + .then(() => { + console.log(`Notification sent to ${username} successfully`); + totalRecipients++; + }) + .catch((error) => { + console.error(`Error sending notification to ${username}:`, error); + // Remove invalid token + if (error.code === 'messaging/registration-token-not-registered') { + tokens.delete(token); + } + }) + ); + } + } + + await Promise.all(promises); + + res.json({ + success: true, + recipients: totalRecipients, + message: `Notification sent to ${totalRecipients} recipient(s)` + }); + + } catch (error) { + console.error('Error sending notifications:', error); + res.status(500).json({ error: 'Failed to send notifications' }); + } +}); + +// Get all registered users (for debugging) +app.get('/users', (req, res) => { + const users = {}; + console.log('Current userTokens map:', userTokens); + console.log('Number of registered users:', userTokens.size); + + for (const [username, tokens] of userTokens.entries()) { + users[username] = { + tokenCount: tokens.size, + tokens: Array.from(tokens) + }; + } + res.json(users); +}); + +// Debug endpoint to check server status +app.get('/debug', (req, res) => { + res.json({ + firebaseAdminInitialized: admin.apps.length > 0, + registeredUsers: userTokens.size, + userTokens: Object.fromEntries( + Array.from(userTokens.entries()).map(([user, tokens]) => [user, { + count: tokens.size, + tokens: Array.from(tokens) + }]) + ), + timestamp: new Date().toISOString() + }); +}); + +// Start server +app.listen(PORT, '0.0.0.0', () => { + console.log(`FCM Test PWA server running on port ${PORT}`); + console.log(`Open http://localhost:${PORT} in your browser`); + console.log(`Server listening on all interfaces (0.0.0.0:${PORT})`); +}); diff --git a/frontend/package.json b/frontend/package.json index 5d79f58..060141c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.7", + "version": "0.12.8", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 9ebbe2f..7419d30 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -76,10 +76,21 @@ function showRosterChirpNotification(data) { } // ── FCM background messages ─────────────────────────────────────────────────── +// Server sends notification+data messages (mirrors the working fcm-app pattern). +// payload.notification carries title/body; payload.data carries url/groupId. +// Fallback to payload.data.title/body supports any older data-only messages still +// in-flight during a deployment transition. if (messaging) { messaging.onBackgroundMessage((payload) => { - console.log('[SW] onBackgroundMessage received, data:', JSON.stringify(payload.data)); - return showRosterChirpNotification(payload.data || {}); + console.log('[SW] onBackgroundMessage received:', JSON.stringify({ notification: payload.notification, data: payload.data })); + const n = payload.notification || {}; + const d = payload.data || {}; + return showRosterChirpNotification({ + title: n.title || d.title || 'New Message', + body: n.body || d.body || '', + url: d.url || '/', + groupId: d.groupId || '', + }); }); } else { console.warn('[SW] Firebase messaging not initialised — push notifications disabled'); diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index f9b696f..7206923 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -193,6 +193,126 @@ function RegistrationTab({ onFeaturesChanged }) { ); } +// ── Push Debug Tab ──────────────────────────────────────────────────────────── +function DebugRow({ label, value, ok, bad }) { + const color = ok ? 'var(--success)' : bad ? 'var(--error)' : 'var(--text-secondary)'; + return ( +
+ {label} + {value} +
+ ); +} + +function PushDebugTab() { + const toast = useToast(); + const [debugData, setDebugData] = useState(null); + const [loading, setLoading] = useState(true); + const [testing, setTesting] = useState(false); + + const permission = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported'; + const cachedToken = localStorage.getItem('rc_fcm_token'); + + const load = async () => { + setLoading(true); + try { + const data = await api.pushDebug(); + setDebugData(data); + } catch (e) { + toast(e.message || 'Failed to load debug data', 'error'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const doTest = async (mode) => { + setTesting(true); + try { + const result = await api.testPush(mode); + const sent = result.results?.find(r => r.status === 'sent'); + const failed = result.results?.find(r => r.status === 'failed'); + if (sent) toast(`Test sent (mode=${mode}) — check device for notification`, 'success'); + else if (failed) toast(`Test failed: ${failed.error}`, 'error'); + else toast('No subscription found — grant permission and reload', 'error'); + } catch (e) { + toast(e.message || 'Test failed', 'error'); + } finally { + setTesting(false); + } + }; + + const clearToken = () => { + localStorage.removeItem('rc_fcm_token'); + toast('Cached token cleared — reload to re-register with server', 'info'); + }; + + const box = { background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 14px', marginBottom: 14 }; + const sectionLabel = { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 8 }; + + return ( +
+
Push Notification Debug
+ + {/* This device */} +
+
This Device
+
+ + + {debugData && } + {debugData && } +
+ +
+ + {/* Test push */} +
+
Send Test Notification to This Device
+

+ notification — same path as real messages (SW onBackgroundMessage)
+ browser — Chrome shows it directly, bypasses the SW (confirm delivery works) +

+
+ + +
+
+ + {/* Registered devices */} +
+
Registered Devices
+ +
+ + {loading ? ( +

Loading…

+ ) : !debugData?.subscriptions?.length ? ( +

No FCM tokens registered.

+ ) : ( +
+ {debugData.subscriptions.map(sub => ( +
+
+ {sub.name || sub.email} + {sub.device} +
+ + {sub.fcm_token} + +
+ ))} +
+ )} +
+ ); +} + // ── Web Push Tab ────────────────────────────────────────────────────────────── function WebPushTab() { const toast = useToast(); @@ -287,6 +407,7 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) { isTeam && { id: 'team', label: 'Team Management' }, { id: 'registration', label: 'Registration' }, { id: 'webpush', label: 'Web Push' }, + { id: 'pushdebug', label: 'Push Debug' }, ].filter(Boolean); return ( @@ -311,6 +432,7 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) { {tab === 'team' && } {tab === 'registration' && } {tab === 'webpush' && } + {tab === 'pushdebug' && } ); diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5329f63..cd99f83 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -170,7 +170,8 @@ export const api = { getFirebaseConfig: () => req('GET', '/push/firebase-config'), subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }), unsubscribePush: () => req('POST', '/push/unsubscribe'), - testPush: (mode = 'data') => req('POST', `/push/test?mode=${mode}`), + testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`), + pushDebug: () => req('GET', '/push/debug'), // Link preview getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),