Skip to content

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 ​

ComponentStatusFile
PWA manifestBuilt (AB#3669)apps/web/public/manifest.webmanifest
Service workerBuilt (AB#3669)apps/web/public/sw.js
Auto-update on page loadBuilt — skipWaiting() on installapps/web/public/sw.js
Push event listenerNOT built — this designapps/web/public/sw.js
VAPID keysNOT builtKey Vault
push_subscriptions tableNOT builtPrisma migration
Subscribe/unsubscribe endpointsNOT builtapps/api
Web push fan-out in PushAdapterNOT builtapps/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 nameContentWho reads it
vapid-private-keyRSA/EC private key (base64url)API container only — via env var VAPID_PRIVATE_KEY
vapid-public-keyCorresponding 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 ​

PlatformSupportNotes
Chrome (desktop + Android)FullStandard Web Push API
Edge (desktop)FullSame as Chrome (Chromium)
Firefox (desktop)FullMozilla Push Service
Safari (macOS 16+)FullAPNs endpoint, different signing from iOS native app
Safari iOS 16.4+Full — PWA onlyPWA must be installed to home screen; in-browser Safari on iOS does not support push
Safari iOS < 16.4Not supportedGraceful degradation — no error, just no subscription created
Chrome iOSNot supportedChrome 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 &ZeroWidthSpace;

VariableContainerSource
VAPID_PUBLIC_KEYweb (React app, for pushManager.subscribe())KV ref → vapid-public-key
VAPID_PUBLIC_KEYapi (for setVapidDetails)KV ref → vapid-public-key
VAPID_PRIVATE_KEYapi onlyKV ref → vapid-private-key

References &ZeroWidthSpace;

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.