Initial skills documentation — 25 categories, all SKILL.md + references + scripts
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# 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", "<base64-private>")
|
||||
VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY", "<base64-public>")
|
||||
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": "<base64>"}` — 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
|
||||
<div id="pushBanner" style="display:none;">
|
||||
<div>🔔 Get scoring alerts — Enable push notifications</div>
|
||||
<button onclick="requestNotificationPermission()">Enable</button>
|
||||
<button onclick="hideNotificationBanner()">✕</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**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)
|
||||
Reference in New Issue
Block a user