+
Skip to content

Conversation

devkiran
Copy link
Collaborator

@devkiran devkiran commented Sep 9, 2025

Summary by CodeRabbit

  • New Features

    • Sale-tracking endpoint accepts optional customerName, customerEmail, customerAvatar, clickId and metadata; can create customers from click events when no prior lead exists; avatars persisted when needed.
    • trackSale now returns enriched sale payloads and publishes lead.created and sale.created events.
  • Improvements

    • Idempotent invoice handling, non‑USD → USD conversion, stronger validation for customer identifiers, preserved legacy ID support, improved logging/metrics, partner commissions, and workflow reliability.
  • Tests

    • Added end-to-end test for sale tracking without a prior lead event.

Copy link
Contributor

vercel bot commented Sep 9, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 9, 2025 10:34pm

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Extends sale-tracking inputs with customerName, customerEmail, customerAvatar, clickId, and metadata; route parses and forwards them. Refactors trackSale to add Redis idempotency, conditional create-from-click customer flow, parallel lead+sale processing, avatar persistence, event transforms/webhooks, and an E2E click→sale test.

Changes

Cohort / File(s) Summary
API route: sale tracking
apps/web/app/(ee)/api/track/sale/route.ts
Route parsing extended to accept customerName, customerEmail, customerAvatar, clickId, and metadata; forwards new fields to trackSale. Legacy external-id fallbacks and error/control flow preserved.
Core tracking logic
apps/web/lib/api/conversions/track-sale.ts
Major refactor: trackSale signature expanded with clickId, customerName, customerEmail, customerAvatar. Adds Redis idempotency for invoiceId, conditional path to create customers from click events (including avatar persistence), lead lookup/caching, modular _trackLead / _trackSale helpers, parallel lead+sale execution, currency conversion, partner commission/workflow hooks, and webhook/event transforms.
Schema updates
apps/web/lib/zod/schemas/sales.ts
trackSaleRequestSchema extended with clickId, customerName, customerEmail, customerAvatar, and metadata; customerExternalId validation tightened (.min(1,...)); new fields are nullish/defaulted and documented.
Tests & test helpers
apps/web/tests/tracks/track-sale.test.ts, tests/utils/helpers, tests/utils/resource
Adds randomCustomer() helper and E2E_TRACK_CLICK_HEADERS; introduces E2E test for tracking a sale sourced from a prior click (no pre-existing lead), asserting customer fields, sale fields, and response shape.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant API_Route as "API Route"
  participant Track as "trackSale"
  participant Redis
  participant ClickStore as "Click Store / DB"
  participant Storage as "R2 Storage"
  participant DB
  participant Webhooks

  Client->>API_Route: POST /api/track/sale {amount,currency,invoiceId,customerExternalId?,customerName?,customerEmail?,customerAvatar?,clickId?,metadata?}
  API_Route->>Track: trackSale(parsedBody)

  rect rgba(230,240,255,0.35)
    note right of Track: Idempotency check
    Track->>Redis: isProcessed(invoiceId / clickId)?
    Redis-->>Track: hit / miss
    alt hit
      Track-->>API_Route: return cached/null result
      API_Route-->>Client: 200 JSON
    end
  end

  rect rgba(240,255,230,0.35)
    note right of Track: Resolve or create customer
    alt customerExternalId present
      Track->>DB: fetch customer & leadEvent
      DB-->>Track: customer (+lead?) / not_found
    else clickId provided
      Track->>ClickStore: getClickEvent(clickId)
      ClickStore-->>Track: click event / not_found
      Track->>Storage: persist avatar (if provided)
      Storage-->>Track: avatar URL
      Track->>DB: create customer (cus_*)
    end
  end

  rect rgba(255,245,230,0.35)
    note right of Track: Parallel lead & sale processing
    Track->>DB: recordLead (if needed)
    Track->>DB: recordSale (convert currency if needed)
    Track->>DB: update metrics (link/workspace/customer)
    Track->>DB: create partner commission / trigger workflows
    Track-->>Webhooks: publish lead.created / sale.created
  end

  Track-->>API_Route: tracked sale response
  API_Route-->>Client: 200 JSON
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I hopped from click to sale with cheer,
Saved avatars and IDs near and dear.
No duplicate invoices, metrics fine—hooray!
Webhooks sing and leads bloom today.
Signed, a rabbit who tracked the way. 🥕🐇


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a83eb20 and 1673578.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/track/sale/route.ts (2 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (12 hunks)
  • apps/web/lib/zod/schemas/sales.ts (2 hunks)
  • apps/web/tests/tracks/track-sale.test.ts (2 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch customer-props-track-sale

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.

@devkiran devkiran marked this pull request as ready for review September 9, 2025 17:58
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: 4

Caution

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

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/api/track/sale/route.ts (1)

30-36: Preserve trimming/non-empty semantics in overrides.

Overriding the schema here drops .trim().min(1), allowing whitespace-only IDs to slip through. Keep nullish for b/c but retain trimming and non-empty validation.

-      .extend({
-        // add backwards compatibility
-        customerExternalId: z.string().nullish(),
-        externalId: z.string().nullish(),
-        customerId: z.string().nullish(),
-      })
+      .extend({
+        // add backwards compatibility while preserving validation
+        customerExternalId: z.string().trim().min(1).nullish(),
+        externalId: z.string().trim().min(1).nullish(),
+        customerId: z.string().trim().min(1).nullish(),
+      })
🧹 Nitpick comments (8)
apps/web/lib/zod/schemas/sales.ts (4)

16-23: Normalize customerName by trimming.

Helps avoid storing accidental leading/trailing spaces.

-  customerName: z
-    .string()
+  customerName: z
+    .string()
+    .trim()
     .max(100)
     .nullish()
     .default(null)

24-30: Trim and lowercase customerEmail.

Emails are case-insensitive; normalizing reduces duplicates.

-  customerEmail: z
-    .string()
-    .email()
-    .max(100)
+  customerEmail: z
+    .string()
+    .trim()
+    .email()
+    .max(100)
+    .transform((s) => s.toLowerCase())
     .nullish()
     .default(null)

36-42: Constrain clickId when present.

Add a minimal length (or a tighter pattern if you have one) to catch obvious bad inputs.

-  clickId: z
-    .string()
-    .trim()
-    .nullish()
+  clickId: z
+    .string()
+    .trim()
+    .min(1, "clickId cannot be empty")
+    .nullish()

51-56: Validate currency format.

Ensure ISO-like shape locally; conversion errors then become less likely downstream.

-  currency: z
-    .string()
+  currency: z
+    .string()
+    .length(3, "currency must be a 3-letter ISO code")
     .default("usd")
     .transform((val) => val.toLowerCase())
apps/web/app/(ee)/api/track/sale/route.ts (1)

40-45: Guard against whitespace-only IDs.

Use .trim() before the check so " " is rejected.

-    if (!customerExternalId) {
+    if (!customerExternalId?.trim()) {
       throw new DubApiError({
         code: "bad_request",
         message: "customerExternalId is required",
       });
     }
apps/web/lib/api/conversions/track-sale.ts (3)

60-74: Invoice idempotency TTL is short; consider configurability.

Seven days may be insufficient for retries/backfills. Make TTL configurable or longer to reduce accidental duplicates outside the window.

-    const ok = await redis.set(`dub_sale_events:invoiceId:${invoiceId}`, 1, {
-      ex: 60 * 60 * 24 * 7,
+    const ttl = Number(process.env.SALES_IDEMPOTENCY_TTL_SECONDS ?? 60 * 60 * 24 * 30);
+    const ok = await redis.set(`dub_sale_events:invoiceId:${invoiceId}`, 1, {
+      ex: ttl,
       nx: true,
     });

98-104: Slug building for leadEventName: use a shared util.

Manual .toLowerCase().replaceAll(" ", "-") misses non-ASCII and punctuation cases. Prefer a shared slugify helper if available.

-        `leadCache:${existingCustomer.id}${leadEventName ? `:${leadEventName.toLowerCase().replaceAll(" ", "-")}` : ""}`,
+        `leadCache:${existingCustomer.id}${leadEventName ? `:${slugify(leadEventName)}` : ""}`,

If no slugifier exists, consider adding one under a common utils module.


248-271: Optional: reduce latency by backgrounding lead recording.

You can return the sale response faster by awaiting only _trackSale and moving _trackLead into waitUntil.

-  const [_, trackedSale] = await Promise.all([
-    newCustomer &&
-      _trackLead({
-        workspace,
-        leadEventData,
-        customer: newCustomer,
-      }),
-
-    _trackSale({
+  const trackedSale = await _trackSale({
       amount,
       currency,
       eventName,
       paymentProcessor,
       invoiceId,
       metadata,
       rawBody,
       workspace,
       leadEventData,
       customer,
-    }),
-  ]);
+  });
+
+  if (newCustomer) {
+    waitUntil(
+      _trackLead({
+        workspace,
+        leadEventData,
+        customer: newCustomer,
+      }),
+    );
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cf20d98 and e9364ef.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/track/sale/route.ts (2 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (11 hunks)
  • apps/web/lib/zod/schemas/sales.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/conversions/track-sale.ts (9)
apps/web/lib/types.ts (3)
  • Customer (392-392)
  • ClickEventTB (522-522)
  • LeadEventTB (524-524)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/lib/api/create-id.ts (1)
  • createId (60-69)
packages/utils/src/constants/main.ts (1)
  • R2_URL (81-81)
apps/web/lib/partners/create-partner-commission.ts (1)
  • createPartnerCommission (25-341)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
apps/web/lib/webhook/transform.ts (2)
  • transformLeadEventData (60-78)
  • transformSaleEventData (80-106)
apps/web/lib/webhook/publish.ts (1)
  • sendWorkspaceWebhook (7-36)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
⏰ 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: Vade Review
🔇 Additional comments (5)
apps/web/lib/zod/schemas/sales.ts (1)

11-11: Be aware: route override circumvents this .min(1) check.

In the route, trackSaleRequestSchema.extend({ customerExternalId: z.string().nullish() }) replaces this validator entirely. If you intend to preserve trimming and non-empty semantics while allowing nullish for backwards-compat, mirror .trim().min(1) in the route override. See route comment for a diff.

apps/web/app/(ee)/api/track/sale/route.ts (2)

18-22: New fields parsed and forwarded — looks good.


47-53: Forwarding new fields into trackSale — good.

apps/web/lib/api/conversions/track-sale.ts (2)

139-144: Missing clickId returns 200 with null payload — confirm API contract.

This silently no-ops instead of 4xx. If clients expect an error for missing clickId when creating a new customer, return a bad_request to surface misuse.

-      return {
-        eventName,
-        customer: null,
-        sale: null,
-      };
+      throw new DubApiError({
+        code: "bad_request",
+        message:
+          "clickId is required when customerExternalId does not match an existing customer.",
+      });

530-541: Response parsing via trackSaleResponseSchema — good.

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: 0

🧹 Nitpick comments (5)
apps/web/tests/tracks/track-sale.test.ts (5)

230-238: Match referer header to the short link to reduce brittleness

Align the referer with the domain/key under test so future referer-dependent logic doesn’t break this test.

-    const clickResponse = await http.post<{ clickId: string }>({
+    const clickResponse = await http.post<{ clickId: string }>({
       path: "/track/click",
-      headers: E2E_TRACK_CLICK_HEADERS,
+      headers: { ...E2E_TRACK_CLICK_HEADERS, referer: "https://getacme.link/derek" },
       body: {
         domain: "getacme.link",
         key: "derek",
       },
     });

230-230: Clarify test intent in title

Make the behavior explicit: auto-creating a lead from a clickId.

-  test("track a sale without a pre-existing lead event", async () => {
+  test("auto-creates lead from clickId when none exists", async () => {

262-276: Relax equality to allow additive fields without breaking the test

Use objectContaining to keep the assertion resilient to new response fields while still being strict about what matters.

-    expect(response.status).toEqual(200);
-    expect(response.data).toStrictEqual({
-      eventName: salePayload.eventName,
-      customer: {
-        id: expect.any(String),
-        ...saleCustomer,
-      },
-      sale: {
-        amount: salePayload.amount,
-        currency: salePayload.currency,
-        paymentProcessor: salePayload.paymentProcessor,
-        invoiceId: salePayload.invoiceId,
-        metadata: null,
-      },
-    });
+    expect(response.status).toEqual(200);
+    expect(response.data).toEqual(
+      expect.objectContaining({
+        eventName: salePayload.eventName,
+        customer: expect.objectContaining({
+          id: expect.any(String),
+          ...saleCustomer,
+        }),
+        sale: expect.objectContaining({
+          amount: salePayload.amount,
+          currency: salePayload.currency,
+          paymentProcessor: salePayload.paymentProcessor,
+          invoiceId: salePayload.invoiceId,
+          metadata: null,
+        }),
+      }),
+    );

250-259: Consider adding a companion test: clickId-only path

Optional follow-up: exercise creation using only clickId (omit customerExternalId, name, email, avatar) to assert pure auto-lead creation works.

I can draft that test if helpful.


231-233: Use shared trackClickResponseSchema for the response type
Instead of the inline { clickId: string }, import and infer the Zod schema’s type:

import { trackClickResponseSchema } from 'app/(ee)/api/track/click/route';

type TrackClickResponse = z.infer<typeof trackClickResponseSchema>;

const clickResponse = await http.post<TrackClickResponse>({
  path: "/track/click",
  headers: E2E_TRACK_CLICK_HEADERS,
  // …
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 760570f and f1c586c.

📒 Files selected for processing (2)
  • apps/web/lib/api/conversions/track-sale.ts (12 hunks)
  • apps/web/tests/tracks/track-sale.test.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/api/conversions/track-sale.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/tests/tracks/track-sale.test.ts (3)
apps/web/tests/utils/resource.ts (1)
  • E2E_TRACK_CLICK_HEADERS (10-14)
apps/web/tests/utils/helpers.ts (3)
  • randomCustomer (7-17)
  • randomSaleAmount (31-33)
  • randomId (4-4)
apps/web/lib/types.ts (1)
  • TrackSaleResponse (390-390)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (1)
apps/web/tests/tracks/track-sale.test.ts (1)

2-6: LGTM: helper imports are correct and used

randomCustomer, randomId, and randomSaleAmount are appropriately consumed below.

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: 1

♻️ Duplicate comments (1)
apps/web/lib/zod/schemas/sales.ts (1)

93-99: Validate customerAvatar as an http(s) URL to mitigate SSRF.

Current string() allows file://, data:, and internal URLs that storage.upload might fetch. Enforce URL + http(s) scheme and cap length.

-  customerAvatar: z
-    .string()
+  customerAvatar: z
+    .string()
+    .trim()
+    .url({ message: "customerAvatar must be a valid URL" })
+    .refine((u) => /^https?:\/\//.test(u), {
+      message: "customerAvatar must be http(s)",
+    })
+    .max(2048)
     .nullish()
     .default(null)
🧹 Nitpick comments (6)
apps/web/lib/zod/schemas/sales.ts (4)

50-59: Make metadata validation resilient to non-serializable objects.

JSON.stringify in .refine can throw on circular structures. Catch it to surface a clean error.

Apply:

-  metadata: z
-    .record(z.unknown())
-    .nullish()
-    .default(null)
-    .refine((val) => !val || JSON.stringify(val).length <= 10000, {
-      message: "Metadata must be less than 10,000 characters when stringified",
-    })
+  metadata: z
+    .record(z.unknown())
+    .nullish()
+    .default(null)
+    .refine((val) => {
+      if (!val) return true;
+      try {
+        return JSON.stringify(val).length <= 10_000;
+      } catch {
+        return false;
+      }
+    }, {
+      message:
+        "Metadata must be JSON-serializable and <= 10,000 characters when stringified",
+    })

69-76: Prevent empty clickId strings.

Whitespace-only values currently pass and become empty after .trim(). Enforce non-empty when provided.

-  clickId: z
-    .string()
-    .trim()
-    .nullish()
+  clickId: z
+    .string()
+    .trim()
+    .min(1, "clickId cannot be empty")
+    .nullish()

77-83: Normalize customerName.

Trim and disallow empty names when provided.

-  customerName: z
-    .string()
-    .max(100)
+  customerName: z
+    .string()
+    .trim()
+    .min(1, "customerName cannot be empty")
+    .max(100)

85-92: Normalize customerEmail.

Trim and lowercase for canonicalization. Keeps comparisons/idempotency consistent.

-  customerEmail: z
-    .string()
-    .email()
-    .max(100)
+  customerEmail: z
+    .string()
+    .trim()
+    .toLowerCase()
+    .email()
+    .max(100)
apps/web/lib/api/conversions/track-sale.ts (2)

228-240: Harden avatar upload scheduling.

Wrap in a try/catch to avoid unhandled rejections in the background task and add minimal context to logs.

-    if (customerAvatar && !isStored(customerAvatar) && finalCustomerAvatar) {
-      // persist customer avatar to R2 if it's not already stored
-      waitUntil(
-        storage.upload(
-          finalCustomerAvatar.replace(`${R2_URL}/`, ""),
-          customerAvatar,
-          {
-            width: 128,
-            height: 128,
-          },
-        ),
-      );
-    }
+    if (customerAvatar && !isStored(customerAvatar) && finalCustomerAvatar) {
+      // persist customer avatar to R2 if it's not already stored
+      waitUntil(
+        (async () => {
+          try {
+            await storage.upload(
+              finalCustomerAvatar.replace(`${R2_URL}/`, ""),
+              customerAvatar,
+              { width: 128, height: 128 },
+            );
+          } catch (err) {
+            console.error("avatar upload failed", {
+              customerId: finalCustomerId,
+              clickId: clickData.click_id,
+              err,
+            });
+          }
+        })(),
+      );
+    }

39-54: Add runtime input validation and defaults in trackSale
trackSale is called directly from apps/web/lib/integrations/singular/track-sale.ts (and other non-route layers), so runtime defaults (e.g. eventName = "Purchase") won’t be applied if callers omit them. Define defaults in the function signature or parse/validate inputs inside trackSale (for example via a Zod schema) to guarantee required fields.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f1c586c and 1673578.

📒 Files selected for processing (2)
  • apps/web/lib/api/conversions/track-sale.ts (12 hunks)
  • apps/web/lib/zod/schemas/sales.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/conversions/track-sale.ts (10)
apps/web/lib/types.ts (3)
  • Customer (392-392)
  • ClickEventTB (522-522)
  • LeadEventTB (524-524)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/lib/api/create-id.ts (1)
  • createId (60-69)
packages/utils/src/constants/main.ts (1)
  • R2_URL (81-81)
apps/web/lib/partners/create-partner-commission.ts (1)
  • createPartnerCommission (25-341)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (17-122)
apps/web/lib/webhook/transform.ts (2)
  • transformLeadEventData (60-78)
  • transformSaleEventData (80-106)
apps/web/lib/webhook/publish.ts (1)
  • sendWorkspaceWebhook (7-36)
apps/web/lib/analytics/is-first-conversion.ts (1)
  • isFirstConversion (3-23)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (6)
apps/web/lib/zod/schemas/sales.ts (2)

11-11: Stricter customerExternalId is good.

The .min(1, ...) closes the empty-string hole. LGTM.


66-67: Description update reads well.

Clearer guidance for the “no pre-existing lead event” path. 👍

apps/web/lib/api/conversions/track-sale.ts (4)

60-74: Invoice idempotency LGTM.

Correct NX pattern, 7-day TTL, consistent early-return shape.


272-295: Parallel lead/sale execution and response return look correct.

Lead is only recorded for newly created customers; sale returns parsed response. 👍


530-541: Webhook payload composition LGTM.

Includes clickedAt fallback and transformed link/customer. Matches transform contract.


86-124: No changes needed: producer and consumer use identical slug transformations for lead cache keys.

@steven-tey
Copy link
Collaborator

@coderabbitai full review

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

✅ Actions performed

Full review triggered.

@steven-tey steven-tey merged commit 88105dc into main Sep 9, 2025
9 checks passed
@steven-tey steven-tey deleted the customer-props-track-sale branch September 9, 2025 22:43
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

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载