Skip to content

Commit 404d2e6

Browse files
authored
Merge pull request dubinc#2328 from dubinc/update-conversions-schema
Update conversion endpoints schema
2 parents daea6bf + add0285 commit 404d2e6

6 files changed

Lines changed: 139 additions & 97 deletions

File tree

apps/web/app/api/track/lead/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,19 @@ export const POST = withWorkspace(
4141
customerAvatar,
4242
metadata,
4343
mode = "async", // Default to async mode if not specified
44-
} = trackLeadRequestSchema.parse(body);
44+
} = trackLeadRequestSchema
45+
.extend({
46+
// add backwards compatibility
47+
externalId: z.string().optional(),
48+
customerId: z.string().optional(),
49+
})
50+
.parse(body);
4551

4652
const stringifiedEventName = eventName.toLowerCase().replace(" ", "-");
4753
const customerExternalId = externalId || oldCustomerId;
4854
const customerId = createId({ prefix: "cus_" });
4955
const finalCustomerName =
5056
customerName || customerEmail || generateRandomName();
51-
// this will either be
5257
const finalCustomerAvatar =
5358
customerAvatar && !isStored(customerAvatar)
5459
? `${R2_URL}/customers/${customerId}/avatar_${nanoid(7)}`
@@ -74,6 +79,7 @@ export const POST = withWorkspace(
7479
customerAvatar,
7580
},
7681
{
82+
ex: 60 * 60 * 24 * 7, // cache for 1 week
7783
nx: true,
7884
},
7985
);

apps/web/app/api/track/sale/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ export const POST = withWorkspace(
3939
metadata,
4040
eventName,
4141
leadEventName,
42-
} = trackSaleRequestSchema.parse(body);
42+
} = trackSaleRequestSchema
43+
.extend({
44+
// add backwards compatibility
45+
externalId: z.string().optional(),
46+
customerId: z.string().optional(),
47+
})
48+
.parse(body);
4349

