Appearance
Push Notification Infrastructure — Design ​
ADO: Feature AB#4429, Story AB#4430, Tasks AB#4431–4435 Status: Design complete — not yet built ADR: 0013 — Notification Transport (Amendment: Web Push)
Purpose ​
This document specifies the Web Push (VAPID) infrastructure that extends the platform's existing push notification channel to cover PWA users. The existing push channel routes through Expo Push → APNs/FCM, which only covers the native React Native app. Members who use the PWA (installed from heritageva.app to their phone or desktop home screen) need a separate, browser-native push path.
This is transport infrastructure — it wires the pipe. The features that send notifications (Calendar, Announcements, Sermons) activate it by calling the existing POST /notifications/send transport endpoint, exactly as they would for any other channel.
What is already built ​
| Component | Status | File |
|---|---|---|
| PWA manifest | Built (AB#3669) | apps/web/public/manifest.webmanifest |
| Service worker | Built (AB#3669) | apps/web/public/sw.js |
| Auto-update on page load | Built — skipWaiting() on install | apps/web/public/sw.js |
| Push event listener | NOT built — this design | apps/web/public/sw.js |
| VAPID keys | NOT built | Key Vault |
push_subscriptions table | NOT built | Prisma migration |
| Subscribe/unsubscribe endpoints | NOT built | apps/api |
| Web push fan-out in PushAdapter | NOT built | apps/api |
Architecture ​
text
[Browser / PWA]
│ 1. Feature calls Notification.requestPermission() (first feature only — Calendar or Announcements)
│ 2. Browser shows OS permission dialog
│ 3. Member allows → browser calls pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY })
│ 4. Browser → push service → returns PushSubscription { endpoint, keys: { p256dh, auth } }
│ 5. App POSTs subscription to POST /api/v1/notifications/subscribe/web
│
▼
[apps/api — notifications/subscribe/web]
│ Upsert into push_subscriptions table (member_id, endpoint, p256dh, auth)
│
▼
[apps/api — POST /notifications/send] ← called by Calendar, Announcements, Sermons, etc.
│
└─► PushAdapter
├─► Expo push tokens → Expo API → APNs / FCM (existing)
└─► web-push subscriptions → browser push service
│
├─► Chrome / Edge endpoint (FCM endpoint)
├─► Firefox endpoint (Mozilla Push Service)
└─► Safari iOS 16.4+ endpoint (APNs, different route than native app)
│
▼
[Service worker push event]
self.registration.showNotification(title, { body, icon, badge, data: { url } })
│
▼
[notificationclick event]
window.clients.openWindow(notification.data.url)Database schema ​
sql
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_push_subscriptions_member_id ON push_subscriptions(member_id);One member may have multiple subscriptions (phone browser, laptop, tablet). The PushAdapter fans out to all active subscriptions for a given member in parallel.
API endpoints ​
POST /api/v1/notifications/subscribe/web ​
Stores or refreshes a web push subscription for the authenticated member.
Auth: Clerk session required (same as all authenticated endpoints)
Request body:
json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BNcRdreALRFXTkOO...",
"auth": "tBHItJI5svbpez..."
},
"userAgent": "Mozilla/5.0 ..."
}Behavior: Upsert on endpoint. Browsers can refresh subscriptions (new p256dh/auth while keeping the same endpoint) — the upsert handles this transparently.
Response: 201 Created
DELETE /api/v1/notifications/subscribe/web ​
Removes a web push subscription. Called on logout or when the member revokes permission at the browser settings level.
Auth: Clerk session required
Request body:
json
{ "endpoint": "https://fcm.googleapis.com/fcm/send/..." }Behavior: Delete the row matching member_id + endpoint. No-op if not found.
Response: 204 No Content
PushAdapter changes (apps/api) ​
typescript
// Existing: fan out to Expo tokens
await Promise.allSettled(expoTokens.map(token => expo.sendPushNotificationsAsync([...])));
// New: fan out to web push subscriptions
const webSubs = await db.push_subscriptions.findMany({ where: { member_id: { in: recipientIds } } });
const webPushPayload = JSON.stringify({
title: payload.title,
body: payload.body,
icon: '/icons/icon-192.svg',
badge: '/icons/icon-192.svg',
data: { url: payload.data?.url ?? '/' },
});
await Promise.allSettled(
webSubs.map(async (sub) => {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
webPushPayload,
);
} catch (err: any) {
if (err.statusCode === 410) {
// Subscription expired — clean up
await db.push_subscriptions.delete({ where: { id: sub.id } });
}
logger.error({ err, subId: sub.id }, 'web-push delivery failed');
}
}),
);VAPID configuration (set once at module init):
typescript
webpush.setVapidDetails(
'mailto:kris@hybridsolutions.cloud',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);Service worker changes (apps/web/public/sw.js) ​
javascript
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'Heritage Virginia', {
body: data.body,
icon: data.icon ?? '/icons/icon-192.svg',
badge: '/icons/icon-192.svg',
data: { url: data.data?.url ?? '/' },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(event.notification.data.url);
}),
);
});Key Vault secrets ​
| Secret name | Content | Who reads it |
|---|---|---|
vapid-private-key | RSA/EC private key (base64url) | API container only — via env var VAPID_PRIVATE_KEY |
vapid-public-key | Corresponding public key (base64url) | API container (for signing) + web container (for subscribe call) via env var VAPID_PUBLIC_KEY |
Generate once:
bash
npx web-push generate-vapid-keys
# outputs: Public Key: ..., Private Key: ...Store in kv-heritageva-prod-eus, then set as Container App environment variables via the existing Managed Identity → Key Vault reference pattern (ADR 0004).
Permission prompt — NOT in this design ​
The browser prompt (Notification.requestPermission()) is not triggered by this infrastructure. It is wired by the first feature that sends push notifications, in context:
- Calendar — "Get reminders for upcoming events" (recommended first)
- Announcements — "Get notified when a new announcement is posted"
- Sermons — "Get notified when a new sermon is posted"
Requesting permission at login with no context results in most members denying it. The platform design calls for showing the prompt at the moment a member would clearly benefit from it.
The subscription flow (steps 3–5 in the architecture diagram) runs after the member grants permission. Until then, no push_subscriptions row exists for that member/device — the adapter simply has nothing to send to, which is correct behavior.
Platform support ​
| Platform | Support | Notes |
|---|---|---|
| Chrome (desktop + Android) | Full | Standard Web Push API |
| Edge (desktop) | Full | Same as Chrome (Chromium) |
| Firefox (desktop) | Full | Mozilla Push Service |
| Safari (macOS 16+) | Full | APNs endpoint, different signing from iOS native app |
| Safari iOS 16.4+ | Full — PWA only | PWA must be installed to home screen; in-browser Safari on iOS does not support push |
| Safari iOS < 16.4 | Not supported | Graceful degradation — no error, just no subscription created |
| Chrome iOS | Not supported | Chrome on iOS uses WebKit; same limitation as in-browser Safari |
Members on unsupported browsers/OS versions receive in-app and SMS/email channels as normal. No code path errors — the push adapter simply has no subscriptions to send to.
Env vars summary ​
| Variable | Container | Source |
|---|---|---|
VAPID_PUBLIC_KEY | web (React app, for pushManager.subscribe()) | KV ref → vapid-public-key |
VAPID_PUBLIC_KEY | api (for setVapidDetails) | KV ref → vapid-public-key |
VAPID_PRIVATE_KEY | api only | KV ref → vapid-private-key |
References ​
- ADR 0013 — Notification Transport (includes Web Push amendment)
- ADR 0031 — Cross-platform delivery / PWA
- ADR 0004 — Cloud hosting stack / Key Vault
- ADR 0005 — Observability model
- apps/web/public/sw.js — existing service worker
- apps/web/public/manifest.webmanifest — existing PWA manifest
- ADO: Feature AB#4429, Story AB#4430, Tasks AB#4431–4435