From e21363205cea50d78faecf533439de8b27f4e215 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:16:53 -0700 Subject: [PATCH 1/6] Update conversion endpoints schema --- apps/web/app/api/track/lead/route.ts | 10 +++++-- apps/web/app/api/track/sale/route.ts | 8 +++++- apps/web/lib/zod/schemas/leads.ts | 39 ++++++++++++---------------- apps/web/lib/zod/schemas/sales.ts | 37 +++++++++----------------- 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/apps/web/app/api/track/lead/route.ts b/apps/web/app/api/track/lead/route.ts index 8c619cccf84..4215b9f1762 100644 --- a/apps/web/app/api/track/lead/route.ts +++ b/apps/web/app/api/track/lead/route.ts @@ -41,14 +41,19 @@ export const POST = withWorkspace( customerAvatar, metadata, mode = "async", // Default to async mode if not specified - } = trackLeadRequestSchema.parse(body); + } = trackLeadRequestSchema + .extend({ + // add backwards compatibility + externalId: z.string().optional(), + customerId: z.string().optional(), + }) + .parse(body); const stringifiedEventName = eventName.toLowerCase().replace(" ", "-"); const customerExternalId = externalId || oldCustomerId; const customerId = createId({ prefix: "cus_" }); const finalCustomerName = customerName || customerEmail || generateRandomName(); - // this will either be const finalCustomerAvatar = customerAvatar && !isStored(customerAvatar) ? `${R2_URL}/customers/${customerId}/avatar_${nanoid(7)}` @@ -74,6 +79,7 @@ export const POST = withWorkspace( customerAvatar, }, { + ex: 60 * 60 * 24 * 7, // cache for 1 week nx: true, }, ); diff --git a/apps/web/app/api/track/sale/route.ts b/apps/web/app/api/track/sale/route.ts index 3f7697d0c26..0adb04f4e57 100644 --- a/apps/web/app/api/track/sale/route.ts +++ b/apps/web/app/api/track/sale/route.ts @@ -39,7 +39,13 @@ export const POST = withWorkspace( metadata, eventName, leadEventName, - } = trackSaleRequestSchema.parse(body); + } = trackSaleRequestSchema + .extend({ + // add backwards compatibility + externalId: z.string().optional(), + customerId: z.string().optional(), + }) + .parse(body); if (invoiceId) { // Skip if invoice id is already processed diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index bd0bfa86ed9..c972c83661e 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -10,14 +10,16 @@ export const trackLeadRequestSchema = z.object({ .trim() .min(1, "clickId is required") .describe( - "The ID of the click in Dub. 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.", ), eventName: z .string({ required_error: "eventName is required" }) .trim() .min(1, "eventName is required") .max(255) - .describe("The name of the lead event to track.") + .describe( + "The name of the lead event to track. Can also be used as a unique identifier to associate a given lead event for a customer for a subsequent sale event (via the `leadEventName` prop in `/track/sale`).", + ) .openapi({ example: "Sign up" }), eventQuantity: z .number() @@ -29,38 +31,35 @@ export const trackLeadRequestSchema = z.object({ .string() .trim() .max(100) - .default("") // Remove this after migrating users from customerId to externalId .describe( - "This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.", + "The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.", ), - customerId: z - .string() - .trim() - .max(100) - .nullish() - .default(null) - .describe( - "This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.", - ) - .openapi({ deprecated: true }), customerName: z .string() .max(100) .nullish() .default(null) - .describe("Name of the customer in the client's app."), + .describe( + "The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).", + ), customerEmail: z .string() .email() .max(100) .nullish() .default(null) - .describe("Email of the customer in the client's app."), + .describe("The email address of the customer."), customerAvatar: z .string() .nullish() .default(null) - .describe("Avatar of the customer in the client's app."), + .describe("The avatar URL of the customer."), + mode: z + .enum(["async", "wait"]) + .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.", + ), metadata: z .record(z.unknown()) .nullish() @@ -71,12 +70,6 @@ export const trackLeadRequestSchema = z.object({ .describe( "Additional metadata to be stored with the lead event. Max 10,000 characters.", ), - mode: z - .enum(["async", "wait"]) - .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.", - ), }); export const trackLeadResponseSchema = z.object({ diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index 74af54369ce..f8033d3df6b 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -9,25 +9,14 @@ export const trackSaleRequestSchema = z.object({ .string() .trim() .max(100) - .default("") // Remove this after migrating users from customerId to externalId .describe( - "This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.", + "The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.", ), - customerId: z - .string() - .trim() - .max(100) - .nullish() - .default(null) - .describe( - "This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.", - ) - .openapi({ deprecated: true }), amount: z .number({ required_error: "amount is required" }) .int() .min(0, "amount cannot be negative") - .describe("The amount of the sale. Should be passed in cents."), + .describe("The amount of the sale in cents."), paymentProcessor: z .enum(["stripe", "shopify", "polar", "paddle", "custom"]) .describe("The payment processor via which the sale was made."), @@ -36,10 +25,8 @@ export const trackSaleRequestSchema = z.object({ .max(255) .optional() .default("Purchase") - .describe( - "The name of the sale event. It can be used to track different types of event for example 'Purchase', 'Upgrade', 'Payment', etc.", - ) - .openapi({ example: "Purchase" }), + .describe("The name of the sale event.") + .openapi({ examples: ["Purchase", "Upgrade", "Payment"] }), invoiceId: z .string() .nullish() @@ -52,6 +39,14 @@ export const trackSaleRequestSchema = z.object({ .default("usd") .transform((val) => val.toLowerCase()) .describe("The currency of the sale. Accepts ISO 4217 currency codes."), + leadEventName: z + .string() + .nullish() + .default(null) + .describe( + "The name of the lead event that occurred before the sale (case-sensitive). This is used to associate the sale event with a particular lead event (instead of the latest lead event, which is the default behavior).", + ) + .openapi({ example: "Cloned template 1481267" }), metadata: z .record(z.unknown()) .nullish() @@ -62,14 +57,6 @@ export const trackSaleRequestSchema = z.object({ .describe( "Additional metadata to be stored with the sale event. Max 10,000 characters.", ), - leadEventName: z - .string() - .nullish() - .default(null) - .describe( - "The name of the lead event that occurred before the sale (case-sensitive). This is used to associate the sale event with a particular lead event (instead of the latest lead event, which is the default behavior).", - ) - .openapi({ example: "Cloned template 1481267" }), }); export const trackSaleResponseSchema = z.object({ From ace8a251924417160d7e09779cef59b53dbc7bbf Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:34:33 -0700 Subject: [PATCH 2/6] update tests --- apps/web/tests/tracks/track-lead.test.ts | 17 +++ apps/web/tests/tracks/track-sale.test.ts | 125 ++++++++++++++--------- 2 files changed, 91 insertions(+), 51 deletions(-) diff --git a/apps/web/tests/tracks/track-lead.test.ts b/apps/web/tests/tracks/track-lead.test.ts index 5448c1cf138..45ed00006f9 100644 --- a/apps/web/tests/tracks/track-lead.test.ts +++ b/apps/web/tests/tracks/track-lead.test.ts @@ -106,4 +106,21 @@ describe("POST /track/lead", async () => { expect(saleResponse.status).toEqual(200); }); + + test("track a lead with `customerId` (backward compatibility)", async () => { + const customer4 = randomCustomer(); + const response = await http.post({ + path: "/track/lead", + body: { + clickId: E2E_CLICK_ID, + customerId: customer4.externalId, + eventName: "Signup", + customerName: customer4.name, + customerEmail: customer4.email, + customerAvatar: customer4.avatar, + }, + }); + + expectValidLeadResponse(response, customer4); + }); }); diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index d9734ae5c84..9b3e5cae99d 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -8,35 +8,19 @@ import { import { expect, test } from "vitest"; import { IntegrationHarness } from "../utils/integration"; -test("POST /track/sale", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const sale = { - eventName: "Subscription", - amount: randomValue([400, 900, 1900]), - currency: "usd", - invoiceId: `INV_${randomId()}`, - paymentProcessor: "stripe", - }; - - const response = await http.post({ - path: "/track/sale", - body: { - ...sale, - externalId: E2E_CUSTOMER_EXTERNAL_ID, - }, - }); - +const expectValidSaleResponse = ( + response: { status: number; data: TrackSaleResponse }, + sale: any, +) => { expect(response.status).toEqual(200); expect(response.data).toStrictEqual({ - eventName: "Subscription", + eventName: sale.eventName, customer: { id: E2E_CUSTOMER_ID, - name: expect.any(String), - email: expect.any(String), - avatar: expect.any(String), - externalId: E2E_CUSTOMER_EXTERNAL_ID, + name: sale.customerName, + email: sale.customerEmail, + avatar: sale.customerAvatar, + externalId: sale.externalId, }, sale: { amount: sale.amount, @@ -51,38 +35,77 @@ test("POST /track/sale", async () => { metadata: null, invoiceId: sale.invoiceId, }); +}; - // An invoiceId that is already processed should return null customer and sale - const response2 = await http.post({ - path: "/track/sale", - body: { - ...sale, - externalId: E2E_CUSTOMER_EXTERNAL_ID, - invoiceId: sale.invoiceId, - }, - }); +describe("POST /track/sale", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); - expect(response2.status).toEqual(200); - expect(response2.data).toStrictEqual({ + const sale = { eventName: "Subscription", - customer: null, - sale: null, + amount: randomValue([400, 900, 1900]), + currency: "usd", + invoiceId: `INV_${randomId()}`, + paymentProcessor: "stripe", + }; + + test("track a sale", async () => { + const response = await http.post({ + path: "/track/sale", + body: { + ...sale, + externalId: E2E_CUSTOMER_EXTERNAL_ID, + }, + }); + + expectValidSaleResponse(response, sale); }); - // An externalId that does not exist should return null customer and sale - const response3 = await http.post({ - path: "/track/sale", - body: { - ...sale, - invoiceId: `INV_${randomId()}`, - externalId: "external-id-that-does-not-exist", - }, + test("track a sale with an invoiceId that is already processed (should return null customer and sale) ", async () => { + const response2 = await http.post({ + path: "/track/sale", + body: { + ...sale, + externalId: E2E_CUSTOMER_EXTERNAL_ID, + invoiceId: sale.invoiceId, + }, + }); + + expect(response2.status).toEqual(200); + expect(response2.data).toStrictEqual({ + eventName: "Subscription", + customer: null, + sale: null, + }); }); - expect(response3.status).toEqual(200); - expect(response3.data).toStrictEqual({ - eventName: "Subscription", - customer: null, - sale: null, + test("track a sale with an externalId that does not exist (should return null customer and sale)", async () => { + const response3 = await http.post({ + path: "/track/sale", + body: { + ...sale, + invoiceId: `INV_${randomId()}`, + externalId: "external-id-that-does-not-exist", + }, + }); + + expect(response3.status).toEqual(200); + expect(response3.data).toStrictEqual({ + eventName: "Subscription", + customer: null, + sale: null, + }); + }); + + test("track a sale with `customerId` (backward compatibility)", async () => { + const response4 = await http.post({ + path: "/track/sale", + body: { + ...sale, + customerId: E2E_CUSTOMER_ID, + }, + }); + + expectValidSaleResponse(response4, sale); }); }); From ecea8eb57dafdb891718aff352063096510edf3d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:35:32 -0700 Subject: [PATCH 3/6] missed import --- apps/web/tests/tracks/track-sale.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index 9b3e5cae99d..a479fc36a1f 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -5,7 +5,7 @@ import { E2E_CUSTOMER_EXTERNAL_ID, E2E_CUSTOMER_ID, } from "tests/utils/resource"; -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { IntegrationHarness } from "../utils/integration"; const expectValidSaleResponse = ( From 9caa0665f1b26feabf07421bdeacb103eec04cdd Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:38:39 -0700 Subject: [PATCH 4/6] fix expectValidSaleResponse --- apps/web/tests/tracks/track-sale.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index a479fc36a1f..21da8eeb741 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -14,13 +14,13 @@ const expectValidSaleResponse = ( ) => { expect(response.status).toEqual(200); expect(response.data).toStrictEqual({ - eventName: sale.eventName, + eventName: "Subscription", customer: { id: E2E_CUSTOMER_ID, - name: sale.customerName, - email: sale.customerEmail, - avatar: sale.customerAvatar, - externalId: sale.externalId, + name: expect.any(String), + email: expect.any(String), + avatar: expect.any(String), + externalId: E2E_CUSTOMER_EXTERNAL_ID, }, sale: { amount: sale.amount, @@ -37,13 +37,17 @@ const expectValidSaleResponse = ( }); }; +const randomSaleAmount = () => { + return randomValue([400, 900, 1900]); +}; + describe("POST /track/sale", async () => { const h = new IntegrationHarness(); const { http } = await h.init(); const sale = { eventName: "Subscription", - amount: randomValue([400, 900, 1900]), + amount: randomSaleAmount(), currency: "usd", invoiceId: `INV_${randomId()}`, paymentProcessor: "stripe", @@ -102,6 +106,8 @@ describe("POST /track/sale", async () => { path: "/track/sale", body: { ...sale, + invoiceId: `INV_${randomId()}`, + amount: randomSaleAmount(), customerId: E2E_CUSTOMER_ID, }, }); From 01b5a5550108378fde597ff3fd978dc0d08fb397 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:40:02 -0700 Subject: [PATCH 5/6] Update track-sale.test.ts --- apps/web/tests/tracks/track-sale.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index 21da8eeb741..e8d3d527ac3 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -108,7 +108,7 @@ describe("POST /track/sale", async () => { ...sale, invoiceId: `INV_${randomId()}`, amount: randomSaleAmount(), - customerId: E2E_CUSTOMER_ID, + customerId: E2E_CUSTOMER_EXTERNAL_ID, }, }); From add0285d3037853dd66a816e9788eb90efbf05ed Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 20 Apr 2025 15:40:58 -0700 Subject: [PATCH 6/6] Update track-sale.test.ts --- apps/web/tests/tracks/track-sale.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index e8d3d527ac3..de66b7bba39 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -102,16 +102,20 @@ describe("POST /track/sale", async () => { }); test("track a sale with `customerId` (backward compatibility)", async () => { + const newSale = { + ...sale, + invoiceId: `INV_${randomId()}`, + amount: randomSaleAmount(), + }; + const response4 = await http.post({ path: "/track/sale", body: { - ...sale, - invoiceId: `INV_${randomId()}`, - amount: randomSaleAmount(), + ...newSale, customerId: E2E_CUSTOMER_EXTERNAL_ID, }, }); - expectValidSaleResponse(response4, sale); + expectValidSaleResponse(response4, newSale); }); });