Skip to content

Route push notifications by deep link, record opens for attribution - #2517

Merged
MusabShakeel576 merged 2 commits into
masterfrom
claude/focused-hypatia-qqic9t
Sep 14, 2026
Merged

MusabShakeel576 merged 2 commits into
masterfrom
claude/focused-hypatia-qqic9t

Conversation

@MusabShakeel576

Copy link
Copy Markdown
Contributor

Summary

This change enables push notifications to be routed by deep links stamped by the backend, rather than solely by notification type. It also adds analytics tracking for push opens and updates the attribution store to credit sessions to the campaign that triggered the notification.

Key Changes

  • Deep link routing: Added routeForLink() function that maps backend-provided URLs to in-app screens, with a ROUTE_BY_LINK_PATH lookup table. This allows the backend to control where pushes navigate without app changes.

  • Routing priority: Restructured getNotificationRoute() to prioritize transaction-specific pushes (spend and 3DS) first, then follow deep links, then fall back to legacy type-based routing for older notifications.

  • Attribution tracking: Added recordOpen() function that:

    • Records a PUSH_NOTIFICATION_OPENED event with campaign metadata (utm_source, utm_medium, utm_campaign)
    • Updates the attribution store with campaign data so subsequent events in the session are enriched with push attribution
    • Gracefully handles errors to prevent telemetry from blocking navigation
  • Enhanced notification data types: Extended NotificationData type to include utm_source, utm_medium, utm_campaign, and link fields from backend push payloads.

  • Comprehensive test coverage: Added 176 lines of tests covering:

    • Path-to-screen mapping for all supported routes
    • Query parameter handling and trailing slash tolerance
    • Special case for /rewards?referral=open
    • Transaction push routing (spend and 3DS)
    • Legacy type-based routing for older notifications
    • Fallback behavior for unrecognized links and types
  • Analytics event: Added PUSH_NOTIFICATION_OPENED tracking event with detailed documentation about how push attribution flows through the session.

Notable Implementation Details

  • The routeForLink() function returns undefined for unrecognized links rather than throwing, allowing graceful fallback to type-based routing.
  • Transaction pushes (card-transaction, card-3ds) never carry a link field and are resolved before link routing to ensure they always open the specific transaction.
  • The attribution store is updated via updateAttribution() directly rather than captureFromDeepLink() to preserve first-touch attribution while recording the last-touch push campaign.
  • Error handling in recordOpen() uses console.warn() to ensure telemetry failures don't block navigation.
  • Exported __testing object for unit test access to routing functions.

https://claude.ai/code/session_01C7vZF3WSp2uZtQP8TvhTPc

The backend now stamps every push with the campaign that sent it
(utm_source / utm_medium=push / utm_campaign) and the deep link it points
at. Read both on tap.

Two things follow. The open is tracked as push_notification_opened, and
the campaign is written into the attribution store — which is the half
that answers "did the nudge move spend", because every event is enriched
from that store, so a deposit or first payment later in the session now
carries the push's utm_source exactly as one after an email CTA carries
the email's. Before this, utm_medium in Amplitude had sixteen values and
`push` was not one of them.

Routing then follows the link's path, mapped to the canonical path.*
Href rather than handed to the router as a string: /card is a redirect
shim, the Fuse vault and the referral sheet are routes plus a param. This
gives the eleven lifecycle types a destination for the first time — they
had no branch here at all and every one of them opened home, while the
email saying the same thing pointed at /card, /savings or /activity.

Spend and 3DS pushes still resolve first on transactionId, which no
static link can carry, and the type switch stays as the fallback for
pushes sent before links existed — a notification can sit in the tray for
days.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vZF3WSp2uZtQP8TvhTPc
@vercel

vercel Bot commented Sep 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated
solid-app Ignored Ignored Preview Sep 14, 2026 11:56am UTC
solid-app-staging Ignored Ignored Preview Sep 14, 2026 11:56am UTC

Request Review

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Comment thread hooks/usePushNotifications.ts
routeForLink read the pathname of a push's deep link without checking
where the link pointed, while redirectSystemPath — the app's other entry
point for an outside URL choosing an in-app screen — checks the host
against KNOWN_HOSTS. Reported by the Sentry review bot on #2517.

The exposure is small: sending a push needs our FCM credentials, and the
paths resolve through a closed map to hardcoded screens, so a foreign
link could at most pick which of our own screens the tap opened. But the
link is data from outside the app, the allowlist already existed for
exactly this decision, and the day a link is built from something a user
supplied is not the day to start looking for the control point.

KNOWN_HOSTS moves to constants/deeplink.ts and both call sites import it,
rather than a second copy that can drift — the copy that falls behind
being the one that stops trusting a host we do serve.

Also screen the scheme. `javascript://app.solid.xyz/card` parses with a
hostname that passes the allowlist; nothing here would execute it, but it
did not come from our backend, and redirectSystemPath draws the same line
with path.startsWith('http').

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vZF3WSp2uZtQP8TvhTPc
@MusabShakeel576
MusabShakeel576 merged commit bd0aaf6 into master Sep 14, 2026
7 checks passed
@MusabShakeel576
MusabShakeel576 deleted the claude/focused-hypatia-qqic9t branch September 14, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants