From b902600193ffec5bbc04e0ed1e74d052d1c80636 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 7 Sep 2025 18:10:48 -0700 Subject: [PATCH 1/9] Add support for `mode="deferred"` in `/track/lead` --- apps/web/lib/api/conversions/track-lead.ts | 188 ++++++++++++--------- apps/web/lib/api/conversions/track-sale.ts | 12 +- apps/web/lib/zod/schemas/leads.ts | 11 +- 3 files changed, 112 insertions(+), 99 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 8477610539a..ef99a487194 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -39,6 +39,27 @@ export const trackLead = async ({ rawBody, workspace, }: TrackLeadParams) => { + if (!clickId) { + const existingCustomer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: workspace.id, + externalId: customerExternalId, + }, + }, + }); + + if (!existingCustomer || !existingCustomer.clickId) { + throw new DubApiError({ + code: "bad_request", + message: + "The `clickId` attribute was not provided in the request, and no existing customer with the provided `customerExternalId` was found.", + }); + } + + clickId = existingCustomer.clickId; + } + const stringifiedEventName = eventName.toLowerCase().replaceAll(" ", "-"); // deduplicate lead events – only record 1 unique event for the same customer and event name @@ -173,9 +194,8 @@ export const trackLead = async ({ let customer: Customer | undefined; - // Handle customer creation and lead recording based on mode + // if wait mode, create the customer and record the lead event synchronously if (mode === "wait") { - // Execute customer creation synchronously customer = await upsertCustomer(); const leadEventPayload = createLeadEventPayload(customer.id); @@ -204,71 +224,22 @@ export const trackLead = async ({ waitUntil( (async () => { - // For async mode, create customer in the background - if (mode === "async") { + // for async and deferred mode, create the customer in the background + if (mode == "async" || mode == "deferred") { customer = await upsertCustomer(); - // Use recordLead which doesn't wait - await recordLead(createLeadEventPayload(customer.id)); + // for async mode, record the lead event right away + if (mode === "async") { + await recordLead(createLeadEventPayload(customer.id)); + } } - // Always process link/project updates, partner rewards, and webhooks in the background - const [link, _project] = await Promise.all([ - // update link leads count - prisma.link.update({ - where: { - id: clickData.link_id, - }, - data: { - leads: { - increment: eventQuantity ?? 1, - }, - }, - include: includeTags, - }), - - // update workspace events usage - prisma.project.update({ - where: { - id: workspace.id, - }, - data: { - usage: { - increment: eventQuantity ?? 1, - }, - }, - }), - - logConversionEvent({ - workspace_id: workspace.id, - link_id: clickData.link_id, - path: "/track/lead", - body: JSON.stringify(rawBody), - }), - ]); - - if (link.programId && link.partnerId && customer) { - await createPartnerCommission({ - event: "lead", - programId: link.programId, - partnerId: link.partnerId, - linkId: link.id, - eventId: leadEventId, - customerId: customer.id, - quantity: eventQuantity ?? 1, - context: { - customer: { - country: customer.country, - }, - }, - }); - - await executeWorkflows({ - trigger: WorkflowTrigger.leadRecorded, - programId: link.programId, - partnerId: link.partnerId, - }); - } + await logConversionEvent({ + workspace_id: workspace.id, + link_id: clickData.link_id, + path: "/track/lead", + body: JSON.stringify(rawBody), + }); if ( customerAvatar && @@ -286,21 +257,81 @@ export const trackLead = async ({ ); } - await sendWorkspaceWebhook({ - trigger: "lead.created", - data: transformLeadEventData({ - ...clickData, - eventName, - link, - customer, - }), - workspace, - }); + // if not deferred mode, process the following right away: + // - update link leads count + // - update workspace events usage + // - update customer leads count + // - create partner commission (for partner links) + // - execute workflows (for partner links) + // - send lead.created webhook + + if (mode !== "deferred") { + const [link, _project] = await Promise.all([ + // update link leads count + prisma.link.update({ + where: { + id: clickData.link_id, + }, + data: { + leads: { + increment: eventQuantity ?? 1, + }, + }, + include: includeTags, + }), + + // update workspace events usage + prisma.project.update({ + where: { + id: workspace.id, + }, + data: { + usage: { + increment: eventQuantity ?? 1, + }, + }, + }), + ]); + + if (link.programId && link.partnerId && customer) { + await createPartnerCommission({ + event: "lead", + programId: link.programId, + partnerId: link.partnerId, + linkId: link.id, + eventId: leadEventId, + customerId: customer.id, + quantity: eventQuantity ?? 1, + context: { + customer: { + country: customer.country, + }, + }, + }); + + await executeWorkflows({ + trigger: WorkflowTrigger.leadRecorded, + programId: link.programId, + partnerId: link.partnerId, + }); + } + + await sendWorkspaceWebhook({ + trigger: "lead.created", + data: transformLeadEventData({ + ...clickData, + eventName, + link, + customer, + }), + workspace, + }); + } })(), ); } - const lead = trackLeadResponseSchema.parse({ + return trackLeadResponseSchema.parse({ click: { id: clickId, }, @@ -311,13 +342,4 @@ export const trackLead = async ({ externalId: customerExternalId, }, }); - - return { - ...lead, - // for backwards compatibility – will remove soon - clickId, - customerName: finalCustomerName, - customerEmail: customerEmail, - customerAvatar: finalCustomerAvatar, - }; }; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 896fb716061..99c5d449b1e 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -262,7 +262,7 @@ export const trackSale = async ({ })(), ); - const sale = trackSaleResponseSchema.parse({ + return trackSaleResponseSchema.parse({ eventName, customer, sale: { @@ -273,14 +273,4 @@ export const trackSale = async ({ metadata, }, }); - - return { - ...sale, - // for backwards compatibility – will remove soon - amount, - currency, - invoiceId, - paymentProcessor, - metadata, - }; }; diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index ee89bc0e55c..a6ffc0a7914 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -6,14 +6,14 @@ import { linkEventSchema } from "./links"; export const trackLeadRequestSchema = z.object({ clickId: z - .string({ required_error: "clickId is required" }) + .string() .trim() - .min(1, "clickId is required") + .nullish() .describe( "The unique ID of the click that the lead conversion event is attributed to. You can read this value from `dub_id` cookie.", ), eventName: z - .string({ required_error: "eventName is required" }) + .string() .trim() .min(1, "eventName is required") .max(255) @@ -24,6 +24,7 @@ export const trackLeadRequestSchema = z.object({ customerExternalId: z .string() .trim() + .min(1, "customerExternalId is required") .max(100) .describe( "The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.", @@ -55,10 +56,10 @@ export const trackLeadRequestSchema = z.object({ "The numerical value associated with this lead event (e.g., number of provisioned seats in a free trial). If defined as N, the lead event will be tracked N times.", ), mode: z - .enum(["async", "wait"]) + .enum(["async", "wait", "deferred"]) .default("async") .describe( - "The mode to use for tracking the lead event. `async` will not block the request; `wait` will block the request until the lead event is fully recorded in Dub.", + "The mode to use for tracking the lead event. `async` will not block the request; `wait` will block the request until the lead event is fully recorded in Dub; `deferred` will defer the lead event creation to a subsequent request.", ), metadata: z .record(z.unknown()) From fcbbbd67662c2b20b3f144dafa12dd20f216f083 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 7 Sep 2025 18:24:33 -0700 Subject: [PATCH 2/9] update response messages --- apps/web/lib/api/conversions/track-lead.ts | 12 ++++++++---- apps/web/lib/zod/schemas/leads.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index ef99a487194..5627b53e86e 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -53,7 +53,7 @@ export const trackLead = async ({ throw new DubApiError({ code: "bad_request", message: - "The `clickId` attribute was not provided in the request, and no existing customer with the provided `customerExternalId` was found.", + "The `clickId` property was not provided in the request, and no existing customer with the provided `customerExternalId` was found.", }); } @@ -140,7 +140,7 @@ export const trackLead = async ({ if (link.projectId !== workspace.id) { throw new DubApiError({ code: "not_found", - message: `Link does not belong to the workspace`, + message: `Link for clickId ${clickId} does not belong to the workspace`, }); } @@ -225,15 +225,19 @@ export const trackLead = async ({ waitUntil( (async () => { // for async and deferred mode, create the customer in the background - if (mode == "async" || mode == "deferred") { + if (mode === "async" || mode === "deferred") { customer = await upsertCustomer(); + console.log(`customer created: ${JSON.stringify(customer, null, 2)}`); // for async mode, record the lead event right away + // for deferred mode, we defer the lead event creation to a subsequent request if (mode === "async") { - await recordLead(createLeadEventPayload(customer.id)); + const res = await recordLead(createLeadEventPayload(customer.id)); + console.log("lead event recorded:", res); } } + // track the conversion event await logConversionEvent({ workspace_id: workspace.id, link_id: clickData.link_id, diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index a6ffc0a7914..7eeb73be3d6 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -10,7 +10,7 @@ export const trackLeadRequestSchema = z.object({ .trim() .nullish() .describe( - "The unique ID of the click that the lead conversion event is attributed to. You can read this value from `dub_id` cookie.", + "The unique ID of the click that the lead conversion event is attributed to. You can read this value from `dub_id` cookie. If not provided, Dub will try to find an existing customer with the provided `customerExternalId` and use the `clickId` from the customer if found.", ), eventName: z .string() @@ -49,18 +49,18 @@ export const trackLeadRequestSchema = z.object({ .nullish() .default(null) .describe("The avatar URL of the customer."), - eventQuantity: z - .number() - .nullish() - .describe( - "The numerical value associated with this lead event (e.g., number of provisioned seats in a free trial). If defined as N, the lead event will be tracked N times.", - ), mode: z .enum(["async", "wait", "deferred"]) .default("async") .describe( "The mode to use for tracking the lead event. `async` will not block the request; `wait` will block the request until the lead event is fully recorded in Dub; `deferred` will defer the lead event creation to a subsequent request.", ), + eventQuantity: z + .number() + .nullish() + .describe( + "The numerical value associated with this lead event (e.g., number of provisioned seats in a free trial). If defined as N, the lead event will be tracked N times.", + ), metadata: z .record(z.unknown()) .nullish() From 305047ce468e4a1a4f6499087e1bf13cf3b50bee Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 7 Sep 2025 19:47:08 -0700 Subject: [PATCH 3/9] restructure track lead, add tests --- apps/web/lib/api/conversions/track-lead.ts | 148 ++++++++++----------- apps/web/lib/api/conversions/track-sale.ts | 12 +- apps/web/tests/tracks/track-lead.test.ts | 44 +++++- 3 files changed, 124 insertions(+), 80 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 5627b53e86e..db2d1e1cdc8 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -15,7 +15,7 @@ import { trackLeadResponseSchema, } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; -import { Customer, WorkflowTrigger } from "@dub/prisma/client"; +import { WorkflowTrigger } from "@dub/prisma/client"; import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { z } from "zod"; @@ -39,17 +39,20 @@ export const trackLead = async ({ rawBody, workspace, }: TrackLeadParams) => { - if (!clickId) { - const existingCustomer = await prisma.customer.findUnique({ - where: { - projectId_externalId: { - projectId: workspace.id, - externalId: customerExternalId, - }, + // try to find the customer to use if it exists + let customer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: workspace.id, + externalId: customerExternalId, }, - }); + }, + }); - if (!existingCustomer || !existingCustomer.clickId) { + // if clickId is not provided, use the existing customer's clickId if it exists + // otherwise, throw an error + if (!clickId) { + if (!customer || !customer.clickId) { throw new DubApiError({ code: "bad_request", message: @@ -57,7 +60,7 @@ export const trackLead = async ({ }); } - clickId = existingCustomer.clickId; + clickId = customer.clickId; } const stringifiedEventName = eventName.toLowerCase().replaceAll(" ", "-"); @@ -80,16 +83,10 @@ export const trackLead = async ({ }, ); - const customerId = createId({ prefix: "cus_" }); - const finalCustomerName = - customerName || customerEmail || generateRandomName(); - const finalCustomerAvatar = - customerAvatar && !isStored(customerAvatar) - ? `${R2_URL}/customers/${customerId}/avatar_${nanoid(7)}` - : customerAvatar; - + // if this is the first time we're tracking this lead event for this customer + // we can proceed if (ok) { - // Find click event + // First, we need to find the click event let clickData: ClickEventTB | null = null; const clickEvent = await getClickEvent({ clickId }); @@ -97,6 +94,7 @@ export const trackLead = async ({ clickData = clickEvent.data[0]; } + // if there is no click data in Tinybird yet, check the clickIdCache if (!clickData) { const cachedClickData = await redis.get( `clickIdCache:${clickId}`, @@ -114,6 +112,7 @@ export const trackLead = async ({ } } + // if there is still no click data, throw an error if (!clickData) { throw new DubApiError({ code: "not_found", @@ -121,6 +120,7 @@ export const trackLead = async ({ }); } + // get the referral link from the from the clickData const link = await prisma.link.findUnique({ where: { id: clickData.link_id, @@ -146,32 +146,6 @@ export const trackLead = async ({ const leadEventId = nanoid(16); - // Create a function to handle customer upsert to avoid duplication - const upsertCustomer = async () => { - return prisma.customer.upsert({ - where: { - projectId_externalId: { - projectId: workspace.id, - externalId: customerExternalId, - }, - }, - create: { - id: customerId, - name: finalCustomerName, - email: customerEmail, - avatar: finalCustomerAvatar, - externalId: customerExternalId, - projectId: workspace.id, - projectConnectId: workspace.stripeConnectId, - clickId: clickData.click_id, - linkId: clickData.link_id, - country: clickData.country, - clickedAt: new Date(clickData.timestamp + "Z"), - }, - update: {}, // no updates needed if the customer exists - }); - }; - // Create a function to prepare the lead event payload const createLeadEventPayload = (customerId: string) => { const basePayload = { @@ -192,12 +166,36 @@ export const trackLead = async ({ : basePayload; }; - let customer: Customer | undefined; + const finalCustomerId = createId({ prefix: "cus_" }); + const finalCustomerName = + customerName || customerEmail || generateRandomName(); + const finalCustomerAvatar = + customerAvatar && !isStored(customerAvatar) + ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` + : customerAvatar; + + // if the customer doesn't exist in our MySQL DB yet, create it + if (!customer) { + customer = await prisma.customer.create({ + data: { + id: finalCustomerId, + name: finalCustomerName, + email: customerEmail, + avatar: finalCustomerAvatar, + externalId: customerExternalId, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickData.click_id, + linkId: clickData.link_id, + country: clickData.country, + clickedAt: new Date(clickData.timestamp + "Z"), + }, + }); + console.log(`customer created: ${JSON.stringify(customer, null, 2)}`); + } - // if wait mode, create the customer and record the lead event synchronously + // if wait mode, record the lead event synchronously if (mode === "wait") { - customer = await upsertCustomer(); - const leadEventPayload = createLeadEventPayload(customer.id); const cacheLeadEventPayload = Array.isArray(leadEventPayload) ? leadEventPayload[0] @@ -224,20 +222,14 @@ export const trackLead = async ({ waitUntil( (async () => { - // for async and deferred mode, create the customer in the background - if (mode === "async" || mode === "deferred") { - customer = await upsertCustomer(); - console.log(`customer created: ${JSON.stringify(customer, null, 2)}`); - - // for async mode, record the lead event right away - // for deferred mode, we defer the lead event creation to a subsequent request - if (mode === "async") { - const res = await recordLead(createLeadEventPayload(customer.id)); - console.log("lead event recorded:", res); - } + // for async mode, record the lead event in the background + // for deferred mode, defer the lead event creation to a subsequent request + if (mode === "async") { + const res = await recordLead(createLeadEventPayload(customer.id)); + console.log("lead event recorded:", res); } - // track the conversion event + // track the conversion event in our logs await logConversionEvent({ workspace_id: workspace.id, link_id: clickData.link_id, @@ -262,11 +254,8 @@ export const trackLead = async ({ } // if not deferred mode, process the following right away: - // - update link leads count - // - update workspace events usage - // - update customer leads count - // - create partner commission (for partner links) - // - execute workflows (for partner links) + // - update link, workspace, and customer stats + // - for partner links, create partner commission and execute workflows // - send lead.created webhook if (mode !== "deferred") { @@ -335,15 +324,26 @@ export const trackLead = async ({ ); } - return trackLeadResponseSchema.parse({ + if (!customer) { + throw new DubApiError({ + code: "not_found", + message: `Customer not found for externalId: ${customerExternalId}`, + }); + } + + const lead = trackLeadResponseSchema.parse({ click: { id: clickId, }, - customer: { - name: finalCustomerName, - email: customerEmail, - avatar: finalCustomerAvatar, - externalId: customerExternalId, - }, + customer, }); + + return { + ...lead, + // for backwards compatibility – will remove soon + clickId, + customerName: customer.name, + customerEmail: customer.email, + customerAvatar: customer.avatar, + }; }; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 99c5d449b1e..896fb716061 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -262,7 +262,7 @@ export const trackSale = async ({ })(), ); - return trackSaleResponseSchema.parse({ + const sale = trackSaleResponseSchema.parse({ eventName, customer, sale: { @@ -273,4 +273,14 @@ export const trackSale = async ({ metadata, }, }); + + return { + ...sale, + // for backwards compatibility – will remove soon + amount, + currency, + invoiceId, + paymentProcessor, + metadata, + }; }; diff --git a/apps/web/tests/tracks/track-lead.test.ts b/apps/web/tests/tracks/track-lead.test.ts index 5dcd9e6740a..e1fb5b7bc85 100644 --- a/apps/web/tests/tracks/track-lead.test.ts +++ b/apps/web/tests/tracks/track-lead.test.ts @@ -67,7 +67,7 @@ describe("POST /track/lead", async () => { }); }); - test("duplicate request with same externalId", async () => { + test("duplicate track lead request with same customerExternalId", async () => { const response = await http.post({ path: "/track/lead", body: { @@ -88,26 +88,38 @@ describe("POST /track/lead", async () => { }); }); - test("track a lead with eventQuantity", async () => { + test("track a lead with mode = 'deferred' + track it again after with mode = 'async' and no clickId", async () => { const customer2 = randomCustomer(); const response = await http.post({ path: "/track/lead", body: { clickId: trackedClickId, - eventName: "Start Trial", + eventName: "Mode=Deferred Signup", customerExternalId: customer2.externalId, customerName: customer2.name, customerEmail: customer2.email, customerAvatar: customer2.avatar, - eventQuantity: 2, + mode: "deferred", }, }); - expectValidLeadResponse({ response, customer: customer2, clickId: trackedClickId, }); + // track the lead again, this time with mode = 'async' and no clickId + const response2 = await http.post({ + path: "/track/lead", + body: { + eventName: "Mode=Deferred Signup", + customerExternalId: customer2.externalId, + }, + }); + expectValidLeadResponse({ + response: response2, + customer: customer2, + clickId: trackedClickId, + }); }); test("track a lead with mode = 'wait' + track a sale right after", async () => { @@ -143,6 +155,28 @@ describe("POST /track/lead", async () => { expect(saleResponse.status).toEqual(200); }); + test("track a lead with eventQuantity", async () => { + const customer4 = randomCustomer(); + const response = await http.post({ + path: "/track/lead", + body: { + clickId: trackedClickId, + eventName: "Start Trial", + customerExternalId: customer4.externalId, + customerName: customer4.name, + customerEmail: customer4.email, + customerAvatar: customer4.avatar, + eventQuantity: 2, + }, + }); + + expectValidLeadResponse({ + response, + customer: customer4, + clickId: trackedClickId, + }); + }); + test("track a lead with `externalId` (backward compatibility)", async () => { const customer4 = randomCustomer(); const response = await http.post({ From 8d6e9fa952982939bcd0088903ada583ce050675 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 7 Sep 2025 19:54:50 -0700 Subject: [PATCH 4/9] update code comments --- apps/web/lib/api/conversions/track-lead.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index db2d1e1cdc8..c16d9a899ba 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -50,7 +50,7 @@ export const trackLead = async ({ }); // if clickId is not provided, use the existing customer's clickId if it exists - // otherwise, throw an error + // otherwise, throw an error (this is for mode="deferred" lead tracking) if (!clickId) { if (!customer || !customer.clickId) { throw new DubApiError({ @@ -66,6 +66,7 @@ export const trackLead = async ({ const stringifiedEventName = eventName.toLowerCase().replaceAll(" ", "-"); // deduplicate lead events – only record 1 unique event for the same customer and event name + // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above? const ok = await redis.set( `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}`, { @@ -84,7 +85,7 @@ export const trackLead = async ({ ); // if this is the first time we're tracking this lead event for this customer - // we can proceed + // we can proceed with the lead tracking process if (ok) { // First, we need to find the click event let clickData: ClickEventTB | null = null; @@ -324,6 +325,9 @@ export const trackLead = async ({ ); } + // edge case (shouldn't happen): if the customer is not found + // and for some reason the externalCustomerId+EventName combo was cached on Redis + // throw an error if (!customer) { throw new DubApiError({ code: "not_found", From 847343c6b48926ed00cf95a6000707f41c5541a1 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 7 Sep 2025 22:05:27 -0700 Subject: [PATCH 5/9] remove deprecated fields from track/lead and track/sale response --- apps/web/lib/api/conversions/track-lead.ts | 46 +++++++------------ apps/web/lib/api/conversions/track-sale.ts | 12 +---- .../tests/tracks/track-lead-client.test.ts | 6 +-- apps/web/tests/tracks/track-lead.test.ts | 4 -- .../tests/tracks/track-sale-client.test.ts | 5 -- apps/web/tests/tracks/track-sale.test.ts | 5 -- 6 files changed, 18 insertions(+), 60 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index c16d9a899ba..056de00ee7b 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -29,12 +29,12 @@ type TrackLeadParams = z.input & { export const trackLead = async ({ clickId, eventName, - eventQuantity, customerExternalId, customerName, customerEmail, customerAvatar, mode, + eventQuantity, metadata, rawBody, workspace, @@ -84,6 +84,14 @@ export const trackLead = async ({ }, ); + const finalCustomerId = createId({ prefix: "cus_" }); + const finalCustomerName = + customerName || customerEmail || generateRandomName(); + const finalCustomerAvatar = + customerAvatar && !isStored(customerAvatar) + ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` + : customerAvatar; + // if this is the first time we're tracking this lead event for this customer // we can proceed with the lead tracking process if (ok) { @@ -167,14 +175,6 @@ export const trackLead = async ({ : basePayload; }; - const finalCustomerId = createId({ prefix: "cus_" }); - const finalCustomerName = - customerName || customerEmail || generateRandomName(); - const finalCustomerAvatar = - customerAvatar && !isStored(customerAvatar) - ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` - : customerAvatar; - // if the customer doesn't exist in our MySQL DB yet, create it if (!customer) { customer = await prisma.customer.create({ @@ -325,29 +325,15 @@ export const trackLead = async ({ ); } - // edge case (shouldn't happen): if the customer is not found - // and for some reason the externalCustomerId+EventName combo was cached on Redis - // throw an error - if (!customer) { - throw new DubApiError({ - code: "not_found", - message: `Customer not found for externalId: ${customerExternalId}`, - }); - } - - const lead = trackLeadResponseSchema.parse({ + return trackLeadResponseSchema.parse({ click: { id: clickId, }, - customer, + customer: customer ?? { + name: finalCustomerName, + email: customerEmail || null, + avatar: finalCustomerAvatar || null, + externalId: customerExternalId, + }, }); - - return { - ...lead, - // for backwards compatibility – will remove soon - clickId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, - }; }; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 896fb716061..99c5d449b1e 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -262,7 +262,7 @@ export const trackSale = async ({ })(), ); - const sale = trackSaleResponseSchema.parse({ + return trackSaleResponseSchema.parse({ eventName, customer, sale: { @@ -273,14 +273,4 @@ export const trackSale = async ({ metadata, }, }); - - return { - ...sale, - // for backwards compatibility – will remove soon - amount, - currency, - invoiceId, - paymentProcessor, - metadata, - }; }; diff --git a/apps/web/tests/tracks/track-lead-client.test.ts b/apps/web/tests/tracks/track-lead-client.test.ts index e115bd3aad6..0c0896219fd 100644 --- a/apps/web/tests/tracks/track-lead-client.test.ts +++ b/apps/web/tests/tracks/track-lead-client.test.ts @@ -45,14 +45,10 @@ describe("POST /track/lead/client", async () => { expect(response.status).toEqual(200); expect(leadResponse).toStrictEqual({ - clickId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, - customer: customer, click: { id: clickId, }, + customer: customer, }); }); }); diff --git a/apps/web/tests/tracks/track-lead.test.ts b/apps/web/tests/tracks/track-lead.test.ts index e1fb5b7bc85..40075ab1266 100644 --- a/apps/web/tests/tracks/track-lead.test.ts +++ b/apps/web/tests/tracks/track-lead.test.ts @@ -16,10 +16,6 @@ const expectValidLeadResponse = ({ }) => { expect(response.status).toEqual(200); expect(response.data).toStrictEqual({ - clickId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, click: { id: clickId, }, diff --git a/apps/web/tests/tracks/track-sale-client.test.ts b/apps/web/tests/tracks/track-sale-client.test.ts index 6a9235894c2..4209df39b5a 100644 --- a/apps/web/tests/tracks/track-sale-client.test.ts +++ b/apps/web/tests/tracks/track-sale-client.test.ts @@ -52,11 +52,6 @@ describe("POST /track/sale/client", async () => { invoiceId: sale.invoiceId, metadata: null, }, - amount: sale.amount, - currency: sale.currency, - paymentProcessor: sale.paymentProcessor, - metadata: null, - invoiceId: sale.invoiceId, }); }); }); diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index e330a3dc601..986808076fa 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -30,11 +30,6 @@ const expectValidSaleResponse = ( invoiceId: sale.invoiceId, metadata: null, }, - amount: sale.amount, - currency: sale.currency, - paymentProcessor: sale.paymentProcessor, - metadata: null, - invoiceId: sale.invoiceId, }); }; From 17b177a8d9f79f566236bb36cc64202419345673 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 16:58:32 +0530 Subject: [PATCH 6/9] Update track-lead.ts --- apps/web/lib/api/conversions/track-lead.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 056de00ee7b..98e18fb6483 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -192,7 +192,6 @@ export const trackLead = async ({ clickedAt: new Date(clickData.timestamp + "Z"), }, }); - console.log(`customer created: ${JSON.stringify(customer, null, 2)}`); } // if wait mode, record the lead event synchronously @@ -211,6 +210,7 @@ export const trackLead = async ({ redis.set(`leadCache:${customer.id}`, cacheLeadEventPayload, { ex: 60 * 5, }), + redis.set( `leadCache:${customer.id}:${stringifiedEventName}`, cacheLeadEventPayload, @@ -226,8 +226,7 @@ export const trackLead = async ({ // for async mode, record the lead event in the background // for deferred mode, defer the lead event creation to a subsequent request if (mode === "async") { - const res = await recordLead(createLeadEventPayload(customer.id)); - console.log("lead event recorded:", res); + await recordLead(createLeadEventPayload(customer.id)); } // track the conversion event in our logs From 043dda836c8c1f8585cf92b255f5351697fa6c9b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 8 Sep 2025 09:47:16 -0700 Subject: [PATCH 7/9] fix deduplication logic in trackLead --- apps/web/lib/api/conversions/track-lead.ts | 64 +++++++++++++--------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 98e18fb6483..04c3d0ba5fe 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -64,26 +64,6 @@ export const trackLead = async ({ } const stringifiedEventName = eventName.toLowerCase().replaceAll(" ", "-"); - - // deduplicate lead events – only record 1 unique event for the same customer and event name - // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above? - const ok = await redis.set( - `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}`, - { - timestamp: Date.now(), - clickId, - eventName, - customerExternalId, - customerName, - customerEmail, - customerAvatar, - }, - { - ex: 60 * 60 * 24 * 7, // cache for 1 week - nx: true, - }, - ); - const finalCustomerId = createId({ prefix: "cus_" }); const finalCustomerName = customerName || customerEmail || generateRandomName(); @@ -92,9 +72,35 @@ export const trackLead = async ({ ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` : customerAvatar; - // if this is the first time we're tracking this lead event for this customer + // if this event needs to be deduplicated + let deduplicate = false; + + // if not deferred mode, we need to deduplicate lead events – only record 1 unique event for the same customer and event name + // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above? + if (mode !== "deferred") { + const ok = await redis.set( + `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}`, + { + timestamp: Date.now(), + clickId, + eventName, + customerExternalId, + customerName, + customerEmail, + customerAvatar, + }, + { + ex: 60 * 60 * 24 * 7, // cache for 1 week + nx: true, + }, + ); + deduplicate = ok ? true : false; + } + + // if this event doesn't need to be deduplicated + // (e.g. mode === 'deferred' or it's regular mode but the first time processing this event) // we can proceed with the lead tracking process - if (ok) { + if (!deduplicate) { // First, we need to find the click event let clickData: ClickEventTB | null = null; const clickEvent = await getClickEvent({ clickId }); @@ -175,10 +181,17 @@ export const trackLead = async ({ : basePayload; }; - // if the customer doesn't exist in our MySQL DB yet, create it + // if the customer doesn't exist in our MySQL DB yet, upsert it + // (here we're doing upsert and not create in case of race conditions) if (!customer) { - customer = await prisma.customer.create({ - data: { + customer = await prisma.customer.upsert({ + where: { + projectId_externalId: { + projectId: workspace.id, + externalId: customerExternalId, + }, + }, + create: { id: finalCustomerId, name: finalCustomerName, email: customerEmail, @@ -191,6 +204,7 @@ export const trackLead = async ({ country: clickData.country, clickedAt: new Date(clickData.timestamp + "Z"), }, + update: {}, }); } From 2942e67c9dc66be7e67c597ff8fbc4d738a16f58 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 8 Sep 2025 10:17:22 -0700 Subject: [PATCH 8/9] Update track-lead.ts --- apps/web/lib/api/conversions/track-lead.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 04c3d0ba5fe..109b1e23277 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -78,7 +78,7 @@ export const trackLead = async ({ // if not deferred mode, we need to deduplicate lead events – only record 1 unique event for the same customer and event name // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above? if (mode !== "deferred") { - const ok = await redis.set( + const res = await redis.set( `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}`, { timestamp: Date.now(), @@ -94,7 +94,8 @@ export const trackLead = async ({ nx: true, }, ); - deduplicate = ok ? true : false; + // if res = null it means the key was already set + deduplicate = res === null ? true : false; } // if this event doesn't need to be deduplicated From ce78a9507c88a1f0d1d4e6a0bc42f29ea44cef60 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 8 Sep 2025 10:19:24 -0700 Subject: [PATCH 9/9] change naming to "isDuplicateEvent" --- apps/web/lib/api/conversions/track-lead.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 109b1e23277..13a63dc0c72 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -72,8 +72,7 @@ export const trackLead = async ({ ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` : customerAvatar; - // if this event needs to be deduplicated - let deduplicate = false; + let isDuplicateEvent = false; // if not deferred mode, we need to deduplicate lead events – only record 1 unique event for the same customer and event name // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above? @@ -95,13 +94,13 @@ export const trackLead = async ({ }, ); // if res = null it means the key was already set - deduplicate = res === null ? true : false; + isDuplicateEvent = res === null ? true : false; } - // if this event doesn't need to be deduplicated + // if it's not a duplicate event // (e.g. mode === 'deferred' or it's regular mode but the first time processing this event) // we can proceed with the lead tracking process - if (!deduplicate) { + if (!isDuplicateEvent) { // First, we need to find the click event let clickData: ClickEventTB | null = null; const clickEvent = await getClickEvent({ clickId });