Skip to content

AppsFlyer integration - #3588

Merged
steven-tey merged 38 commits into
mainfrom
appsflyer
Apr 3, 2026
Merged

AppsFlyer integration#3588
steven-tey merged 38 commits into
mainfrom
appsflyer

Conversation

@devkiran

@devkiran devkiran commented Mar 16, 2026

Copy link
Copy Markdown
Collaborator
  • Detect AppsFlyer OneLink URLs (*.onelink.me) and inject clickid, af_siteid, af_ip, af_ua params on redirect
  • Cache clickId for AppsFlyer tracking URLs to support conversion matching
  • Add webhook endpoint (GET /api/appsflyer/webhook) authenticated via publishable_key
  • Support lead and sale event tracking from AppsFlyer postbacks
  • Add redirect test for AppsFlyer parameter injection

Summary by CodeRabbit

  • New Features

    • AppsFlyer integration: settings UI, install & update actions, updated enable flow, and webhook GET/HEAD endpoints with IP allowlisting.
  • Partner Links

    • Partner link generation can inject AppsFlyer tracking parameters and resolve macros when applicable.
  • Background Jobs

    • Cron flows load/apply AppsFlyer parameters when creating, remapping, or updating default partner links.
  • Middleware

    • Redirects and click handling detect AppsFlyer tracking URLs and add required AppsFlyer params.
  • Tests

    • Added redirect test validating AppsFlyer query parameters and headers.
  • Chores

    • Added AppsFlyer integration identifier and updated integration provisioning script.

- Detect AppsFlyer OneLink URLs (*.onelink.me) and inject clickid, af_siteid, af_ip, af_ua params on redirect
- Cache clickId for AppsFlyer tracking URLs to support conversion matching
- Add webhook endpoint (GET /api/appsflyer/webhook) authenticated via publishable_key
- Support lead and sale event tracking from AppsFlyer postbacks
- Add redirect test for AppsFlyer parameter injection
@vercel

vercel Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Apr 3, 2026 6:13am

Request Review

@coderabbitai

coderabbitai Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds AppsFlyer integration across backend and frontend: webhook endpoint, AppsFlyer settings UI and server actions, parameter loading/apply utilities, partner-link generation and cron flows updated to inject AppsFlyer parameters, middleware handling for AppsFlyer URLs, constants, tests, and installer script updates.

Changes

Cohort / File(s) Summary
Webhook Handler
apps/web/app/(ee)/api/appsflyer/webhook/route.ts
New GET/HEAD route with IP allowlist, Zod query parsing, installed-integration lookup by appId, and dispatch to trackLead/trackSale; errors handled centrally.
Integration schema & constants
apps/web/lib/integrations/appsflyer/schema.ts, apps/web/lib/integrations/appsflyer/constants.ts
Adds Zod schemas/types for AppsFlyer settings, default/required/hardcoded parameters, macro definitions, and IP CIDR ranges.
Parameter utilities
apps/web/lib/integrations/appsflyer/apply-parameters.ts, .../macro-template.ts
New applyAppsFlyerParameters and loadAppsFlyerParameters; macro validation helpers to resolve/validate {{...}} tokens.
Partner link generation
apps/web/lib/api/partners/generate-partner-link.ts, apps/web/lib/api/partners/create-partner-default-links.ts
generatePartnerLink signature extended to accept appsFlyerParameters and rewrite AppsFlyer URLs; default-link creation conditionally loads parameters when AppsFlyer URLs exist.
Cron jobs (create/remap/update)
apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts, .../remap-default-links/route.ts, .../update-default-links/route.ts
Cron flows detect AppsFlyer tracking URLs, load workspace parameters when present, pass parameters into generatePartnerLink; update-default-links switched to per-link updates via Promise.allSettled and expanded selected fields/cursor/cache handling.
Middleware & final URL logic
apps/web/lib/middleware/utils/is-appsflyer-tracking-url.ts, apps/web/lib/middleware/utils/get-final-url.ts, apps/web/lib/middleware/link.ts
New helper to detect .onelink.me; getFinalUrl injects AppsFlyer params (pid, af_ua, af_ip, optional clickid, c, af_siteid); middleware includes AppsFlyer in clickId caching decisions.
Settings UI & server actions
apps/web/lib/integrations/appsflyer/ui/settings.tsx, .../install.ts, .../update-settings.ts
Adds AppsFlyerSettings React UI and server actions installAppsFlyerAction & updateAppsFlyerSettingsAction to persist validated settings, revalidate pages, and enqueue cron re-runs when parameters change.
Dashboard pages wiring
apps/web/app/.../settings/integrations/[integrationSlug]/page-client.tsx, .../page.tsx
Wires AppsFlyer install flow into the enable button, adjusts loading/tooltip logic, and prevents guideUrl redirect for AppsFlyer.
Constants, scripts, tests
packages/utils/src/constants/integrations.ts, apps/web/scripts/create-integration.ts, apps/web/tests/redirects/index.test.ts, apps/web/scripts/test-appsflyer.ts
Adds APPSFLYER_INTEGRATION_ID, updates integration seed script, adds test asserting AppsFlyer redirect params, and new local test script to craft webhook URLs.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Webhook as AppsFlyer Webhook
    participant Prisma
    participant Tracker as Tracking Service
    participant Response

    Client->>Webhook: GET /api/appsflyer/webhook?appId=...&partnerEventId=...
    Webhook->>Webhook: resolve caller IP\nvalidate against APPSFLYER_IP_RANGES
    alt IP not allowed
        Webhook->>Response: return 403 forbidden (DubApiError)
    else IP allowed
        Webhook->>Webhook: parse & validate query params
        Webhook->>Prisma: find installedIntegration by integrationId & appId in settings
        alt not found
            Webhook->>Response: return bad_request
        else found
            alt partnerEventId == "lead"
                Webhook->>Webhook: validate lead payload
                Webhook->>Tracker: trackLead(parsed..., workspace, rawBody)
            else partnerEventId == "sale"
                Webhook->>Webhook: validate sale payload
                Webhook->>Tracker: trackSale(parsed..., workspace, rawBody)
            else
                Webhook->>Response: return JSON (ignored event)
            end
            Tracker->>Response: return success JSON
        end
    end
