Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Sep 24, 2025

Summary by CodeRabbit

  • Chores
    • Added optional timestamps to links to record when a link was disabled, when it last generated a lead, and when it last produced a conversion.
    • Integrations, imports, and tracking flows now update those last-lead/last-conversion timestamps when relevant events occur.
    • These timestamps are tracked server-side and are not exposed in API responses or the UI; no user action required.

@vercel
Copy link
Contributor

vercel bot commented Sep 24, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 26, 2025 4:47pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 24, 2025

Walkthrough

Added three nullable DateTime fields to the Link model and propagated timestamp updates (lastLeadAt, lastConversionAt, disabledAt) into lead/sale/webhook/import flows; introduced a helper to choose importer timestamps; the API transformer strips these fields from outbound link payloads.

Changes

Cohort / File(s) Summary
Prisma schema
packages/prisma/schema/link.prisma
Added disabledAt DateTime?, lastLeadAt DateTime?, lastConversionAt DateTime? to model Link.
API transformer
apps/web/lib/api/links/utils/transform-link.ts
Remove disabledAt, lastLeadAt, lastConversionAt from outbound link payloads.
Conversion & webhook flows
apps/web/lib/api/conversions/track-lead.ts, apps/web/lib/api/conversions/track-sale.ts, apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts, apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts, apps/web/app/(ee)/api/stripe/integration/utils.ts
Prisma updates now set lastLeadAt and/or lastConversionAt when leads or (first) conversions occur; minor comment changes.
Importer helper
apps/web/lib/api/links/update-link-stats-for-importer.ts
New export updateLinkStatsForImporter({ currentTimestamp, newTimestamp }) returning newTimestamp when it should replace the current one, otherwise undefined.
Importer integrations
apps/web/lib/firstpromoter/..., apps/web/lib/partnerstack/..., apps/web/lib/rewardful/..., apps/web/lib/tolt/..., apps/web/lib/integrations/shopify/...
Added imports/use of updateLinkStatsForImporter or direct timestamp updates; select queries include lastLeadAt where needed; some function signatures extended to accept/pass event timestamps.
Manual partner commission flow
apps/web/lib/actions/partners/create-manual-commission.ts
Capture lead/sale event timestamps and apply updateLinkStatsForImporter to compute lastLeadAt/lastConversionAt when updating link stats during manual commissions.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Event as External Event (webhook/importer/shopify)
    participant Processor as App Processor (track/import/create flows)
    participant DB as Prisma (Link)
    Note over DB: Link schema now includes\n`disabledAt`, `lastLeadAt`, `lastConversionAt`
    Event->>Processor: incoming lead / sale / commission / customer
    Processor->>DB: prisma.link.update({ increments..., lastLeadAt?: date, lastConversionAt?: date })
    alt importer with candidate timestamps
        Processor->>Processor: compute via updateLinkStatsForImporter(current, new)
        Processor->>DB: set returned timestamp when present
    end
    DB-->>Processor: updated Link
    Processor-->>Client: transform-link strips timestamps before response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

I nibble bytes beneath the moonlit stack,
Timestamps tucked in each new track.
A hop for leads, a hop for sale,
My whiskers log each little trail.
Happy hops — metrics on the map! 🐇📈

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ 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 accurately describes two of the new columns added to the Link table and is clear and concise; although it omits the lastLeadAt field, it still references valid changes and avoids vague wording.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-link-columns

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.

@steven-tey steven-tey marked this pull request as ready for review September 26, 2025 04:39
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb2c48c and df14e86.

📒 Files selected for processing (1)
  • packages/prisma/schema/link.prisma (2 hunks)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

304-309: Don’t bump lead stats when no lead was recorded

clickEvent stays truthy even when we find existingCustomer, so we skip recordLead(...) yet still increment leads, push lastLeadAt, and add two units of workspace usage. Returning customers will therefore skew the new timestamp and inflate metrics. Gate these updates on !existingCustomer so we only touch lead stats when a fresh lead was actually created.

