Skip to content

Push Notifications for Product Events (Design) โ€‹

Date: 2026-07-25 Status: Draft for operator review (implementation proceeding in parallel on low-risk pieces) Issue: #161 (canonical). Related: #163 (iOS wrapper decision), #326 (push is critical for activation), #166 (chats), #177 (core loop). Plan: ../plans/2026-07-25-push-notifications.md

Problem โ€‹

The December 2026 soft alpha runs scheduled "lantern evenings" (manufactured density). That mechanic depends on reaching people who do not have the app open: "you got a wave," "you have a new message," "lanterns are lighting at your spot tonight." Today, push exists only for one-way moderation notices. Without product push, activation relies on users re-opening a PWA at exactly the right moment, which is the cold-start trap the risk assessment calls the app's biggest threat.

What already exists (build on it, do not duplicate it) โ€‹

  • Client registration rail: apps/web/src/lib/notificationsService.js registers FCM web push, stores tokens at users/{uid}/fcmTokens/{sha256(token)} (owner-write), dedicated SW scope so the PWA caching worker is untouched.
  • Service worker: apps/web/public/firebase-messaging-sw.js handles background display + notification click deep-linking via data.url.
  • Server send path: auth-api moderation notices (services/api/auth/src/routes/moderation.js) reads fcmTokens, sends via Admin SDK sendEachForMulticast with 500-token chunking, best-effort (never fails the parent operation).
  • Event-driven precedent: Cloud Functions Firestore triggers already exist (frens.js, syncPublicProfile.js, phoneHashSync.js).

Goals (v1, alpha-gating) โ€‹

  1. Push on wave received (recipient), wave accepted / connection created (original sender), and new chat message (recipient).
  2. A contextual permission prompt flow (deliberate user gesture, not a load-time prompt; iOS requires a gesture anyway).
  3. Per-category preferences (waves, messages, venue activity) that the send path respects. Moderation notices stay non-optional.
  4. Token hygiene: prune tokens FCM reports as invalid; refresh lastSeenAt on re-registration.
  5. Foreground behavior: an in-app toast/badge instead of a dead devLog.

Stretch (only if v1 lands early) โ€‹

  • Favorite-venue activity nudge ("lanterns are lighting at <venue>"): aggregate-only, threshold-gated (never "1 lantern"), opt-in per favorite. This is the strongest activation lever for lantern evenings but needs fan-out design (who follows a venue), so it ships behind v1.

Non-goals (post-alpha, descoped from #161's original list) โ€‹

Achievement/streak notifications, quiet hours, notification history screen, cross-device sync beyond multi-token, native-app push (#163 wrapper decision is separate; web push is the alpha bet).

Design โ€‹

Send path: Cloud Functions triggers + a shared send helper โ€‹

Per AGENTS.md rule 9, HTTP endpoints belong in Cloud Run, but event-driven triggers are exactly what Cloud Functions are for. Sends are triggered by Firestore writes that already happen:

  • onDocumentCreated('waves/{waveId}') โ†’ notify recipient (category waves)
  • onDocumentUpdated('waves/{waveId}') where status flips to accepted โ†’ notify original sender (category waves)
  • onDocumentCreated('connections/{cid}/messages/{mid}') โ†’ notify the non-author participant (category messages), throttled (see below)

A new module services/functions/firebase/modules/pushNotifications.js owns the triggers plus a shared sendPushToUser(uid, {title, body, data, category}) helper that: checks the recipient's category preference, loads tokens, chunks at 500, sends, and deletes token docs on messaging/registration-token-not-registered (hygiene the moderation path currently lacks; that path can adopt the helper later, not in this PR).

Payload privacy rules (the load-bearing part) โ€‹

Push payloads transit Google (and on iOS, Apple) infrastructure and can land on lock screens. Treat that channel as hostile to confidentiality. Invariants:

  1. No message content, ever. Chat is E2EE; the server cannot read bodies and the payload must not try to carry them. Copy is generic: "New message on Lantern."
  2. No counterpart identity. No lantern names, no user ids of the other party in notification or data.
  3. No venue + person linkage. Wave/message pushes never name a venue. (The stretch venue nudge names a venue but no people, aggregate counts only, k >= 3.)
  4. Deep links go to inbox-level routes (/#/frens, wave inbox), not resource-specific URLs, so push-infra logs hold no waveId/connectionId edges. The app resolves specifics after open.
  5. data.kind distinguishes categories for client handling; that enum leaks nothing.

This follows the prefer-losing-data-over-leaking axiom: a blander notification is a UX cost; a leaky one is a trust failure.

Message throttling โ€‹

Chat can burst; one push per message would spam lock screens and burn quota. v1 rule: per (recipient, connection), suppress repeat pushes within a short window (target 5 minutes) using a lastPushAt field on the connection doc (server-written by the trigger, not client-writable). FCM collapseKey/tag provides display-level collapsing on top.

Preferences โ€‹

users/{uid}.notificationPrefs map: {waves: true, messages: true, venueActivity: false} (defaults on registration; missing map = defaults). Plaintext booleans reveal nothing sensitive, so no encryption needed; owner-write via rules, read by the trigger with Admin SDK. Settings UI adds three toggles to the existing settings surface; react-select rule does not apply (toggles), design skill will be invoked for the UI pass.

Permission prompt flow โ€‹

Contextual, gesture-driven: prompt when the user takes their first push-worthy action (lighting a lantern or sending a wave), with a pre-prompt explainer card (why push, what we never include in one). Never prompt on load. registerForNotices(prompt=true) already implements the gesture-driven request; it gets generalized to registerForPush (kept exported under the old name to avoid churn in NoticesSection).

iOS PWA reality (informs #163, does not block v1) โ€‹

Web push on iOS requires 16.4+, installed to home screen, and a user gesture. The alpha is invite-only, so onboarding can require Add to Home Screen (the invite flow gains an install step). This is the cheapest path to December; if alpha data shows install friction kills the funnel, that is the evidence for the app-store wrapper decision, which stays a pre-launch checklist item.

Key decisions โ€‹

See the decision log in the plan doc. Headlines: reuse the moderation FCM rail (D3), Cloud Functions triggers not a new Cloud Run service (D4), inbox-level deep links + generic copy as privacy invariants (D5), v1 scope cut to waves/messages/prefs (D6).

Open questions (operator input welcome, not blocking v1) โ€‹

  1. Should the venue-activity stretch use "favorites" (existing concept?) or a new follow mechanic? (Affects fan-out shape.)
  2. Wave-accepted push copy: "Your wave was accepted" reveals to a lock-screen shoulder-surfer that the user waved at someone. Acceptable, or collapse to the generic "Something happened on Lantern"? v1 ships the explicit-but-identity-free copy unless overruled.
  3. Do moderation notices adopt the shared helper in this PR or a follow-up? (Current lean: follow-up, keep this PR's blast radius small.)

Built with VitePress