# Web Push Notifications — Full Implementation Adding web push notifications (VAPID-based) to a FastAPI + PWA game. Covers VAPID key generation, backend subscription management, scoring-triggered push, service worker, and frontend opt-in UI. ## Architecture ``` Browser (SW) ← push event → Push Service (Chrome/Firefox) ← POST → FastAPI backend └─ registers SW + subscribe() ─────────────────────────→ POST /api/push/subscribe └─ receives notification → showNotification() ← scoring event triggers send_push_notification() ``` ## VAPID Key Generation `pywebpush` no longer exports `generate_vapid_keys()`. Generate keys using `cryptography` directly: ```python from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization import base64 private_key = ec.generate_private_key(ec.SECP256R1()) public_key = private_key.public_key() # Public key in X962 uncompressed point format (65 bytes) public_bytes = public_key.public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint ) public_b64 = base64.urlsafe_b64encode(public_bytes).decode().rstrip('=') # Private key in PKCS8 DER format private_bytes = private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) private_b64 = base64.urlsafe_b64encode(private_bytes).decode().rstrip('=') ``` **PITFALL:** The public key MUST be in uncompressed X962 format (65 bytes), NOT SubjectPublicKeyInfo (SPKI/91 bytes). The browser's `PushManager.subscribe()` expects the raw point. If you get a `DOMException: Registration failed - invalid key` on the client, the key format is wrong. ## Backend: Database Add a `push_subscriptions` table: ```sql CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, endpoint) ); ``` Add a `notifications_enabled` column to `users`: ```sql ALTER TABLE users ADD COLUMN notifications_enabled INTEGER DEFAULT 1; ``` ## Backend: VAPID Config ```python from pywebpush import webpush VAPID_PRIVATE_KEY = os.environ.get("VAPID_PRIVATE_KEY", "") VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS = {"sub": "mailto:admin@example.com"} ``` ## Backend: Send Function ```python def send_push_notification(user_id, title, body, icon="/icon.png"): conn = get_db() try: subs = conn.execute( "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?", (user_id,) ).fetchall() # Check user preference user = conn.execute( "SELECT notifications_enabled FROM users WHERE id = ?", (user_id,) ).fetchone() if user and not user["notifications_enabled"]: return for sub in subs: try: webpush( subscription_info={ "endpoint": sub["endpoint"], "keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]} }, data=json.dumps({ "title": title, "body": body, "icon": icon, }), vapid_private_key=VAPID_PRIVATE_KEY, vapid_claims=VAPID_CLAIMS, ) except Exception as e: print(f"Push failed for user {user_id}: {e}") finally: conn.close() ``` **PITFALL:** `webpush()` from pywebpush raises `pywebpush.WebPushException` on HTTP errors (410 Gone = subscription expired). Catch broadly and log — expired subscriptions should be cleaned up but the function should not crash. ## Backend: API Endpoints ### GET /api/push/vapid-public-key Returns `{"public_key": ""}` — no auth needed (called before registering SW). ### POST /api/push/subscribe (auth required) ```json {"endpoint": "https://fcm.googleapis.com/...", "p256dh": "...", "auth": "..."} ``` Uses `INSERT OR REPLACE INTO push_subscriptions`. **PITFALL:** The `endpoint` is unique per registration (not user) — a user with multiple devices will have multiple rows. ### POST /api/push/unsubscribe (auth required) Same body shape. Deletes by `user_id + endpoint`. ### GET /api/push/preferences (auth required) Returns `{"notifications_enabled": bool, "subscription_count": int}`. ### PATCH /api/push/preferences (auth required) ```json {"notifications_enabled": false} ``` Updates the `users.notifications_enabled` column. ## Backend: Triggering Notifications After a scoring event, find all users who drafted the affected region and notify them: ```python region_name = conn.execute("SELECT name, emoji FROM regions WHERE id = ?", (req.region_id,)).fetchone() if region_name: event_labels = {"sighting": "Sighting!", "bite": "Bite!", "fatality": "FATALITY!"} title = f"🦈 {event_labels[req.event_type]}" body = f"+{points} pts — {region_name['emoji']} {region_name['name']}" affected = conn.execute( "SELECT DISTINCT dp.user_id FROM draft_picks dp WHERE dp.region_id = ?", (req.region_id,), ).fetchall() for au in affected: send_push_notification(au["user_id"], title, body) ``` **PITFALL:** `send_push_notification()` opens its own DB connection — call it AFTER closing the scoring transaction's connection, or use a separate connection. Don't keep a conn open across push HTTP calls. ## Frontend: Service Worker (sw.js) ```javascript self.addEventListener('push', (event) => { let data = { title: 'Default', body: '', icon: '/icon.png' }; if (event.data) { try { data = event.data.json(); } catch { data.body = event.data.text(); } } event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: data.icon, badge: '/badge.png', vibrate: [200, 100, 200], data: { url: '/app.html' }, requireInteraction: true, }) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window' }).then((clients) => { for (const c of clients) { if (c.url.includes(url) && 'focus' in c) return c.focus(); } if (clients.openWindow) return clients.openWindow(url); }) ); }); ``` ## Frontend: Registration + Subscription ```javascript // Register SW const swReg = await navigator.serviceWorker.register('/sw.js'); // Get VAPID public key from backend const keyRes = await apiCall('/api/push/vapid-public-key'); const vapidKey = urlBase64ToUint8Array(keyRes.public_key); // Get permission const permission = await Notification.requestPermission(); if (permission !== 'granted') return; // Subscribe const sub = await swReg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidKey, }); // Send to server await apiCall('/api/push/subscribe', { method: 'POST', body: JSON.stringify({ endpoint: sub.endpoint, p256dh: arrayBufferToBase64(sub.getKey('p256dh')), auth: arrayBufferToBase64(sub.getKey('auth')), }), }); ``` ### Base64 ↔ Uint8Array Utility Functions ```javascript function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); return outputArray; } function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } ``` **PITFALL:** The VAPID public key from the server is URL-safe base64 (no padding). `atob()` requires standard base64. Always convert `-` → `+` and `_` → `/` before decoding. Add padding with `'='.repeat((4 - len % 4) % 4)`. ## Frontend: UX Patterns ### Opt-in Banner Show a subtle in-page banner after login (not a dialog — don't be pushy): ```html ``` **PITFALL:** Call `Notification.requestPermission()` only on explicit user action (click/tap). Browsers silently ignore requests not triggered by user gesture. The banner's "Enable" button is the gesture. ### State UI Show current notification state in settings: - **Unsubscribed**: show "Enable" button - **Subscribed**: show "✅ Notifications enabled" - **Blocked**: show "🔕 Notifications blocked — enable in browser settings" - **Unsupported**: silently hide the entire block ### Preference Toggle (settings page) A toggle button in user/league settings to globally disable notifications without unsubscribing devices: ``` PATCH /api/push/preferences {"notifications_enabled": false} ``` When disabling, also call `sub.unsubscribe()` and `POST /api/push/unsubscribe` to clean up. ## Verification 1. Open the app, log in — banner should show "Get scoring alerts" 2. Click "Enable" — browser prompts for notification permission 3. After granting, `POST /api/push/subscribe` is called with the subscription 4. Trigger a score event via the scoring endpoint 5. Browser shows a push notification even if the tab is minimized 6. Clicking the notification opens (or focuses) the app tab **Test with curl:** ```bash curl -s -X POST http://localhost:8083/api/scores/events \ -H "Content-Type: application/json" \ -d '{"region_id": 1, "event_type": "sighting", "description": "Test push"}' ``` ## References - [Web Push API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - [VAPID Spec (RFC 8292)](https://datatracker.ietf.org/doc/html/rfc8292) - [pywebpush on PyPI](https://pypi.org/project/pywebpush/) - [PushManager.subscribe() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)