v0.12.8 FCM bug fix
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.7",
|
||||
"version": "0.12.8",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
15
fcm-app/.dockerignore
Normal file
15
fcm-app/.dockerignore
Normal file
@@ -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
|
||||
18
fcm-app/.env
Normal file
18
fcm-app/.env
Normal file
@@ -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
|
||||
15
fcm-app/.env.example
Normal file
15
fcm-app/.env.example
Normal file
@@ -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
|
||||
33
fcm-app/Dockerfile
Normal file
33
fcm-app/Dockerfile
Normal file
@@ -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"]
|
||||
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal file
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -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
|
||||
209
fcm-app/README.md
Normal file
209
fcm-app/README.md
Normal file
@@ -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 <your-repo>
|
||||
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
|
||||
22
fcm-app/docker-compose.yml
Normal file
22
fcm-app/docker-compose.yml
Normal file
@@ -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:
|
||||
1013
fcm-app/fcm_details.txt
Normal file
1013
fcm-app/fcm_details.txt
Normal file
File diff suppressed because it is too large
Load Diff
57
fcm-app/nginx.conf
Normal file
57
fcm-app/nginx.conf
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
23
fcm-app/package.json
Normal file
23
fcm-app/package.json
Normal file
@@ -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"
|
||||
}
|
||||
334
fcm-app/public/app.js
Normal file
334
fcm-app/public/app.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
48
fcm-app/public/firebase-messaging-sw.js
Normal file
48
fcm-app/public/firebase-messaging-sw.js
Normal file
@@ -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('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
BIN
fcm-app/public/icon-192.png
Normal file
BIN
fcm-app/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
fcm-app/public/icon-512.png
Normal file
BIN
fcm-app/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
111
fcm-app/public/index.html
Normal file
111
fcm-app/public/index.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FCM Test PWA</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2196F3">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.login-form {
|
||||
display: block;
|
||||
}
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.user-display {
|
||||
background-color: #e3f2fd;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>FCM Test PWA</h1>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
|
||||
<div id="loginForm" class="login-form">
|
||||
<h2>Login</h2>
|
||||
<input type="text" id="username" placeholder="Username (pwau1, pwau2, or pwau3)" required>
|
||||
<input type="password" id="password" placeholder="Password" required>
|
||||
<button onclick="login()">Login</button>
|
||||
</div>
|
||||
|
||||
<div id="userInfo" class="user-info">
|
||||
<div class="user-display">
|
||||
Logged in as: <span id="currentUser"></span>
|
||||
</div>
|
||||
<button id="sendNotificationBtn" onclick="sendNotification()">Send Notification</button>
|
||||
<button id="logoutBtn" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
fcm-app/public/manifest.json
Normal file
28
fcm-app/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
82
fcm-app/public/sw.js
Normal file
82
fcm-app/public/sw.js
Normal file
@@ -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);
|
||||
});
|
||||
244
fcm-app/server.js
Normal file
244
fcm-app/server.js
Normal file
@@ -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})`);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.7",
|
||||
"version": "0.12.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{label}</span>
|
||||
<span style={{ color, fontFamily: 'monospace', fontSize: 12 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="settings-section-label" style={{ marginBottom: 14 }}>Push Notification Debug</div>
|
||||
|
||||
{/* This device */}
|
||||
<div style={box}>
|
||||
<div style={sectionLabel}>This Device</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||||
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
|
||||
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />
|
||||
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
|
||||
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
|
||||
</div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button>
|
||||
</div>
|
||||
|
||||
{/* Test push */}
|
||||
<div style={box}>
|
||||
<div style={sectionLabel}>Send Test Notification to This Device</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
<strong>notification</strong> — same path as real messages (SW <code>onBackgroundMessage</code>)<br/>
|
||||
<strong>browser</strong> — Chrome shows it directly, bypasses the SW (confirm delivery works)
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => doTest('notification')} disabled={testing}>
|
||||
{testing ? 'Sending…' : 'Test (notification)'}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => doTest('browser')} disabled={testing}>
|
||||
{testing ? 'Sending…' : 'Test (browser)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registered devices */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Loading…</p>
|
||||
) : !debugData?.subscriptions?.length ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>No FCM tokens registered.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{debugData.subscriptions.map(sub => (
|
||||
<div key={sub.id} style={box}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{sub.name || sub.email}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', background: 'var(--surface)', padding: '2px 7px', borderRadius: 4, border: '1px solid var(--border)' }}>{sub.device}</span>
|
||||
</div>
|
||||
<code style={{ fontSize: 10, color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.6, display: 'block' }}>
|
||||
{sub.fcm_token}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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' && <TeamManagementTab />}
|
||||
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
||||
{tab === 'webpush' && <WebPushTab />}
|
||||
{tab === 'pushdebug' && <PushDebugTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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)}`),
|
||||
|
||||
Reference in New Issue
Block a user