-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Allow automatic lead event creation in track/sale #2818
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Caution Review failedThe pull request is closed. WalkthroughExtends 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (4)
✨ Finishing Touches
🧪 Generate unit tests
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. Comment |
There was a problem hiding this 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
: NormalizecustomerName
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 lowercasecustomerEmail
.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
: ConstrainclickId
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
: Validatecurrency
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 forleadEventName
: use a shared util.Manual
.toLowerCase().replaceAll(" ", "-")
misses non-ASCII and punctuation cases. Prefer a sharedslugify
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
intowaitUntil
.- 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
📒 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 intotrackSale
— good.apps/web/lib/api/conversions/track-sale.ts (2)
139-144
: MissingclickId
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 abad_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 viatrackSaleResponseSchema
— good.
There was a problem hiding this 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 brittlenessAlign 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 titleMake 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 testUse
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 pathOptional follow-up: exercise creation using only
clickId
(omitcustomerExternalId
, 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
📒 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
, andrandomSaleAmount
are appropriately consumed below.
There was a problem hiding this 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
: ValidatecustomerAvatar
as an http(s) URL to mitigate SSRF.Current
string()
allows file://, data:, and internal URLs thatstorage.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 emptyclickId
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
: NormalizecustomerName
.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
: NormalizecustomerEmail
.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
📒 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
: StrictercustomerExternalId
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.
@coderabbitai full review |
✅ Actions performedFull review triggered. |
Summary by CodeRabbit
New Features
Improvements
Tests