Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/app/api/track/lead/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand All @@ -74,6 +79,7 @@ export const POST = withWorkspace(
customerAvatar,
},
{
ex: 60 * 60 * 24 * 7, // cache for 1 week
nx: true,
},
);
Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/api/track/sale/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 16 additions & 23 deletions apps/web/lib/zod/schemas/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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({
Expand Down
37 changes: 12 additions & 25 deletions apps/web/lib/zod/schemas/sales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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({
Expand Down
17 changes: 17 additions & 0 deletions apps/web/tests/tracks/track-lead.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrackLeadResponse>({
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);
});
});
125 changes: 79 additions & 46 deletions apps/web/tests/tracks/track-sale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrackSaleResponse>({
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",
Expand All @@ -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<TrackSaleResponse>({
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<TrackSaleResponse>({
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<TrackSaleResponse>({
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<TrackSaleResponse>({
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<TrackSaleResponse>({
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<TrackSaleResponse>({
path: "/track/sale",
body: {
...newSale,
customerId: E2E_CUSTOMER_EXTERNAL_ID,
},
});

expectValidSaleResponse(response4, newSale);
});
});