4450
if (invoiceId) {
4551
// Skip if invoice id is already processed

apps/web/lib/zod/schemas/leads.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ export const trackLeadRequestSchema = z.object({
1010
.trim()
1111
.min(1, "clickId is required")
1212
.describe(
13-
"The ID of the click in Dub. You can read this value from `dub_id` cookie.",
13+
"The unique ID of the click that the lead conversion event is attributed to. You can read this value from `dub_id` cookie.",
1414
),
1515
eventName: z
1616
.string({ required_error: "eventName is required" })
1717
.trim()
1818
.min(1, "eventName is required")
1919
.max(255)
20-
.describe("The name of the lead event to track.")
20+
.describe(
21+
"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`).",
22+
)
2123
.openapi({ example: "Sign up" }),
2224
eventQuantity: z
2325
.number()
@@ -29,38 +31,35 @@ export const trackLeadRequestSchema = z.object({
2931
.string()
3032
.trim()
3133
.max(100)
32-
.default("") // Remove this after migrating users from customerId to externalId
3334
.describe(
34-
"This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.",
35+
"The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.",
3536
),
36-
customerId: z
37-
.string()
38-
.trim()
39-
.max(100)
40-
.nullish()
41-
.default(null)
42-
.describe(
43-
"This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.",
44-
)
45-
.openapi({ deprecated: true }),
4637
customerName: z
4738
.string()
4839
.max(100)
4940
.nullish()
5041
.default(null)
51-
.describe("Name of the customer in the client's app."),
42+
.describe(
43+
"The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).",
44+
),
5245
customerEmail: z
5346
.string()
5447
.email()
5548
.max(100)
5649
.nullish()
5750
.default(null)
58-
.describe("Email of the customer in the client's app."),
51+
.describe("The email address of the customer."),
5952
customerAvatar: z
6053
.string()
6154
.nullish()
6255
.default(null)
63-
.describe("Avatar of the customer in the client's app."),
56+
.describe("The avatar URL of the customer."),
57+
mode: z
58+
.enum(["async", "wait"])
59+
.default("async")
60+
.describe(
61+
"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.",
62+
),
6463
metadata: z
6564
.record(z.unknown())
6665
.nullish()
@@ -71,12 +70,6 @@ export const trackLeadRequestSchema = z.object({
7170
.describe(
7271
"Additional metadata to be stored with the lead event. Max 10,000 characters.",
7372
),
74-
mode: z
75-
.enum(["async", "wait"])
76-
.default("async")
77-
.describe(
78-
"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.",
79-
),
8073
});
8174

8275
export const trackLeadResponseSchema = z.object({

apps/web/lib/zod/schemas/sales.ts

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,14 @@ export const trackSaleRequestSchema = z.object({
99
.string()
1010
.trim()
1111
.max(100)
12-
.default("") // Remove this after migrating users from customerId to externalId
1312
.describe(
14-
"This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.",
13+
"The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.",
1514
),
16-
customerId: z
17-
.string()
18-
.trim()
19-
.max(100)
20-
.nullish()
21-
.default(null)
22-
.describe(
23-
"This is the unique identifier for the customer in the client's app. This is used to track the customer's journey.",
24-
)
25-
.openapi({ deprecated: true }),
2615
amount: z
2716
.number({ required_error: "amount is required" })
2817
.int()
2918
.min(0, "amount cannot be negative")
30-
.describe("The amount of the sale. Should be passed in cents."),
19+
.describe("The amount of the sale in cents."),
3120
paymentProcessor: z
3221
.enum(["stripe", "shopify", "polar", "paddle", "custom"])
3322
.describe("The payment processor via which the sale was made."),
@@ -36,10 +25,8 @@ export const trackSaleRequestSchema = z.object({
3625
.max(255)
3726
.optional()
3827
.default("Purchase")
39-
.describe(
40-
"The name of the sale event. It can be used to track different types of event for example 'Purchase', 'Upgrade', 'Payment', etc.",
41-
)
42-
.openapi({ example: "Purchase" }),
28+
.describe("The name of the sale event.")
29+
.openapi({ examples: ["Purchase", "Upgrade", "Payment"] }),
4330
invoiceId: z
4431
.string()
4532
.nullish()
@@ -52,6 +39,14 @@ export const trackSaleRequestSchema = z.object({
5239
.default("usd")
5340
.transform((val) => val.toLowerCase())
5441
.describe("The currency of the sale. Accepts ISO 4217 currency codes."),
42+
leadEventName: z
43+
.string()
44+
.nullish()
45+
.default(null)
46+
.describe(
47+
"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).",
48+
)
49+
.openapi({ example: "Cloned template 1481267" }),
5550
metadata: z
5651
.record(z.unknown())
5752
.nullish()
@@ -62,14 +57,6 @@ export const trackSaleRequestSchema = z.object({
6257
.describe(
6358
"Additional metadata to be stored with the sale event. Max 10,000 characters.",
6459
),
65-
leadEventName: z
66-
.string()
67-
.nullish()
68-
.default(null)
69-
.describe(
70-
"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).",
71-
)
72-
.openapi({ example: "Cloned template 1481267" }),
7360
});
7461

7562
export const trackSaleResponseSchema = z.object({

apps/web/tests/tracks/track-lead.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,21 @@ describe("POST /track/lead", async () => {
106106

107107
expect(saleResponse.status).toEqual(200);
108108
});
109+
110+
test("track a lead with `customerId` (backward compatibility)", async () => {
111+
const customer4 = randomCustomer();
112+
const response = await http.post<TrackLeadResponse>({
113+
path: "/track/lead",
114+
body: {
115+
clickId: E2E_CLICK_ID,
116+
customerId: customer4.externalId,
117+
eventName: "Signup",
118+
customerName: customer4.name,
119+
customerEmail: customer4.email,
120+
customerAvatar: customer4.avatar,
121+
},
122+
});
123+
124+
expectValidLeadResponse(response, customer4);
125+
});
109126
});

apps/web/tests/tracks/track-sale.test.ts

Lines changed: 79 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,13 @@ import {
55
E2E_CUSTOMER_EXTERNAL_ID,
66
E2E_CUSTOMER_ID,
77
} from "tests/utils/resource";
8-
import { expect, test } from "vitest";
8+
import { describe, expect, test } from "vitest";
99
import { IntegrationHarness } from "../utils/integration";
1010

11-
test("POST /track/sale", async () => {
12-
const h = new IntegrationHarness();
13-
const { http } = await h.init();
14-
15-
const sale = {
16-
eventName: "Subscription",
17-
amount: randomValue([400, 900, 1900]),
18-
currency: "usd",
19-
invoiceId: `INV_${randomId()}`,
20-
paymentProcessor: "stripe",
21-
};
22-
23-
const response = await http.post<TrackSaleResponse>({
24-
path: "/track/sale",
25-
body: {
26-
...sale,
27-
externalId: E2E_CUSTOMER_EXTERNAL_ID,
28-
},
29-
});
30-
11+
const expectValidSaleResponse = (
12+
response: { status: number; data: TrackSaleResponse },
13+
sale: any,
14+
) => {
3115
expect(response.status).toEqual(200);
3216
expect(response.data).toStrictEqual({
3317
eventName: "Subscription",
@@ -51,38 +35,87 @@ test("POST /track/sale", async () => {
5135
metadata: null,
5236
invoiceId: sale.invoiceId,
5337
});
38+
};
5439

55-
// An invoiceId that is already processed should return null customer and sale
56-
const response2 = await http.post<TrackSaleResponse>({
57-
path: "/track/sale",
58-
body: {
59-
...sale,
60-
externalId: E2E_CUSTOMER_EXTERNAL_ID,
61-
invoiceId: sale.invoiceId,
62-
},
63-
});
40+
const randomSaleAmount = () => {
41+
return randomValue([400, 900, 1900]);
42+
};
6443

65-
expect(response2.status).toEqual(200);
66-
expect(response2.data).toStrictEqual({
44+
describe("POST /track/sale", async () => {
45+
const h = new IntegrationHarness();
46+
const { http } = await h.init();
47+
48+
const sale = {
6749
eventName: "Subscription",
68-
customer: null,
69-
sale: null,
50+
amount: randomSaleAmount(),
51+
currency: "usd",
52+
invoiceId: `INV_${randomId()}`,
53+
paymentProcessor: "stripe",
54+
};
55+
56+
test("track a sale", async () => {
57+
const response = await http.post<TrackSaleResponse>({
58+
path: "/track/sale",
59+
body: {
60+
...sale,
61+
externalId: E2E_CUSTOMER_EXTERNAL_ID,
62+
},
63+
});
64+
65+
expectValidSaleResponse(response, sale);
7066
});
7167

72-
// An externalId that does not exist should return null customer and sale
73-
const response3 = await http.post<TrackSaleResponse>({
74-
path: "/track/sale",
75-
body: {
68+
test("track a sale with an invoiceId that is already processed (should return null customer and sale) ", async () => {
69+
const response2 = await http.post<TrackSaleResponse>({
70+
path: "/track/sale",
71+
body: {
72+
...sale,
73+
externalId: E2E_CUSTOMER_EXTERNAL_ID,
74+
invoiceId: sale.invoiceId,
75+
},
76+
});
77+
78+
expect(response2.status).toEqual(200);
79+
expect(response2.data).toStrictEqual({
80+
eventName: "Subscription",
81+
customer: null,
82+
sale: null,
83+
});
84+
});
85+
86+
test("track a sale with an externalId that does not exist (should return null customer and sale)", async () => {
87+
const response3 = await http.post<TrackSaleResponse>({
88+
path: "/track/sale",
89+
body: {
90+
...sale,
91+
invoiceId: `INV_${randomId()}`,
92+
externalId: "external-id-that-does-not-exist",
93+
},
94+
});
95+
96+
expect(response3.status).toEqual(200);
97+
expect(response3.data).toStrictEqual({
98+
eventName: "Subscription",
99+
customer: null,
100+
sale: null,
101+
});
102+
});
103+
104+
test("track a sale with `customerId` (backward compatibility)", async () => {
105+
const newSale = {
76106
...sale,
77107
invoiceId: `INV_${randomId()}`,
78-
externalId: "external-id-that-does-not-exist",
79-
},
80-
});
108+
amount: randomSaleAmount(),
109+
};
81110

82-
expect(response3.status).toEqual(200);
83-
expect(response3.data).toStrictEqual({
84-
eventName: "Subscription",
85-
customer: null,
86-
sale: null,
111+
const response4 = await http.post<TrackSaleResponse>({
112+
path: "/track/sale",
113+
body: {
114+
...newSale,
115+
customerId: E2E_CUSTOMER_EXTERNAL_ID,
116+
},
117+
});
118+
119+
expectValidSaleResponse(response4, newSale);
87120
});
88121
});

0 commit comments

Comments
 (0)