Loading
sequenceDiagram
    participant Cron as Cron Job
    participant AFUtil as loadAppsFlyerParameters
    participant LinkGen as generatePartnerLink
    participant URL as applyAppsFlyerParameters
    participant DB as Prisma

    Cron->>Cron: identify default link URLs
    alt isAppsFlyerTrackingUrl detected
        Cron->>AFUtil: loadAppsFlyerParameters(workspaceId)
        AFUtil->>DB: query installedIntegration.settings
        DB-->>AFUtil: return parameters
        AFUtil-->>Cron: return parameters array
    else
        Cron->>Cron: use empty parameters
    end
    Cron->>LinkGen: generatePartnerLink(..., appsFlyerParameters)
    LinkGen->>LinkGen: processLink => processedLink.url
    alt appsFlyerParameters present & URL is AppsFlyer
        LinkGen->>URL: applyAppsFlyerParameters(url, parameters, context)
        URL->>URL: resolve macros & set search params
        URL-->>LinkGen: return rewritten URL
    end
    LinkGen->>DB: create/update link record
    DB-->>Cron: confirmation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • pepeladeira

Poem

🐰 I stitched some params with carrot flair,

Onelink hummed secrets into the air,
Macros hopped in, keys snug and bright,
Links sprout tracking under moonlight,
Hooray — AppsFlyer fills the night! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'AppsFlyer integration' is concise and directly reflects the main change (adding AppsFlyer integration support), though it lacks specificity about which aspects of the integration are included.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch appsflyer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…th direct API calls for lead and sale events, update query parameter schema, and remove obsolete tracking files. Add a new script for creating AppsFlyer postback requests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/web/app/(ee)/api/appsflyer/webhook/route.ts (1)

16-20: ⚠️ Potential issue | 🔴 Critical

Webhook auth is still appId-only; bind requests to publishable_key as well.

Line 16 and Line 52 currently authenticate by appId membership only. Since appId is non-secret, any caller from allowed AppsFlyer IP space can target another installation if they know/guess an appId.

🔒 Suggested direction
 const querySchema = z.object({
   appId: z.string(),
+  publishable_key: z.string().min(1, "publishable_key is required"),
   partnerEventId: z.string(),
   eventValue: z.string().nullish(),
 });
 
