Skip to content

Commit 6f0db3f

Browse files
authored
Partner Analytics/Events export (#3351)
1 parent 7259062 commit 6f0db3f

File tree

7 files changed

+339
-17
lines changed

7 files changed

+339
-17
lines changed

apps/web/app/(ee)/api/events/export/route.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,12 @@ export const GET = withWorkspace(
4848
throwIfClicksUsageExceeded(workspace);
4949

5050
const parsedParams = eventsQuerySchema
51-
.and(
52-
z.object({
53-
columns: z
54-
.string()
55-
.transform((c) => c.split(","))
56-
.pipe(z.string().array()),
57-
}),
58-
)
51+
.extend({
52+
columns: z
53+
.string()
54+
.transform((c) => c.split(","))
55+
.pipe(z.string().array()),
56+
})
5957
.parse(searchParams);
6058

6159
const { event, domain, interval, start, end, columns, key, folderId } =
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { VALID_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants";
2+
import { getAnalytics } from "@/lib/analytics/get-analytics";
3+
import { convertToCSV } from "@/lib/analytics/utils";
4+
import { DubApiError } from "@/lib/api/errors";
5+
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
6+
import { withPartnerProfile } from "@/lib/auth/partner";
7+
import {
8+
LARGE_PROGRAM_IDS,
9+
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,
10+
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,
11+
} from "@/lib/constants/partner-profile";
12+
import { partnerProfileAnalyticsQuerySchema } from "@/lib/zod/schemas/partner-profile";
13+
import JSZip from "jszip";
14+
15+
// GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics
16+
export const GET = withPartnerProfile(
17+
async ({ partner, params, searchParams }) => {
18+
const { program, links, totalCommissions } =
19+
await getProgramEnrollmentOrThrow({
20+
partnerId: partner.id,
21+
programId: params.programId,
22+
include: {
23+
program: true,
24+
links: true,
25+
},
26+
});
27+
28+
if (
29+
LARGE_PROGRAM_IDS.includes(program.id) &&
30+
totalCommissions < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS
31+
) {
32+
throw new DubApiError({
33+
code: "forbidden",
34+
message: "This feature is not available for your program.",
35+
});
36+
}
37+
38+
const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams);
39+
40+
let { linkId, domain, key, ...rest } = parsedParams;
41+
42+
if (linkId) {
43+
if (!links.some((link) => link.id === linkId)) {
44+
throw new DubApiError({
45+
code: "not_found",
46+
message: "Link not found",
47+
});
48+
}
49+
} else if (domain && key) {
50+
const foundLink = links.find(
51+
(link) => link.domain === domain && link.key === key,
52+
);
53+
if (!foundLink) {
54+
throw new DubApiError({
55+
code: "not_found",
56+
message: "Link not found",
57+
});
58+
}
59+
60+
linkId = foundLink.id;
61+
}
62+
63+
// Early return if there are no links and no linkId specified
64+
if (links.length === 0 && !linkId) {
65+
const zip = new JSZip();
66+
const zipData = await zip.generateAsync({ type: "nodebuffer" });
67+
return new Response(zipData as unknown as BodyInit, {
68+
headers: {
69+
"Content-Type": "application/zip",
70+
"Content-Disposition": "attachment; filename=analytics_export.zip",
71+
},
72+
});
73+
}
74+
75+
const zip = new JSZip();
76+
77+
await Promise.all(
78+
VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => {
79+
// no need to fetch top links data if there's a link specified
80+
// since this is just a single link
81+
if (endpoint === "top_links" && linkId) return;
82+
// skip clicks count
83+
if (endpoint === "count") return;
84+
85+
const response = await getAnalytics({
86+
...rest,
87+
workspaceId: program.workspaceId,
88+
...(linkId
89+
? { linkId }
90+
: links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING
91+
? { partnerId: partner.id }
92+
: { linkIds: links.map((link) => link.id) }),
93+
dataAvailableFrom: program.startedAt ?? program.createdAt,
94+
groupBy: endpoint,
95+
});
96+
97+
if (!response || response.length === 0) return;
98+
99+
const csvData = convertToCSV(response);
100+
zip.file(`${endpoint}.csv`, csvData);
101+
}),
102+
);
103+
104+
const zipData = await zip.generateAsync({ type: "nodebuffer" });
105+
106+
return new Response(zipData as unknown as BodyInit, {
107+
headers: {
108+
"Content-Type": "application/zip",
109+
"Content-Disposition": "attachment; filename=analytics_export.zip",
110+
},
111+
});
112+
},
113+
);
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { getEvents } from "@/lib/analytics/get-events";
2+
import { convertToCSV } from "@/lib/analytics/utils";
3+
import { DubApiError } from "@/lib/api/errors";
4+
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
5+
import { withPartnerProfile } from "@/lib/auth/partner";
6+
import {
7+
LARGE_PROGRAM_IDS,
8+
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,
9+
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,
10+
} from "@/lib/constants/partner-profile";
11+
import { generateRandomName } from "@/lib/names";
12+
import { ClickEvent, LeadEvent, SaleEvent } from "@/lib/types";
13+
import {
14+
PartnerProfileLinkSchema,
15+
partnerProfileEventsQuerySchema,
16+
} from "@/lib/zod/schemas/partner-profile";
17+
import { COUNTRIES, capitalize } from "@dub/utils";
18+
import * as z from "zod/v4";
19+
20+
type Row = ClickEvent | LeadEvent | SaleEvent;
21+
22+
const columnNames: Record<string, string> = {
23+
trigger: "Event",
24+
url: "Destination URL",
25+
os: "OS",
26+
referer: "Referrer",
27+
refererUrl: "Referrer URL",
28+
timestamp: "Date",
29+
invoiceId: "Invoice ID",
30+
saleAmount: "Sale Amount",
31+
clickId: "Click ID",
32+
};
33+
34+
const columnAccessors = {
35+
trigger: (r: Row) => r.click.trigger,
36+
event: (r: LeadEvent | SaleEvent) => r.eventName,
37+
url: (r: ClickEvent) => r.click.url,
38+
link: (r: Row) => r.domain + (r.key === "_root" ? "" : `/${r.key}`),
39+
country: (r: Row) =>
40+
r.country ? COUNTRIES[r.country] ?? r.country : r.country,
41+
referer: (r: ClickEvent) => r.click.referer,
42+
refererUrl: (r: ClickEvent) => r.click.refererUrl,
43+
customer: (r: LeadEvent | SaleEvent) =>
44+
r.customer.name + (r.customer.email ? ` <${r.customer.email}>` : ""),
45+
invoiceId: (r: SaleEvent) => r.sale.invoiceId,
46+
saleAmount: (r: SaleEvent) => "$" + (r.sale.amount / 100).toFixed(2),
47+
clickId: (r: ClickEvent) => r.click.id,
48+
};
49+
50+
// GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events
51+
export const GET = withPartnerProfile(
52+
async ({ partner, params, searchParams }) => {
53+
const { program, links, totalCommissions, customerDataSharingEnabledAt } =
54+
await getProgramEnrollmentOrThrow({
55+
partnerId: partner.id,
56+
programId: params.programId,
57+
include: {
58+
program: true,
59+
links: true,
60+
},
61+
});
62+
63+
if (
64+
LARGE_PROGRAM_IDS.includes(program.id) &&
65+
totalCommissions < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS
66+
) {
67+
throw new DubApiError({
68+
code: "forbidden",
69+
message: "This feature is not available for your program.",
70+
});
71+
}
72+
73+
const parsedParams = partnerProfileEventsQuerySchema
74+
.extend({
75+
columns: z
76+
.string()
77+
.optional()
78+
.transform((c) => (c ? c.split(",") : []))
79+
.pipe(z.string().array()),
80+
})
81+
.parse(searchParams);
82+
83+
const { event, columns: columnsParam } = parsedParams;
84+
let { linkId, domain, key, ...rest } = parsedParams;
85+
86+
// Default columns based on event type if not provided
87+
const defaultColumns: Record<string, string[]> = {
88+
clicks: ["timestamp", "link", "referer", "country", "device"],
89+
leads: ["timestamp", "event", "link", "customer", "referer"],
90+
sales: [
91+
"timestamp",
92+
"saleAmount",
93+
"event",
94+
"customer",
95+
"referer",
96+
"link",
97+
],
98+
};
99+
100+
const columns =
101+
columnsParam.length > 0
102+
? columnsParam
103+
: defaultColumns[event] || defaultColumns.clicks;
104+
105+
if (linkId) {
106+
if (!links.some((link) => link.id === linkId)) {
107+
throw new DubApiError({
108+
code: "not_found",
109+
message: "Link not found",
110+
});
111+
}
112+
} else if (domain && key) {
113+
const foundLink = links.find(
114+
(link) => link.domain === domain && link.key === key,
115+
);
116+
if (!foundLink) {
117+
throw new DubApiError({
118+
code: "not_found",
119+
message: "Link not found",
120+
});
121+
}
122+
123+
linkId = foundLink.id;
124+
}
125+
126+
if (links.length === 0) {
127+
return new Response("", {
128+
headers: {
129+
"Content-Type": "application/csv",
130+
"Content-Disposition": `attachment; filename=${event}_export.csv`,
131+
},
132+
});
133+
}
134+
135+
const events = await getEvents({
136+
...rest,
137+
workspaceId: program.workspaceId,
138+
...(linkId
139+
? { linkId }
140+
: links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING
141+
? { partnerId: partner.id }
142+
: { linkIds: links.map((link) => link.id) }),
143+
dataAvailableFrom: program.startedAt ?? program.createdAt,
144+
limit: 100000,
145+
});
146+
147+
// Apply partner profile data transformations similar to the main events route
148+
const transformedEvents = events.map((event) => {
149+
// don't return ip address for partner profile
150+
// @ts-ignore – ip is deprecated but present in the data
151+
const { ip, click, customer, ...eventRest } = event;
152+
const { ip: _, ...clickRest } = click;
153+
154+
return {
155+
...eventRest,
156+
click: clickRest,
157+
link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null,
158+
...(customer && {
159+
customer: z
160+
.object({
161+
id: z.string(),
162+
email: z.string(),
163+
...(customerDataSharingEnabledAt && { name: z.string() }),
164+
})
165+
.parse({
166+
...customer,
167+
email: customer.email
168+
? customerDataSharingEnabledAt
169+
? customer.email
170+
: customer.email.replace(/(?<=^.).+(?=.@)/, "****")
171+
: customer.name || generateRandomName(),
172+
...(customerDataSharingEnabledAt && {
173+
name: customer.name || generateRandomName(),
174+
}),
175+
}),
176+
}),
177+
};
178+
});
179+
180+
const data = transformedEvents.map((row) =>
181+
Object.fromEntries(
182+
columns.map((c) => [
183+
columnNames?.[c] ?? capitalize(c),
184+
columnAccessors[c]?.(row) ?? row?.[c],
185+
]),
186+
),
187+
);
188+
189+
const csvData = convertToCSV(data);
190+
191+
return new Response(csvData, {
192+
headers: {
193+
"Content-Type": "application/csv",
194+
"Content-Disposition": `attachment; filename=${event}_export.csv`,
195+
},
196+
});
197+
},
198+
);
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import Events from "@/ui/analytics/events";
2+
import { EventsProvider } from "@/ui/analytics/events/events-provider";
23
import { PageContent } from "@/ui/layout/page-content";
34

45
export default function ProgramEvents() {
56
return (
67
<PageContent title="Events">
7-
<Events />
8+
<EventsProvider>
9+
<Events />
10+
</EventsProvider>
811
</PageContent>
912
);
1013
}

apps/web/ui/analytics/analytics-export-button.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ export function AnalyticsExportButton({
1010
setOpenPopover: Dispatch<SetStateAction<boolean>>;
1111
}) {
1212
const [loading, setLoading] = useState(false);
13-
const { queryString } = useContext(AnalyticsContext);
13+
const { queryString, baseApiPath, partnerPage } = useContext(AnalyticsContext);
1414

1515
async function exportData() {
1616
setLoading(true);
1717
try {
18-
const response = await fetch(`/api/analytics/export?${queryString}`, {
18+
// Use partner profile export endpoint if on partner page, otherwise use regular export endpoint
19+
const exportPath = partnerPage
20+
? `${baseApiPath}/export`
21+
: "/api/analytics/export";
22+
23+
const response = await fetch(`${exportPath}?${queryString}`, {
1924
method: "GET",
2025
headers: {
2126
"Content-Type": "application/json",

0 commit comments

Comments
 (0)