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.jsregisters FCM web push, stores tokens atusers/{uid}/fcmTokens/{sha256(token)}(owner-write), dedicated SW scope so the PWA caching worker is untouched. - Service worker:
apps/web/public/firebase-messaging-sw.jshandles background display + notification click deep-linking viadata.url. - Server send path: auth-api moderation notices (
services/api/auth/src/routes/moderation.js) readsfcmTokens, sends via Admin SDKsendEachForMulticastwith 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) โ
- Push on wave received (recipient), wave accepted / connection created (original sender), and new chat message (recipient).
- A contextual permission prompt flow (deliberate user gesture, not a load-time prompt; iOS requires a gesture anyway).
- Per-category preferences (waves, messages, venue activity) that the send path respects. Moderation notices stay non-optional.
- Token hygiene: prune tokens FCM reports as invalid; refresh
lastSeenAton re-registration. - 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 (categorywaves)onDocumentUpdated('waves/{waveId}')where status flips toacceptedโ notify original sender (categorywaves)onDocumentCreated('connections/{cid}/messages/{mid}')โ notify the non-author participant (categorymessages), 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:
- 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."
- No counterpart identity. No lantern names, no user ids of the other party in
notificationordata. - 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.)
- Deep links go to inbox-level routes (
/#/frens, wave inbox), not resource-specific URLs, so push-infra logs hold nowaveId/connectionIdedges. The app resolves specifics after open. data.kinddistinguishes 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) โ
- Should the venue-activity stretch use "favorites" (existing concept?) or a new follow mechanic? (Affects fan-out shape.)
- 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.
- 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.)