-const { appId, partnerEventId } = querySchema.parse(queryParams);
+const { appId, publishable_key, partnerEventId } = querySchema.parse(queryParams);
 
 const installation = await prisma.installedIntegration.findFirst({
   where: {
     integrationId: APPSFLYER_INTEGRATION_ID,
     settings: {
       path: "$.appIds",
       array_contains: appId,
     },
+    // also validate installation/workspace-bound publishable key
+    // (exact field path depends on where this key is persisted)
   },

Also applies to: 52-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts around lines 16 - 20,
Update the webhook request validation and auth to bind requests to the
publishable_key in addition to appId: extend the existing Zod querySchema to
require a publishable_key (e.g., add publishable_key: z.string()) and then
change the authentication logic that currently authorizes by appId-only to
verify that the provided publishable_key belongs to the same installation as the
appId before processing (update the route handler/auth check where it looks up
or validates appId membership so it validates both appId and publishable_key
together). Ensure any error messages/logging reference both identifiers and
reject requests where the publishable_key does not match the appId.
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/appsflyer/webhook/route.ts (1)

79-87: Lead parsing can be made more robust for query-string numeric fields.

trackLeadRequestSchema.parse(queryParams) will fail if optional numeric fields (for example eventQuantity) arrive as strings in the URL. Consider parsing only the used fields (or coercing known numeric fields) to avoid brittle webhook failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts around lines 79 - 87,
partnerEventId handling is fine but trackLeadRequestSchema.parse(queryParams)
can fail when numeric fields (e.g., eventQuantity) arrive as strings; modify the
parsing to coerce known numeric query params before validating (or use a schema
coercion/preprocess for those fields) so optional numeric fields won't break the
webhook: identify the numeric fields (like eventQuantity) in the incoming
queryParams, convert them to numbers when present (or apply zod/coercion on
trackLeadRequestSchema) and then call trackLeadRequestSchema.parse on the
normalized input instead of raw queryParams.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts:
- Around line 16-20: Update the webhook request validation and auth to bind
requests to the publishable_key in addition to appId: extend the existing Zod
querySchema to require a publishable_key (e.g., add publishable_key: z.string())
and then change the authentication logic that currently authorizes by appId-only
to verify that the provided publishable_key belongs to the same installation as
the appId before processing (update the route handler/auth check where it looks
up or validates appId membership so it validates both appId and publishable_key
together). Ensure any error messages/logging reference both identifiers and
reject requests where the publishable_key does not match the appId.

---

Nitpick comments:
In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts:
- Around line 79-87: partnerEventId handling is fine but
trackLeadRequestSchema.parse(queryParams) can fail when numeric fields (e.g.,
eventQuantity) arrive as strings; modify the parsing to coerce known numeric
query params before validating (or use a schema coercion/preprocess for those
fields) so optional numeric fields won't break the webhook: identify the numeric
fields (like eventQuantity) in the incoming queryParams, convert them to numbers
when present (or apply zod/coercion on trackLeadRequestSchema) and then call
trackLeadRequestSchema.parse on the normalized input instead of raw queryParams.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f16ac3d3-ac4c-4f82-992a-8036a2a5adff

📥 Commits

Reviewing files that changed from the base of the PR and between e4661fa and 85a27fc.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/appsflyer/webhook/route.ts

Allow workspaces to customize the values of c and af_siteid parameters
which are always included in AppsFlyer links. These default to
{{PARTNER_NAME}} and {{PARTNER_LINK_KEY}} respectively but can be
changed. Also triggers re-application of parameters to existing links
when settings change and normalizes input by trimming whitespace.
@steven-tey

Copy link
Copy Markdown
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai

coderabbitai Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the APPSFLYER_INTEGRATION_ID and TODO comment
cat packages/utils/src/constants/integrations.ts

echo "---"
# Check the update-default-links cron route for behavioral changes
cat apps/web/app/\(ee\)/api/cron/groups/update-default-links/route.ts

echo "---"
# Check create-integration script
cat apps/web/scripts/create-integration.ts

echo "---"
# Check AppsFlyer constants (IP ranges)
cat apps/web/lib/integrations/appsflyer/constants.ts

echo "---"
# Check getFinalUrl for AppsFlyer block
cat apps/web/lib/middleware/utils/get-final-url.ts

echo "---"
# Check isAppsFlyerTrackingUrl
cat apps/web/lib/middleware/utils/is-appsflyer-tracking-url.ts

echo "---"
# Check webhook route
cat apps/web/app/\(ee\)/api/appsflyer/webhook/route.ts

Length of output: 17434


🏁 Script executed:

#!/bin/bash
# Check getIP implementation to see if it takes a req param
fd "get-ip" --type f
echo "---"
ast-grep --pattern 'export function getIP($_) { $$$ }'
echo "---"
ast-grep --pattern 'export const getIP = $_'
echo "---"
# Check apply-parameters for macro resolution
cat apps/web/lib/integrations/appsflyer/apply-parameters.ts
echo "---"
# Check if there's any existing test setup for appsflyer redirect
rg -n "appsflyer" apps/web/tests/
echo "---"
# Check the link.ts middleware for shouldCacheClickId
rg -n "shouldCacheClickId" apps/web/lib/middleware/link.ts -A5 -B2

Length of output: 6280


🏁 Script executed:

#!/bin/bash
cat apps/web/lib/integrations/appsflyer/schema.ts
echo "---"
# Check if requiredParameters is used elsewhere
rg -n "requiredParameters" apps/web/lib/integrations/appsflyer/
echo "---"
# Check the install.ts to verify plan guard
cat apps/web/lib/integrations/appsflyer/install.ts
echo "---"
# Check update-settings.ts for parameter change detection
cat apps/web/lib/integrations/appsflyer/update-settings.ts
echo "---"
# Verify AppsFlyer IP ranges against published docs
rg -n "APPSFLYER_IP_RANGES" apps/web/

Length of output: 7874


@steven-tey — here's the full review with breaking change analysis.


✅ No Breaking Changes to Existing Integrations

All changes are additive. Existing Stripe, HubSpot, Singular, and other integrations are unaffected. The one behavioral change in update-default-links (bulk updateMany → per-link update via Promise.allSettled) is actually an improvement in resilience.


⚠️ Issues to Address

1. Hardcoded IP Ranges Will Become Stale (constants.ts)

AppsFlyer's IP addresses are controlled by and for the use of AppsFlyer, and the list changes from time to time. With only two CIDR ranges hardcoded:

export const APPSFLYER_IP_RANGES = ["45.92.116.0/22", "194.28.46.0/23"];

Any IP range rotation by AppsFlyer will silently block legitimate postbacks and require a code deploy to fix. Consider fetching the IP list dynamically or adding a fallback/bypass mechanism, and document a process for updating these ranges.


2. Silent Failures in update-default-links (route.ts)

Promise.allSettled is used for per-link updates but failures are never logged:

await Promise.allSettled(
  linksToUpdate.map(({ id, link }) =>
    prisma.link.update({ where: { id }, data: link }),
  ),
);

If any individual link update fails, it fails silently. Suggest logging rejected promises:

const results = await Promise.allSettled(
  linksToUpdate.map(({ id, link }) =>
    prisma.link.update({ where: { id }, data: link }),
  ),
);

const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
  await log({
    message: `Failed to update ${failed.length} links (defaultLinkId=${defaultLink.id}): ${failed.map((f) => (f as PromiseRejectedResult).reason).join(", ")}`,
    type: "errors",
  });
}

3. Dual dub_id + clickid Injection for AppsFlyer URLs (get-final-url.ts)

When clickId is present for an AppsFlyer URL, both params are injected:

  • The standard block sets dub_id=${clickId}
  • The AppsFlyer block also sets clickid=${clickId}

This results in a redirect URL like ?dub_id=abc&clickid=abc&.... The dub_id is redundant on AppsFlyer URLs since attribution uses clickid. Consider skipping the standard dub_id injection for AppsFlyer URLs (similar to how Stripe uses client_reference_id instead):

} else if (!searchParams.has("dub-no-track") && !isAppsFlyerTrackingUrl(url)) {
  urlObj.searchParams.set("dub_id", clickId);
}

