From d6a37d59489efa3b4d8d7167e47b0c332cdb4178 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 26 Mar 2026 14:49:17 -0400 Subject: [PATCH] v0.12.30 add notifications for iOS --- backend/package.json | 5 +- backend/src/index.js | 4 +- backend/src/models/migrations/011_webpush.sql | 9 + backend/src/routes/messages.js | 4 +- backend/src/routes/push.js | 307 ++++++++++++------ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ProfileModal.jsx | 62 ++-- frontend/src/components/UserFooter.jsx | 49 +++ frontend/src/pages/Chat.jsx | 89 ++++- frontend/src/utils/api.js | 2 + 11 files changed, 386 insertions(+), 149 deletions(-) create mode 100644 backend/src/models/migrations/011_webpush.sql diff --git a/backend/package.json b/backend/package.json index 6c42091..5614aef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.29", + "version": "0.12.30", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { @@ -20,7 +20,8 @@ "sharp": "^0.33.2", "socket.io": "^4.6.1", "csv-parse": "^5.5.6", - "pg": "^8.11.3" + "pg": "^8.11.3", + "web-push": "^3.6.7" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/src/index.js b/backend/src/index.js index 9da3a99..d820876 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -253,10 +253,10 @@ io.on('connection', async (socket) => { }).catch(() => {}); } } else if (group.type === 'public') { - // Push to all users who have registered an FCM token — everyone is implicitly + // Push to all users who have a push subscription — everyone is implicitly // a member of every public group. Skip the sender. const subUsers = await query(schema, - 'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', + 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1', [userId] ); for (const sub of subUsers) { diff --git a/backend/src/models/migrations/011_webpush.sql b/backend/src/models/migrations/011_webpush.sql new file mode 100644 index 0000000..8d6a032 --- /dev/null +++ b/backend/src/models/migrations/011_webpush.sql @@ -0,0 +1,9 @@ +-- Migration 011: Add Web Push (VAPID) subscription columns for iOS PWA support +-- iOS uses the standard W3C Web Push protocol (not FCM). A subscription consists of +-- an endpoint URL (web.push.apple.com) plus two crypto keys (p256dh + auth). +-- Rows will have either fcm_token set (Android/Chrome) OR the three webpush_* columns +-- set (iOS/Firefox/Edge). Never both on the same row. +ALTER TABLE push_subscriptions + ADD COLUMN IF NOT EXISTS webpush_endpoint TEXT, + ADD COLUMN IF NOT EXISTS webpush_p256dh TEXT, + ADD COLUMN IF NOT EXISTS webpush_auth TEXT; diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index 1c01435..c075dac 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -118,7 +118,7 @@ module.exports = function(io) { } } else if (group.type === 'public') { const subUsers = await query(req.schema, - 'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', + 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1', [req.user.id] ); for (const sub of subUsers) { @@ -166,7 +166,7 @@ module.exports = function(io) { } } else if (group.type === 'public') { const subUsers = await query(req.schema, - 'SELECT DISTINCT user_id FROM push_subscriptions WHERE fcm_token IS NOT NULL AND user_id != $1', + 'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1', [req.user.id] ); for (const sub of subUsers) { diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js index fb72911..6cde8b9 100644 --- a/backend/src/routes/push.js +++ b/backend/src/routes/push.js @@ -3,7 +3,7 @@ const router = express.Router(); const { query, queryOne, exec } = require('../models/db'); const { authMiddleware } = require('../middleware/auth'); -// ── Firebase Admin ───────────────────────────────────────────────────────────── +// ── Firebase Admin (FCM — Android/Chrome) ────────────────────────────────────── let firebaseAdmin = null; let firebaseApp = null; @@ -25,65 +25,118 @@ function getMessaging() { } } +// ── web-push (VAPID — iOS/Firefox/Edge) ──────────────────────────────────────── +let webPushReady = false; + +function getWebPush() { + if (webPushReady) return require('web-push'); + const pub = process.env.VAPID_PUBLIC; + const priv = process.env.VAPID_PRIVATE; + if (!pub || !priv) return null; + try { + const wp = require('web-push'); + // Subject must be mailto: or https:// — Apple returns 403 for any other format. + const subject = process.env.VAPID_SUBJECT || 'mailto:push@rosterchirp.app'; + wp.setVapidDetails(subject, pub, priv); + webPushReady = true; + console.log('[Push] web-push (VAPID) initialised'); + return wp; + } catch (e) { + console.error('[Push] web-push init failed:', e.message); + return null; + } +} + // ── Helpers ──────────────────────────────────────────────────────────────────── -// Called from index.js socket push notifications +// Called from messages.js (REST) and index.js (socket) for every outbound push. +// Dispatches to FCM (fcm_token rows) or web-push (webpush_endpoint rows) based on +// which columns are populated. Both paths run concurrently for a given user. async function sendPushToUser(schema, userId, payload) { - const messaging = getMessaging(); - if (!messaging) return; try { const subs = await query(schema, - 'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL', + `SELECT * FROM push_subscriptions + WHERE user_id = $1 + AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`, [userId] ); if (subs.length === 0) { - console.log(`[Push] No FCM token for user ${userId} (schema=${schema})`); + console.log(`[Push] No subscription for user ${userId} (schema=${schema})`); return; } + + const messaging = getMessaging(); + const wp = getWebPush(); + for (const sub of subs) { - 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: { - url: payload.url || '/', - groupId: payload.groupId ? String(payload.groupId) : '', - }, - android: { - priority: 'high', - notification: { sound: 'default' }, - }, - apns: { - headers: { 'apns-priority': '10' }, - payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } }, - }, - webpush: { - headers: { Urgency: 'high' }, + if (sub.fcm_token) { + // ── FCM path ────────────────────────────────────────────────────────── + if (!messaging) continue; + try { + await messaging.send({ + token: sub.fcm_token, notification: { - icon: '/icons/icon-192.png', - badge: '/icons/icon-192-maskable.png', - tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message', - renotify: true, + title: payload.title || 'New Message', + body: payload.body || '', }, - fcm_options: { link: payload.url || '/' }, - }, + data: { + url: payload.url || '/', + groupId: payload.groupId ? String(payload.groupId) : '', + }, + android: { + priority: 'high', + notification: { sound: 'default' }, + }, + apns: { + headers: { 'apns-priority': '10' }, + 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 || '/' }, + }, + }); + console.log(`[Push] FCM sent to user ${userId} device=${sub.device} schema=${schema}`); + } catch (err) { + const stale = [ + 'messaging/registration-token-not-registered', + 'messaging/invalid-registration-token', + 'messaging/invalid-argument', + ]; + if (stale.includes(err.code)) { + await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]); + console.log(`[Push] Removed stale FCM token for user ${userId} device=${sub.device}`); + } + } + } else if (sub.webpush_endpoint) { + // ── Web Push / VAPID path (iOS, Firefox, Edge) ──────────────────────── + if (!wp) continue; + const subscription = { + endpoint: sub.webpush_endpoint, + keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth }, + }; + const body = JSON.stringify({ + title: payload.title || 'New Message', + body: payload.body || '', + url: payload.url || '/', + groupId: payload.groupId ? String(payload.groupId) : '', + icon: '/icons/icon-192.png', }); - console.log(`[Push] Sent to user ${userId} device=${sub.device} schema=${schema}`); - } catch (err) { - // Remove stale tokens - const stale = [ - 'messaging/registration-token-not-registered', - 'messaging/invalid-registration-token', - 'messaging/invalid-argument', - ]; - if (stale.includes(err.code)) { - await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]); + try { + await wp.sendNotification(subscription, body, { TTL: 86400, urgency: 'high' }); + console.log(`[Push] WebPush sent to user ${userId} device=${sub.device} schema=${schema}`); + } catch (err) { + // 404/410 = subscription expired or user unsubscribed — remove the stale row + if (err.statusCode === 404 || err.statusCode === 410) { + await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]); + console.log(`[Push] Removed stale WebPush sub for user ${userId} device=${sub.device}`); + } } } } @@ -108,7 +161,14 @@ router.get('/firebase-config', (req, res) => { res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey }); }); -// Register / refresh an FCM token for the logged-in user +// Public — iOS frontend fetches this to create a PushManager subscription +router.get('/vapid-public-key', (req, res) => { + const pub = process.env.VAPID_PUBLIC; + if (!pub) return res.status(503).json({ error: 'VAPID not configured' }); + res.json({ vapidPublicKey: pub }); +}); + +// Register / refresh an FCM token for the logged-in user (Android/Chrome) router.post('/subscribe', authMiddleware, async (req, res) => { const { fcmToken } = req.body; if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' }); @@ -126,7 +186,29 @@ router.post('/subscribe', authMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// Remove the FCM token for the logged-in user / device +// Register / refresh a Web Push subscription for the logged-in user (iOS/Firefox/Edge) +// Body: { endpoint, keys: { p256dh, auth } } — the PushSubscription JSON from the browser +router.post('/subscribe-webpush', authMiddleware, async (req, res) => { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ error: 'endpoint and keys.p256dh/auth required' }); + } + try { + const device = req.device || 'mobile'; // iOS is always mobile + await exec(req.schema, + 'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2', + [req.user.id, device] + ); + await exec(req.schema, + `INSERT INTO push_subscriptions (user_id, device, webpush_endpoint, webpush_p256dh, webpush_auth) + VALUES ($1, $2, $3, $4, $5)`, + [req.user.id, device, endpoint, keys.p256dh, keys.auth] + ); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Remove the push subscription for the logged-in user / device router.post('/unsubscribe', authMiddleware, async (req, res) => { try { const device = req.device || 'desktop'; @@ -138,96 +220,105 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// Send a test push to the requesting user's own device — for diagnosing FCM setup. -// 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. +// Send a test push to the requesting user's own devices. +// Covers both FCM tokens and Web Push subscriptions in one call. +// mode query param only applies to FCM test messages (notification vs browser). router.post('/test', authMiddleware, async (req, res) => { try { const subs = await query(req.schema, - 'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL', + `SELECT * FROM push_subscriptions + WHERE user_id = $1 + AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`, [req.user.id] ); if (subs.length === 0) { return res.status(404).json({ - error: 'No push subscription found for your account. Grant notification permission and reload the app first.', + error: 'No push subscription found. Grant notification permission and reload the app first.', }); } const messaging = getMessaging(); - if (!messaging) { - return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' }); - } + const wp = getWebPush(); + const mode = req.query.mode === 'browser' ? 'browser' : 'notification'; + const results = []; - 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', - notification: { sound: 'default' }, - }, - apns: { - headers: { 'apns-priority': '10' }, - 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', - }, - }, - }; - - 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)'; - message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.'; - message.webpush.fcm_options = { link: '/' }; - } else { - // 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: '/' }; + if (sub.fcm_token) { + if (!messaging) { + results.push({ device: sub.device, type: 'fcm', status: 'failed', error: 'Firebase Admin not initialised — check FIREBASE_SERVICE_ACCOUNT in .env' }); + continue; + } + try { + const message = { + token: sub.fcm_token, + android: { priority: 'high', notification: { sound: 'default' } }, + apns: { + headers: { 'apns-priority': '10' }, + 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' }, + }, + }; + if (mode === 'browser') { + message.webpush.notification.title = 'RosterChirp Test (browser)'; + message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.'; + message.webpush.fcm_options = { link: '/' }; + } else { + message.notification = { title: 'RosterChirp Test', body: 'Push notifications are working!' }; + message.data = { url: '/', groupId: '' }; + message.webpush.fcm_options = { link: '/' }; + } + await messaging.send(message); + results.push({ device: sub.device, type: 'fcm', mode, status: 'sent' }); + } catch (err) { + results.push({ device: sub.device, type: 'fcm', mode, status: 'failed', error: err.message, code: err.code }); + } + } else if (sub.webpush_endpoint) { + if (!wp) { + results.push({ device: sub.device, type: 'webpush', status: 'failed', error: 'VAPID keys not configured — check VAPID_PUBLIC/VAPID_PRIVATE in .env' }); + continue; + } + const subscription = { + endpoint: sub.webpush_endpoint, + keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth }, + }; + try { + await wp.sendNotification( + subscription, + JSON.stringify({ title: 'RosterChirp Test', body: 'Push notifications are working!', url: '/', icon: '/icons/icon-192.png' }), + { TTL: 300, urgency: 'high' } + ); + results.push({ device: sub.device, type: 'webpush', status: 'sent' }); + } catch (err) { + results.push({ device: sub.device, type: 'webpush', status: 'failed', error: err.message, statusCode: err.statusCode }); } - - await messaging.send(message); - results.push({ device: sub.device, mode, status: 'sent' }); - console.log(`[Push] Test (${mode}) sent to user ${req.user.id} device=${sub.device}`); - } catch (err) { - results.push({ device: sub.device, mode, status: 'failed', error: err.message, code: err.code }); - console.error(`[Push] Test (${mode}) failed for user ${req.user.id} device=${sub.device}:`, err.message); } } + res.json({ results }); } catch (e) { res.status(500).json({ error: e.message }); } }); -// Debug endpoint (admin-only) — lists all FCM subscriptions for this schema +// Debug endpoint (admin-only) — lists all push 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, + SELECT ps.id, ps.user_id, ps.device, + ps.fcm_token, + ps.webpush_endpoint, u.name, u.email FROM push_subscriptions ps JOIN users u ON u.id = ps.user_id - WHERE ps.fcm_token IS NOT NULL + WHERE ps.fcm_token IS NOT NULL OR ps.webpush_endpoint IS NOT NULL ORDER BY u.name, ps.device `); - const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY); + const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY); const firebaseAdminReady = !!getMessaging(); - res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady }); + const vapidConfigured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE); + res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady, vapidConfigured }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/build.sh b/build.sh index 3971e3f..db76970 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.29}" +VERSION="${1:-0.12.30}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index d5391f1..78ebc4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.29", + "version": "0.12.30", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 815d32c..88cf919 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -27,6 +27,7 @@ export default function ProfileModal({ onClose }) { const [notifPermission, setNotifPermission] = useState( typeof Notification !== 'undefined' ? Notification.permission : 'unsupported' ); + const isIOS = /iphone|ipad/i.test(navigator.userAgent); const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); @@ -216,7 +217,9 @@ export default function ProfileModal({ onClose }) {
{notifPermission === 'denied' - ? 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.' + ? isIOS + ? 'Notifications are blocked. Enable them in iOS Settings → RosterChirp → Notifications.' + : 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.' : 'Push notifications are not yet enabled on this device.'}
{notifPermission === 'default' && ( @@ -232,7 +235,12 @@ export default function ProfileModal({ onClose }) {

Tap Send Test Notification to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.

If it doesn't arrive, check:
- • Android Settings → Apps → RosterChirp → Notifications → Enabled
+ {isIOS ? ( + <>• iOS Settings → RosterChirp → Notifications → Allow
+ • App must be added to the Home Screen (not open in Safari)
+ ) : ( + <>• Android Settings → Apps → RosterChirp → Notifications → Enabled
+ )} • App is backgrounded when the test fires

@@ -258,30 +266,34 @@ export default function ProfileModal({ onClose }) { > {pushTesting ? 'Sending…' : 'Test (via SW)'} - -
-
- Test (via SW) — normal production path, service worker shows notification.
- Test (via Browser) — bypasses service worker; Chrome displays directly. + {!isIOS && ( + + )}
+ {!isIOS && ( +
+ Test (via SW) — normal production path, service worker shows notification.
+ Test (via Browser) — bypasses service worker; Chrome displays directly. +
+ )} )} {pushResult && (
localStorage.getItem(PUSH_ENABLED_KEY) !== 'false'); + + const toggle = async () => { + if (enabled) { + // Disable: remove the server subscription so no pushes are sent + try { + const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token'); + await fetch('/api/push/unsubscribe', { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); + } catch (e) { /* best effort */ } + localStorage.removeItem('rc_fcm_token'); + localStorage.removeItem('rc_webpush_endpoint'); + localStorage.setItem(PUSH_ENABLED_KEY, 'false'); + setEnabled(false); + } else { + // Enable: re-run the registration flow + localStorage.setItem(PUSH_ENABLED_KEY, 'true'); + setEnabled(true); + window.dispatchEvent(new CustomEvent('rosterchirp:push-init')); + } + }; + + return { permitted, enabled, toggle }; +} + export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) { const { user, logout } = useAuth(); const [showMenu, setShowMenu] = useState(false); const [dark, setDark] = useTheme(); + const { permitted: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle(); const menuRef = useRef(null); const btnRef = useRef(null); @@ -44,6 +75,13 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f ))} + {showPushToggle && ( + + )}
@@ -96,6 +134,17 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f About + {showPushToggle && ( + + )}