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({ 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..de66b7bba39 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -5,29 +5,13 @@ 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"; -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", @@ -51,38 +35,87 @@ 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, - }, - }); +const randomSaleAmount = () => { + return randomValue([400, 900, 1900]); +}; - expect(response2.status).toEqual(200); - expect(response2.data).toStrictEqual({ +describe("POST /track/sale", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + const sale = { eventName: "Subscription", - customer: null, - sale: null, + amount: randomSaleAmount(), + 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: { + 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, + }); + }); + + 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 newSale = { ...sale, invoiceId: `INV_${randomId()}`, - externalId: "external-id-that-does-not-exist", - }, - }); + amount: randomSaleAmount(), + }; - expect(response3.status).toEqual(200); - expect(response3.data).toStrictEqual({ - eventName: "Subscription", - customer: null, - sale: null, + const response4 = await http.post({ + path: "/track/sale", + body: { + ...newSale, + customerId: E2E_CUSTOMER_EXTERNAL_ID, + }, + }); + + expectValidSaleResponse(response4, newSale); }); });