4. pid Unconditionally Overrides Destination URL (get-final-url.ts)

urlObj.searchParams.set("pid", "dubinc_int");

This overwrites any pid value already present in the user's configured AppsFlyer URL. This is likely intentional for certified partner enforcement, but should be documented in a code comment.


5. create-integration.ts ID Drift Risk

The script uses createId({ prefix: "int_" }) to generate a random ID, but APPSFLYER_INTEGRATION_ID is hardcoded to "int_1KN8JP7ET3VQQRF7ZQEVNFPJ5". If the script is re-run (e.g., in staging/production reset), the generated ID won't match the constant, silently breaking all AppsFlyer integration lookups. Consider adding a guard or hardcoding the desired ID in the script itself.


6. Minor: isAppsFlyerTrackingUrl Misses Custom OneLink Domains

Only .onelink.me hostnames are matched. Customers using custom OneLink domains (e.g., go.example.com) won't benefit from parameter injection. This is probably an acceptable V1 limitation but worth a // TODO comment.


Summary Table

Issue Severity Breaking?
Hardcoded IP ranges may become stale High No (silent auth failures)
Promise.allSettled silent failures Medium No
Dual dub_id + clickid injection Medium No
pid unconditionally overridden Low No
create-integration.ts ID drift Medium No (deployment risk)
Custom OneLink domains not detected Low No
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
apps/web/app/(ee)/api/appsflyer/webhook/route.ts (1)