-          ...(clickEvent && {
+          ...(clickEvent && !existingCustomer && {
             leads: {
               increment: 1,
             },
             lastLeadAt: new Date(),
           }),
@@
-        usage: {
-          increment: clickEvent ? 2 : 1,
+        usage: {
+          increment: clickEvent && !existingCustomer ? 2 : 1,
         },

Also applies to: 332-334

apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

164-169: lastConversionAt should update on every sale

lastConversionAt is nested under the firstConversionFlag branch, so we never refresh it after the very first conversion. That leaves the new column stuck at the first conversion timestamp and defeats the purpose of tracking the most recent conversion. Please move the timestamp update outside the conditional. Example fix:

         ...(firstConversionFlag && {
           conversions: {
             increment: 1,
           },
-          lastConversionAt: new Date(),
         }),
+        lastConversionAt: new Date(),
apps/web/lib/integrations/shopify/create-sale.ts (1)

87-92: Keep lastConversionAt in sync with every sale

Same issue as above: by keeping lastConversionAt inside the firstConversionFlag branch, the column never reflects sales after the first one. Please lift the timestamp out of that conditional so it always updates. Suggested change:

         ...(firstConversionFlag && {
           conversions: {
             increment: 1,
           },
-          lastConversionAt: new Date(),
         }),
+        lastConversionAt: new Date(),
apps/web/lib/partnerstack/import-commissions.ts (1)

344-355: lastConversionAt must advance even when it’s not a first conversion

Because lastConversionAt sits inside the isFirstConversion block, it only updates for the first unique conversion per customer. Imports for later conversions will leave it stale. Please compute the timestamp separately so it can update whenever newer data comes in:

-        ...(isFirstConversion({
-          customer,
-          linkId: customer.linkId,
-        }) && {
-          conversions: {
-            increment: 1,
-          },
-          lastConversionAt: updateLinkStatsForImporter({
-            currentTimestamp: customer.link.lastConversionAt,
-            newTimestamp: new Date(commission.created_at),
-          }),
-        }),
+        ...(isFirstConversion({
+          customer,
+          linkId: customer.linkId,
+        }) && {
+          conversions: {
+            increment: 1,
+          },
+        }),
+        lastConversionAt: updateLinkStatsForImporter({
+          currentTimestamp: customer.link.lastConversionAt,
+          newTimestamp: new Date(commission.created_at),
+        }),
apps/web/lib/api/conversions/track-sale.ts (1)

464-469: Always refresh lastConversionAt when a sale is recorded

Within _trackSale, lastConversionAt is coupled to the unique-conversion branch, so we stop updating it after the first conversion for a customer. Move the timestamp outside that conditional so the column reflects the latest sale:

         ...(firstConversionFlag && {
           conversions: {
             increment: 1,
           },
-          lastConversionAt: new Date(),
         }),
+        lastConversionAt: new Date(),
apps/web/lib/tolt/import-commissions.ts (1)

356-367: Let importer refresh lastConversionAt for every newer commission

Here too, the timestamp only updates when isFirstConversion is true. Later commissions (even if newer in time) won’t bump lastConversionAt. Please lift the timestamp update outside the conditional block:

-        ...(isFirstConversion({
-          customer: customerFound,
-          linkId: customerFound.linkId,
-        }) && {
-          conversions: {
-            increment: 1,
-          },
-          lastConversionAt: updateLinkStatsForImporter({
-            currentTimestamp: customerFound.link.lastConversionAt,
-            newTimestamp: new Date(commission.created_at),
-          }),
-        }),
+        ...(isFirstConversion({
+          customer: customerFound,
+          linkId: customerFound.linkId,
+        }) && {
+          conversions: {
+            increment: 1,
+          },
+        }),
+        lastConversionAt: updateLinkStatsForImporter({
+          currentTimestamp: customerFound.link.lastConversionAt,
+          newTimestamp: new Date(commission.created_at),
+        }),
apps/web/lib/firstpromoter/import-commissions.ts (1)

352-361: lastConversionAt can regress under concurrent commission imports.

Multiple commissions for the same link run in parallel via Promise.allSettled. Each call reuses the same customer.link.lastConversionAt snapshot and can therefore overwrite the column with an older timestamp. Push the comparison down to Prisma so only newer conversion times land.

-    await Promise.allSettled([
+    const shouldCountConversion = isFirstConversion({
+      customer: customer,
+      linkId: customer.linkId,
+    });
+    const nextLastConversionAt =
+      shouldCountConversion
+        ? updateLinkStatsForImporter({
+            currentTimestamp: customer.link.lastConversionAt,
+            newTimestamp: new Date(commission.created_at),
+          })
+        : undefined;
+
+    await Promise.allSettled([
@@
-      prisma.link.update({
-        where: {
-          id: customer.linkId,
-        },
-        data: {
-          ...(isFirstConversion({
-            customer: customer,
-            linkId: customer.linkId,
-          }) && {
-            conversions: {
-              increment: 1,
-            },
-            lastConversionAt: updateLinkStatsForImporter({
-              currentTimestamp: customer.link.lastConversionAt,
-              newTimestamp: new Date(commission.created_at),
-            }),
-          }),
-          sales: {
-            increment: 1,
-          },
-          saleAmount: {
-            increment: saleAmount,
-          },
-        },
-      }),
+      prisma.link.update({
+        where: {
+          id: customer.linkId,
+        },
+        data: {
+          ...(shouldCountConversion && {
+            conversions: {
+              increment: 1,
+            },
+          }),
+          sales: {
+            increment: 1,
+          },
+          saleAmount: {
+            increment: saleAmount,
+          },
+        },
+      }),
+      nextLastConversionAt
+        ? prisma.link.updateMany({
+            where: {
+              id: customer.linkId,
+              OR: [
+                { lastConversionAt: null },
+                { lastConversionAt: { lt: nextLastConversionAt } },
+              ],
+            },
+            data: {
+              lastConversionAt: nextLastConversionAt,
+            },
+          })
+        : Promise.resolve(),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ece5240 and f4e95dd.

📒 Files selected for processing (17)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (2 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (2 hunks)
  • apps/web/lib/api/conversions/track-lead.ts (1 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (3 hunks)
  • apps/web/lib/api/links/update-link-stats-for-importer.ts (1 hunks)
  • apps/web/lib/firstpromoter/import-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-customers.ts (4 hunks)
  • apps/web/lib/integrations/shopify/create-lead.ts (2 hunks)
  • apps/web/lib/integrations/shopify/create-sale.ts (1 hunks)
  • apps/web/lib/partnerstack/import-commissions.ts (2 hunks)
  • apps/web/lib/partnerstack/import-customers.ts (4 hunks)
  • apps/web/lib/rewardful/import-commissions.ts (2 hunks)
  • apps/web/lib/rewardful/import-customers.ts (2 hunks)
  • apps/web/lib/tolt/import-commissions.ts (2 hunks)
  • apps/web/lib/tolt/import-customers.ts (4 hunks)
👮 Files not reviewed due to content moderation or server errors (2)
  • apps/web/lib/api/conversions/track-lead.ts
  • apps/web/lib/integrations/shopify/create-lead.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/firstpromoter/import-customers.ts
  • apps/web/lib/tolt/import-customers.ts
  • apps/web/lib/partnerstack/import-customers.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
🧬 Code graph analysis (9)
apps/web/lib/tolt/import-commissions.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/firstpromoter/import-customers.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/partnerstack/import-commissions.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/firstpromoter/import-commissions.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/rewardful/import-commissions.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/tolt/import-customers.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/partnerstack/import-customers.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/rewardful/import-customers.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
apps/web/lib/api/links/update-link-stats-for-importer.ts (1)
  • updateLinkStatsForImporter (1-20)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (1)

78-88: lastLeadAt update looks good

Incrementing the lead count while stamping lastLeadAt with the current time keeps the link stats in sync with the new column. No issues spotted.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 26, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/lib/tolt/import-customers.ts (1)

247-259: Make the last-lead timestamp update conditional

Every customer runs through this Promise, so multiple customers on the same link still share the old link.lastLeadAt. If a slower task carries an older latestLeadAt, it can overwrite the fresher value you just wrote. Please hoist the updateLinkStatsForImporter call outside Promise.allSettled and persist through an updateMany with a lastLeadAt IS NULL OR lastLeadAt < candidate guard, like we’ve done in the Rewardful/PartnerStack fixes.

Suggested change:

-    await Promise.all([
+    const nextLastLeadAt = updateLinkStatsForImporter({
+      currentTimestamp: link.lastLeadAt,
+      newTimestamp: latestLeadAt,
+    });
+
+    await Promise.all([
       recordLeadWithTimestamp({
         ...clickEvent,
@@
-      prisma.link.update({
-        where: {
-          id: link.id,
-        },
-        data: {
-          leads: {
-            increment: 1,
-          },
-          lastLeadAt: updateLinkStatsForImporter({
-            currentTimestamp: link.lastLeadAt,
-            newTimestamp: latestLeadAt,
-          }),
-        },
-      }),
+      prisma.link.update({
+        where: { id: link.id },
+        data: {
+          leads: { increment: 1 },
+        },
+      }),
+      nextLastLeadAt
+        ? prisma.link.updateMany({
+            where: {
+              id: link.id,
+              OR: [
+                { lastLeadAt: null },
+                { lastLeadAt: { lt: nextLastLeadAt } },
+              ],
+            },
+            data: { lastLeadAt: nextLastLeadAt },
+          })
+        : Promise.resolve(),
     ]);
apps/web/lib/integrations/shopify/create-sale.ts (1)

87-101: lastConversionAt never updates after the first customer conversion.

Line 92 ties the timestamp update to firstConversionFlag. For returning customers this flag is false, so every subsequent sale leaves lastConversionAt stale even though a conversion just happened. That breaks recency-driven features (dashboards, automations, cleanup jobs) because they’ll believe the link hasn’t converted since the very first buyer.

Please move lastConversionAt outside the firstConversionFlag guard so it updates on every sale while keeping the conversions counter gated.

       data: {
         ...(firstConversionFlag && {
           conversions: {
             increment: 1,
           },
         }),
-        ...(firstConversionFlag && {
-          lastConversionAt: new Date(),
-        }),
+        lastConversionAt: new Date(),
         sales: {
           increment: 1,
         },
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

296-323: lastConversionAt stops moving after a customer’s first Stripe checkout.

On Line 315 the timestamp update is wrapped in the firstConversionFlag block. Any repeat conversion (flag = false) leaves lastConversionAt untouched, so monitoring and lifecycle logic downstream will think the link hasn’t converted since the first customer. Please update the timestamp unconditionally while leaving the conversions counter gated.

           ...(firstConversionFlag && {
             conversions: {
               increment: 1,
             },
-            lastConversionAt: new Date(),
           }),
+          lastConversionAt: new Date(),
apps/web/lib/api/conversions/track-sale.ts (1)

458-476: lastConversionAt only changes on the visitor’s first tracked sale.

Line 469 places the timestamp behind firstConversionFlag, so each subsequent sale by the same customer leaves the field frozen. This distorts “last conversion” analytics and any automation that relies on recency. Please update lastConversionAt every time _trackSale runs, while keeping the conversions counter themselves gated by firstConversionFlag.

         prisma.link.update({
           where: {
             id: saleData.link_id,
           },
           data: {
             ...(firstConversionFlag && {
               conversions: {
                 increment: 1,
               },
-              lastConversionAt: new Date(),
             }),
+            lastConversionAt: new Date(),
             sales: {
               increment: 1,
             },
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

158-169: Use the actual event timestamp instead of “now”

Align lastConversionAt with when the sale occurred (late webhooks happen). Stripe provides epoch seconds.

-          lastConversionAt: new Date(),
+          lastConversionAt: new Date(invoice.created * 1000),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb2c48c and f93e714.

📒 Files selected for processing (19)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (2 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (7 hunks)
  • apps/web/lib/api/conversions/track-lead.ts (1 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (3 hunks)
  • apps/web/lib/api/links/update-link-stats-for-importer.ts (1 hunks)
  • apps/web/lib/api/links/utils/transform-link.ts (1 hunks)
  • apps/web/lib/firstpromoter/import-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-customers.ts (6 hunks)
  • apps/web/lib/integrations/shopify/create-lead.ts (2 hunks)
  • apps/web/lib/integrations/shopify/create-sale.ts (1 hunks)
  • apps/web/lib/partnerstack/import-commissions.ts (2 hunks)
  • apps/web/lib/partnerstack/import-customers.ts (6 hunks)
  • apps/web/lib/rewardful/import-commissions.ts (2 hunks)
  • apps/web/lib/rewardful/import-customers.ts (2 hunks)
  • apps/web/lib/tolt/import-commissions.ts (2 hunks)
  • apps/web/lib/tolt/import-customers.ts (5 hunks)
  • packages/prisma/schema/link.prisma (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/api/conversions/track-lead.ts
  • apps/web/lib/actions/partners/create-manual-commission.ts
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/partnerstack/import-customers.ts
  • apps/web/lib/tolt/import-customers.ts
  • apps/web/lib/rewardful/import-customers.ts
  • apps/web/lib/firstpromoter/import-customers.ts
🔇 Additional comments (14)
apps/web/lib/api/links/utils/transform-link.ts (1)

31-41: Hiding internal timestamps from API response — LGTM

Stripping disabledAt, lastLeadAt, and lastConversionAt here is appropriate and consistent with prior redactions.

packages/prisma/schema/link.prisma (2)

65-66: Migration gap applies to lastLeadAt/lastConversionAt as well

Ensure the same migration includes these fields.

Use the script above; expect to see ALTER TABLE statements adding all three columns in a single migration. If missing, generate one (e.g., pnpm prisma migrate dev --name add-link-timestamps) and commit.


10-10: Blocking: add and commit the Prisma migration for new columns

disabledAt requires a migration. Without it, deploys will fail and columns won’t exist.

Run to verify whether a migration exists in this PR:

#!/bin/bash
set -euo pipefail

echo "Searching for migrations mentioning Link/disabledAt/lastLeadAt/lastConversionAt..."
fd -t d migrations packages/prisma | wc -l

rg -n -C2 -i 'disabledAt|lastLeadAt|lastConversionAt' packages/prisma/migrations || true

echo "Prisma schema snapshot:"
sed -n '1,140p' packages/prisma/schema/link.prisma | sed -n '1,120p'
apps/web/lib/actions/partners/create-manual-commission.ts (1)

140-143: Good: preserving real event timestamps for link stats

Capturing leadEventTimestamp/saleEventTimestamp from the actual events fixes the earlier “use now()” issue in manual/backfill flows.

Also applies to: 230-231, 289-295, 389-391, 431-433

apps/web/lib/api/links/update-link-stats-for-importer.ts (1)

1-20: Avoid regressing timestamps under concurrency; enforce “only move forward” at DB level

This helper bases decisions on a stale in-memory currentTimestamp and can overwrite a newer DB value during concurrent updates. Guard the update in the database (updateMany with a where clause) instead of returning a value to blindly set. Apply the same pattern at call sites.

Proposed addition (keep this function for now, but prefer using these DB-guarded helpers going forward):

// New helpers (exported)
import type { Prisma } from "@dub/prisma";

// Use a transaction client when possible
export async function setLastLeadAtIfNewer(
  tx: Prisma.TransactionClient,
  linkId: string,
  ts: Date,
) {
  return tx.link.updateMany({
    where: { id: linkId, OR: [{ lastLeadAt: null }, { lastLeadAt: { lt: ts } }] },
    data: { lastLeadAt: ts },
  });
}

export async function setLastConversionAtIfNewer(
  tx: Prisma.TransactionClient,
  linkId: string,
  ts: Date,
) {
  return tx.link.updateMany({
    where: {
      id: linkId,
      OR: [{ lastConversionAt: null }, { lastConversionAt: { lt: ts } }],
    },
    data: { lastConversionAt: ts },
  });
}

Then, at call sites, drop lastXAt from prisma.link.update data and invoke one of the helpers in the same Promise.all or transaction.

apps/web/lib/partnerstack/import-commissions.ts (1)

351-355: Guard lastConversionAt update in DB to prevent timestamp regression

This still risks overwriting a newer value under concurrency. Move the update to an updateMany guarded by “NULL or older”.

-          lastConversionAt: updateLinkStatsForImporter({
-            currentTimestamp: customer.link.lastConversionAt,
-            newTimestamp: new Date(commission.created_at),
-          }),

Add a DB-guarded update alongside the existing Promise.all:

   await Promise.all([
     prisma.commission.create({ /* ... */ }),
     recordSaleWithTimestamp({ /* ... */ }),
     // update link stats
     prisma.link.update({ /* ... conversions/sales/saleAmount ... */ }),
+    // ensure lastConversionAt only moves forward
+    isFirstConversion({ customer, linkId: customer.linkId }) &&
+      prisma.link.updateMany({
+        where: {
+          id: customer.linkId,
+          OR: [
+            { lastConversionAt: null },
+            { lastConversionAt: { lt: new Date(commission.created_at) } },
+          ],
+        },
+        data: { lastConversionAt: new Date(commission.created_at) },
+      }),
     // update customer stats
     prisma.customer.update({ /* ... */ }),
   ]);
apps/web/lib/firstpromoter/import-commissions.ts (1)

358-361: Same DB-level guard needed for lastConversionAt

Prevent regressions by updating only when NULL/older via updateMany.

-          lastConversionAt: updateLinkStatsForImporter({
-            currentTimestamp: customer.link.lastConversionAt,
-            newTimestamp: new Date(commission.created_at),
-          }),

Add a guarded update alongside the existing Promise.allSettled:

   await Promise.allSettled([
     prisma.commission.create({ /* ... */ }),
     recordSaleWithTimestamp({ /* ... */ }),
     prisma.link.update({ /* conversions/sales/saleAmount ... */ }),
+    (isFirstConversion({ customer, linkId: customer.linkId }) &&
+      prisma.link.updateMany({
+        where: {
+          id: customer.linkId,
+          OR: [
+            { lastConversionAt: null },
+            { lastConversionAt: { lt: new Date(commission.created_at) } },
+          ],
+        },
+        data: { lastConversionAt: new Date(commission.created_at) },
+      })) as any,
     prisma.customer.update({ /* ... */ }),
   ]);
apps/web/lib/rewardful/import-commissions.ts (1)

352-366: lastConversionAt can still move backwards under concurrent commission imports

Multiple commissions for the same link run this Promise.all in parallel. Each call still reads customerFound.link.lastConversionAt, the helper returns a plain Date, and whichever promise settles last overwrites the column—even if its commission.created_at is older. We need to move the freshness check into the database so concurrent runs can’t regress the timestamp (same issue previously highlighted). Compute the new value once, update counters separately, and use an updateMany with a guard on lastConversionAt.

-  await Promise.all([
+  const shouldCountConversion = isFirstConversion({
+    customer: customerFound,
+    linkId: customerFound.linkId,
+  });
+  const nextLastConversionAt = shouldCountConversion
+    ? updateLinkStatsForImporter({
+        currentTimestamp: customerFound.link.lastConversionAt,
+        newTimestamp: new Date(commission.created_at),
+      })
+    : undefined;
+
+  await Promise.all([
@@
-    prisma.link.update({
-      where: { id: customerFound.linkId },
-      data: {
-        ...(isFirstConversion({
-          customer: customerFound,
-          linkId: customerFound.linkId,
-        }) && {
-          conversions: {
-            increment: 1,
-          },
-          lastConversionAt: updateLinkStatsForImporter({
-            currentTimestamp: customerFound.link.lastConversionAt,
-            newTimestamp: new Date(commission.created_at),
-          }),
-        }),
-        sales: { increment: 1 },
-        saleAmount: { increment: amount },
-      },
-    }),
+    prisma.link.update({
+      where: { id: customerFound.linkId },
+      data: {
+        ...(shouldCountConversion && {
+          conversions: {
+            increment: 1,
+          },
+        }),
+        sales: { increment: 1 },
+        saleAmount: { increment: amount },
+      },
+    }),
+    nextLastConversionAt
+      ? prisma.link.updateMany({
+          where: {
+            id: customerFound.linkId,
+            OR: [
+              { lastConversionAt: null },
+              { lastConversionAt: { lt: nextLastConversionAt } },
+            ],
+          },
+          data: {
+            lastConversionAt: nextLastConversionAt,
+          },
+        })
+      : Promise.resolve(),
apps/web/lib/rewardful/import-customers.ts (1)

222-231: Still need a DB-side guard to keep lastLeadAt monotonic

Promise.all means multiple referrals hitting the same link run this block concurrently. Each invocation captures the same stale link.lastLeadAt, the helper returns a plain Date, and whichever promise resolves last wins. That means an older became_lead_at can overwrite a newer timestamp. We have the same race the earlier review flagged—only moving the comparison into userland didn’t fix it. Please push the “is this fresher?” check into the database (e.g. unconditional update for the counters plus a conditional updateMany for the timestamp) so concurrent imports can’t regress lastLeadAt.

-  await Promise.all([
-    recordLeadWithTimestamp({
-      ...clickEvent,
-      event_id: nanoid(16),
-      event_name: "Sign up",
-      customer_id: customerId,
-      timestamp: new Date(referral.became_lead_at).toISOString(),
-    }),
-
-    prisma.link.update({
-      where: { id: link.id },
-      data: {
-        leads: { increment: 1 },
-        lastLeadAt: updateLinkStatsForImporter({
-          currentTimestamp: link.lastLeadAt,
-          newTimestamp: new Date(referral.became_lead_at),
-        }),
-      },
-    }),
-  ]);
+  const nextLastLeadAt = updateLinkStatsForImporter({
+    currentTimestamp: link.lastLeadAt,
+    newTimestamp: new Date(referral.became_lead_at),
+  });
+
+  await Promise.all([
+    recordLeadWithTimestamp({
+      ...clickEvent,
+      event_id: nanoid(16),
+      event_name: "Sign up",
+      customer_id: customerId,
+      timestamp: new Date(referral.became_lead_at).toISOString(),
+    }),
+    prisma.link.update({
+      where: { id: link.id },
+      data: {
+        leads: { increment: 1 },
+      },
+    }),
+    nextLastLeadAt
+      ? prisma.link.updateMany({
+          where: {
+            id: link.id,
+            OR: [
+              { lastLeadAt: null },
+              { lastLeadAt: { lt: nextLastLeadAt } },
+            ],
+          },
+          data: { lastLeadAt: nextLastLeadAt },
+        })
+      : Promise.resolve(),
+  ]);
apps/web/lib/partnerstack/import-customers.ts (1)

274-286: Guard lastLeadAt against concurrent downgrades

We still have the same race here: every customer update reads the original link.lastLeadAt, so the slowest task can overwrite a newer value. Match the conditional-write pattern (compute once, then conditional updateMany) used in other importers to ensure we only persist strictly newer timestamps.

apps/web/lib/firstpromoter/import-customers.ts (1)

278-282: Protect lastLeadAt from regressing under concurrency

This block still writes back whatever the stale snapshot said was “current,” so an older customer processed later can clobber a newer timestamp. Apply the same compute-and-conditional-update approach we’ve been standardizing on to keep lastLeadAt monotonic.

apps/web/lib/api/conversions/track-lead.ts (1)

281-286: Lead recency capture looks good.

Setting lastLeadAt alongside the existing lead counter preserves freshness for real-time leads.

apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts (1)

78-88: Stripe-created lead now updates recency correctly.

The timestamp pairs nicely with the counter increment, so this path will surface when the most recent lead arrived.

apps/web/lib/integrations/shopify/create-lead.ts (1)

70-82: Shopify lead path keeps recency in sync.

Updating lastLeadAt whenever a lead is recorded ensures the link’s fresh-lead metadata stays accurate.

@steven-tey steven-tey merged commit bf0ce50 into main Sep 26, 2025
8 checks passed
@steven-tey steven-tey deleted the add-link-columns branch September 26, 2025 17:04
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.

3 participants