16-20: ⚠️ Potential issue | 🔴 Critical

Require publishable_key for installation lookup.

The handler still authenticates on shared AppsFlyer IPs plus appId only. appId is not secret, so an allowed sender that knows an app ID can target another workspace, and findFirst() stays ambiguous if IDs collide. Add publishable_key to querySchema and match on it in the Prisma lookup.

Also applies to: 47-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts around lines 16 - 20, The
handler currently authenticates using only appId (in querySchema) which is
public; add publishable_key: z.string() to the querySchema and update the Prisma
lookup (the call that uses findFirst()) to include publishable_key in its where
clause alongside appId so the installation lookup matches both appId and
publishable_key (e.g., use where: { appId: appId, publishable_key:
publishable_key }) to prevent ambiguous/incorrect workspace matches.
apps/web/lib/integrations/appsflyer/update-settings.ts (1)

55-57: ⚠️ Potential issue | 🟠 Major

Don't let malformed stored settings block the repair path.

InstalledIntegration.settings is free-form JSON. parse() here turns a stale/corrupt blob into a hard failure, so the workspace can't save corrected AppsFlyer settings.

Suggested fix
-    const current = appsFlyerSettingsSchema.parse(
-      installedIntegration.settings ?? {},
-    );
+    const parsedCurrent = appsFlyerSettingsSchema.safeParse(
+      installedIntegration.settings ?? {},
+    );
+    const current = parsedCurrent.success
+      ? parsedCurrent.data
+      : appsFlyerSettingsSchema.parse({});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/integrations/appsflyer/update-settings.ts` around lines 55 - 57,
The call to appsFlyerSettingsSchema.parse on InstalledIntegration.settings can
throw on stale/corrupt JSON and block saving; change the code to validate safely
(e.g., use appsFlyerSettingsSchema.safeParse or wrap parse in try/catch) and
fall back to an empty/default settings object when validation fails so the
repair flow can proceed; update the assignment to current (the variable holding
parsed settings) to use the safe result or default and optionally log a warning
about the malformed stored settings.
apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts (1)

181-191: ⚠️ Potential issue | 🟠 Major

Fail the batch when any per-link update rejects.

Promise.allSettled() is still treated as success here. That makes the success log, cache expiry, and cursor advance lie about progress, so failed links get skipped until someone reruns the job.

Suggested fix
       if (linksToUpdate.length > 0) {
-        await Promise.allSettled(
+        const results = await Promise.allSettled(
           linksToUpdate.map(({ id, link }) =>
             prisma.link.update({
               where: {
                 id,
               },
               data: link,
             }),
           ),
         );
+
+        const rejectedLinkIds = results.flatMap((result, index) =>
+          result.status === "rejected" ? [linksToUpdate[index].id] : [],
+        );
+
+        if (rejectedLinkIds.length > 0) {
+          throw new Error(
+            `Failed to update default partner links: ${rejectedLinkIds.join(", ")}`,
+          );
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/api/cron/groups/update-default-links/route.ts around lines
181 - 191, The batch currently uses Promise.allSettled on linksToUpdate (mapping
to prisma.link.update) but treats the overall result as success; change this so
any per-link rejection fails the batch: either replace Promise.allSettled with
Promise.all so a single rejection will propagate, or keep allSettled but
immediately inspect the settled results and throw an Error if any result.status
=== "rejected"; ensure this change is made around the linksToUpdate ->
prisma.link.update block so the surrounding success log, cache expiry, and
cursor advance only run when all updates succeeded.
🧹 Nitpick comments (4)
apps/web/lib/middleware/utils/get-final-url.ts (2)

54-68: Dual dub_id and clickid injection for AppsFlyer URLs.

When the destination is an AppsFlyer tracking URL and clickId is present, both dub_id (Line 55) and clickid (Line 68) are set to the same value. This is redundant for AppsFlyer URLs since AppsFlyer uses clickid for attribution.

Consider skipping dub_id injection when the URL is an AppsFlyer tracking URL:

♻️ Proposed fix
     } else if (!searchParams.has("dub-no-track")) {
-      urlObj.searchParams.set("dub_id", clickId);
+      // Skip dub_id for AppsFlyer URLs since they use clickid instead
+      if (!isAppsFlyerTrackingUrl(url)) {
+        urlObj.searchParams.set("dub_id", clickId);
+      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/middleware/utils/get-final-url.ts` around lines 54 - 68, The
code currently injects dub_id then later injects clickid for AppsFlyer links
causing redundant params; modify the dub_id injection to skip setting dub_id
when the destination is an AppsFlyer tracking URL by adding a check using
isAppsFlyerTrackingUrl(url) (or isAppsFlyerTrackingUrl(urlObj)) in the block
that sets urlObj.searchParams.set("dub_id", clickId) so dub_id is only added
when not an AppsFlyer URL and still respect the existing
!searchParams.has("dub-no-track") and clickId conditions.

64-65: Add a comment documenting the hardcoded pid override.

Line 65 unconditionally sets pid=dubinc_int, which overwrites any existing pid value in user-configured AppsFlyer URLs. If this is intentional for Dub attribution, a brief comment would clarify the intent for future maintainers.

📝 Suggested documentation
-    // set hardcoded query params
-    urlObj.searchParams.set("pid", "dubinc_int");
+    // Hardcode pid to identify traffic from Dub (overwrites any existing pid)
+    urlObj.searchParams.set("pid", "dubinc_int");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/middleware/utils/get-final-url.ts` around lines 64 - 65, Add a
short explanatory comment above the hardcoded override that sets pid to
"dubinc_int" in get-final-url (i.e., the line using
urlObj.searchParams.set("pid", "dubinc_int")). The comment should state that
this unconditional overwrite is intentional for Dub attribution (or note any
conditions/links to spec), and warn that it will replace user-provided AppsFlyer
pid values so future maintainers understand the reason and can safely change or
remove it.
apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts (1)

147-184: Consider logging rejected promises from Promise.allSettled.

The .filter(isFulfilled) silently discards any generatePartnerLink calls that fail. While Promise.allSettled is appropriate for resilience (one failure shouldn't block others), capturing and logging rejected results would help with debugging and monitoring.

♻️ Proposed enhancement to log failures
     const processedLinks = (
       await Promise.allSettled(
         linksToCreate.map((link) => {
           // ... existing code
         }),
       )
-    )
-      .filter(isFulfilled)
-      .map(({ value }) => value);
+    );
+
+    const fulfilled = processedLinks.filter(isFulfilled).map(({ value }) => value);
+    const rejected = processedLinks.filter(
+      (r): r is PromiseRejectedResult => r.status === "rejected",
+    );
+
+    if (rejected.length > 0) {
+      console.error(
+        `Failed to generate ${rejected.length} partner links:`,
+        rejected.map((r) => r.reason),
+      );
+    }

     const createdLinks = await bulkCreateLinks({
-      links: processedLinks,
+      links: fulfilled,
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/api/cron/groups/remap-default-links/route.ts around lines
147 - 184, The Promise.allSettled for linksToCreate currently filters only
fulfilled results (via isFulfilled) and silently drops rejections; change the
post-processing to capture and log rejected results from the settled array
before mapping fulfilled values: after awaiting Promise.allSettled(...) iterate
the settled results, log each rejected entry (including the associated link
metadata like link.partnerGroupDefaultLinkId, link.domain or link.url and the
rejection reason/error) using the existing logger (or console) so failures from
generatePartnerLink are visible, then continue to filter isFulfilled and map ({
value }) => value to build processedLinks.
apps/web/lib/api/partners/create-partner-default-links.ts (1)

60-91: Same Promise.allSettled silent failure concern.

Consider logging rejected promises here as well for consistency and debuggability. The same pattern improvement suggested for remap-default-links/route.ts would apply here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/api/partners/create-partner-default-links.ts` around lines 60 -
91, The Promise.allSettled in processedLinks silently drops rejections; after
awaiting Promise.allSettled for the
buildPartnerDefaultLinkKey/generatePartnerLink loop, add handling to log any
rejected results with contextual data (defaultLink.id, key, partner, link info)
and the rejection reason so failures aren’t hidden—use the existing logger
(e.g., processLogger or logger) to emit error/warn messages before filtering
with isFulfilled and mapping to value; this keeps generatePartnerLink failures
visible and aids debugging of constructURLFromUTMParams/extractUtmParams related
errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/lib/integrations/appsflyer/constants.ts`:
- Line 42: The APPSFLYER_IP_RANGES constant is hardcoded and will break when
AppsFlyer rotates CIDRs; change APPSFLYER_IP_RANGES to be loaded from a managed
configuration source (e.g., environment variable, remote config service, or
feature-flag store) with a sensible default fallback and validation. Implement a
loader function (e.g., getAppsflyerIpRanges or loadAppsflyerConfig) that parses
and validates CIDRs, expose that value where APPSFLYER_IP_RANGES is currently
used, and add a periodic refresh + logging/alerting hook so rotations are
detected and operators are notified if validation fails. Ensure existing callers
of APPSFLYER_IP_RANGES are updated to use the loader and that the default array
remains available only as a fallback.

In `@apps/web/lib/integrations/appsflyer/ui/settings.tsx`:
- Around line 92-95: Replace the unsafe schema.parse calls with schema.safeParse
and fall back to defaults when validation fails: use
appsFlyerSettingsSchema.safeParse({...APPSFLYER_DEFAULT_SETTINGS, ...(settings
as any)}) and if result.success is false, assign APPSFLYER_DEFAULT_SETTINGS (or
a merged default) to appsFlyerSettings; do the same for the second parse block
(lines 111-121) so invalid stored macro templates or blank values do not throw
and users can recover to defaults. Ensure you still use the validated data when
result.success is true.

In `@apps/web/lib/integrations/appsflyer/update-settings.ts`:
- Around line 96-107: The current publish loop uses Promise.allSettled but
ignores failures, so any rejected qstash.publishJSON calls for
appsFlyerDefaultLinks silently succeed; update the block that maps
appsFlyerDefaultLinks and calls qstash.publishJSON to collect the allSettled
results, filter for rejected entries and either throw an error containing the
failed defaultLinkId(s) or call the workspace logger to record those
defaultLinkId values and the rejection reasons; reference the
appsFlyerDefaultLinks array, qstash.publishJSON calls, APP_DOMAIN_WITH_NGROK URL
and defaultLink.id when building the failure report so the code can surface
which defaultLinkIds need retrying.

In `@apps/web/scripts/create-integration.ts`:
- Around line 7-20: The integration ID is generated with createId() while
APPSFLYER_INTEGRATION_ID is a fixed constant, which causes ID drift on re-runs;
update the prisma.integration.create call to either use the
APPSFLYER_INTEGRATION_ID constant for the id field (if this is a one-time
bootstrap) or replace the create() with an upsert using
prisma.integration.upsert keyed on the slug or APPSFLYER_INTEGRATION_ID so
re-running the script updates the existing record instead of creating a new one;
ensure references to createId(), prisma.integration.create,
APPSFLYER_INTEGRATION_ID and DUB_WORKSPACE_ID are adjusted accordingly.

---

Duplicate comments:
In `@apps/web/app/`(ee)/api/appsflyer/webhook/route.ts:
- Around line 16-20: The handler currently authenticates using only appId (in
querySchema) which is public; add publishable_key: z.string() to the querySchema
and update the Prisma lookup (the call that uses findFirst()) to include
publishable_key in its where clause alongside appId so the installation lookup
matches both appId and publishable_key (e.g., use where: { appId: appId,
publishable_key: publishable_key }) to prevent ambiguous/incorrect workspace
matches.

In `@apps/web/app/`(ee)/api/cron/groups/update-default-links/route.ts:
- Around line 181-191: The batch currently uses Promise.allSettled on
linksToUpdate (mapping to prisma.link.update) but treats the overall result as
success; change this so any per-link rejection fails the batch: either replace
Promise.allSettled with Promise.all so a single rejection will propagate, or
keep allSettled but immediately inspect the settled results and throw an Error
if any result.status === "rejected"; ensure this change is made around the
linksToUpdate -> prisma.link.update block so the surrounding success log, cache
expiry, and cursor advance only run when all updates succeeded.

In `@apps/web/lib/integrations/appsflyer/update-settings.ts`:
- Around line 55-57: The call to appsFlyerSettingsSchema.parse on
InstalledIntegration.settings can throw on stale/corrupt JSON and block saving;
change the code to validate safely (e.g., use appsFlyerSettingsSchema.safeParse
or wrap parse in try/catch) and fall back to an empty/default settings object
when validation fails so the repair flow can proceed; update the assignment to
current (the variable holding parsed settings) to use the safe result or default
and optionally log a warning about the malformed stored settings.

---

Nitpick comments:
In `@apps/web/app/`(ee)/api/cron/groups/remap-default-links/route.ts:
- Around line 147-184: The Promise.allSettled for linksToCreate currently
filters only fulfilled results (via isFulfilled) and silently drops rejections;
change the post-processing to capture and log rejected results from the settled
array before mapping fulfilled values: after awaiting Promise.allSettled(...)
iterate the settled results, log each rejected entry (including the associated
link metadata like link.partnerGroupDefaultLinkId, link.domain or link.url and
the rejection reason/error) using the existing logger (or console) so failures
from generatePartnerLink are visible, then continue to filter isFulfilled and
map ({ value }) => value to build processedLinks.

In `@apps/web/lib/api/partners/create-partner-default-links.ts`:
- Around line 60-91: The Promise.allSettled in processedLinks silently drops
rejections; after awaiting Promise.allSettled for the
buildPartnerDefaultLinkKey/generatePartnerLink loop, add handling to log any
rejected results with contextual data (defaultLink.id, key, partner, link info)
and the rejection reason so failures aren’t hidden—use the existing logger
(e.g., processLogger or logger) to emit error/warn messages before filtering
with isFulfilled and mapping to value; this keeps generatePartnerLink failures
visible and aids debugging of constructURLFromUTMParams/extractUtmParams related
errors.

In `@apps/web/lib/middleware/utils/get-final-url.ts`:
- Around line 54-68: The code currently injects dub_id then later injects
clickid for AppsFlyer links causing redundant params; modify the dub_id
injection to skip setting dub_id when the destination is an AppsFlyer tracking
URL by adding a check using isAppsFlyerTrackingUrl(url) (or
isAppsFlyerTrackingUrl(urlObj)) in the block that sets
urlObj.searchParams.set("dub_id", clickId) so dub_id is only added when not an
AppsFlyer URL and still respect the existing !searchParams.has("dub-no-track")
and clickId conditions.
- Around line 64-65: Add a short explanatory comment above the hardcoded
override that sets pid to "dubinc_int" in get-final-url (i.e., the line using
urlObj.searchParams.set("pid", "dubinc_int")). The comment should state that
this unconditional overwrite is intentional for Dub attribution (or note any
conditions/links to spec), and warn that it will replace user-provided AppsFlyer
pid values so future maintainers understand the reason and can safely change or
remove it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a6d6d4d6-75cd-452f-9dcf-7fcb9af3b8ee

📥 Commits

Reviewing files that changed from the base of the PR and between d703b20 and 4258b85.

📒 Files selected for processing (23)
  • apps/web/app/(ee)/api/appsflyer/webhook/route.ts
  • apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts
  • apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts
  • apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page.tsx
  • apps/web/lib/api/partners/create-partner-default-links.ts
  • apps/web/lib/api/partners/generate-partner-link.ts
  • apps/web/lib/integrations/appsflyer/apply-parameters.ts
  • apps/web/lib/integrations/appsflyer/constants.ts
  • apps/web/lib/integrations/appsflyer/install.ts
  • apps/web/lib/integrations/appsflyer/macro-template.ts
  • apps/web/lib/integrations/appsflyer/schema.ts
  • apps/web/lib/integrations/appsflyer/ui/settings.tsx
  • apps/web/lib/integrations/appsflyer/update-settings.ts
  • apps/web/lib/middleware/link.ts
  • apps/web/lib/middleware/utils/get-final-url.ts
  • apps/web/lib/middleware/utils/is-appsflyer-tracking-url.ts
  • apps/web/lib/plan-capabilities.ts
  • apps/web/scripts/create-integration.ts
  • apps/web/scripts/test-appsflyer.ts
  • apps/web/tests/redirects/index.test.ts
  • packages/utils/src/constants/integrations.ts

Comment thread apps/web/lib/integrations/appsflyer/constants.ts
Comment thread apps/web/lib/integrations/appsflyer/ui/settings.tsx
Comment thread apps/web/lib/integrations/appsflyer/update-settings.ts
Comment thread apps/web/scripts/create-integration.ts
@steven-tey
steven-tey merged commit b98ce3f into main Apr 3, 2026
9 checks passed
@steven-tey
steven-tey deleted the appsflyer branch April 3, 2026 18:34
@coderabbitai coderabbitai Bot mentioned this pull request Apr 11, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jun 8, 2026
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