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
14 changes: 6 additions & 8 deletions apps/web/app/(ee)/api/events/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,12 @@ export const GET = withWorkspace(
throwIfClicksUsageExceeded(workspace);

const parsedParams = eventsQuerySchema
.and(
z.object({
columns: z
.string()
.transform((c) => c.split(","))
.pipe(z.string().array()),
}),
)
.extend({
columns: z
.string()
.transform((c) => c.split(","))
.pipe(z.string().array()),
})
.parse(searchParams);

const { event, domain, interval, start, end, columns, key, folderId } =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { VALID_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants";
import { getAnalytics } from "@/lib/analytics/get-analytics";
import { convertToCSV } from "@/lib/analytics/utils";
import { DubApiError } from "@/lib/api/errors";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { withPartnerProfile } from "@/lib/auth/partner";
import {
LARGE_PROGRAM_IDS,
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,
} from "@/lib/constants/partner-profile";
import { partnerProfileAnalyticsQuerySchema } from "@/lib/zod/schemas/partner-profile";
import JSZip from "jszip";

// GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics
export const GET = withPartnerProfile(
async ({ partner, params, searchParams }) => {
const { program, links, totalCommissions } =
await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId: params.programId,
include: {
program: true,
links: true,
},
});

if (
LARGE_PROGRAM_IDS.includes(program.id) &&
totalCommissions < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS
) {
throw new DubApiError({
code: "forbidden",
message: "This feature is not available for your program.",
});
}

const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams);

let { linkId, domain, key, ...rest } = parsedParams;

if (linkId) {
if (!links.some((link) => link.id === linkId)) {
throw new DubApiError({
code: "not_found",
message: "Link not found",
});
}
} else if (domain && key) {
const foundLink = links.find(
(link) => link.domain === domain && link.key === key,
);
if (!foundLink) {
throw new DubApiError({
code: "not_found",
message: "Link not found",
});
}

linkId = foundLink.id;
}

// Early return if there are no links and no linkId specified
if (links.length === 0 && !linkId) {
const zip = new JSZip();
const zipData = await zip.generateAsync({ type: "nodebuffer" });
return new Response(zipData as unknown as BodyInit, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": "attachment; filename=analytics_export.zip",
},
});
}

const zip = new JSZip();

await Promise.all(
VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => {
// no need to fetch top links data if there's a link specified
// since this is just a single link
if (endpoint === "top_links" && linkId) return;
// skip clicks count
if (endpoint === "count") return;

const response = await getAnalytics({
...rest,
workspaceId: program.workspaceId,
...(linkId
? { linkId }
: links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING
? { partnerId: partner.id }
: { linkIds: links.map((link) => link.id) }),
dataAvailableFrom: program.startedAt ?? program.createdAt,
groupBy: endpoint,
});

if (!response || response.length === 0) return;

const csvData = convertToCSV(response);
zip.file(`${endpoint}.csv`, csvData);
}),
);

const zipData = await zip.generateAsync({ type: "nodebuffer" });

return new Response(zipData as unknown as BodyInit, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": "attachment; filename=analytics_export.zip",
},
});
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { getEvents } from "@/lib/analytics/get-events";
import { convertToCSV } from "@/lib/analytics/utils";
import { DubApiError } from "@/lib/api/errors";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { withPartnerProfile } from "@/lib/auth/partner";
import {
LARGE_PROGRAM_IDS,
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,
} from "@/lib/constants/partner-profile";
import { generateRandomName } from "@/lib/names";
import { ClickEvent, LeadEvent, SaleEvent } from "@/lib/types";
import {
PartnerProfileLinkSchema,
partnerProfileEventsQuerySchema,
} from "@/lib/zod/schemas/partner-profile";
import { COUNTRIES, capitalize } from "@dub/utils";
import * as z from "zod/v4";

type Row = ClickEvent | LeadEvent | SaleEvent;

const columnNames: Record<string, string> = {
trigger: "Event",
url: "Destination URL",
os: "OS",
referer: "Referrer",
refererUrl: "Referrer URL",
timestamp: "Date",
invoiceId: "Invoice ID",
saleAmount: "Sale Amount",
clickId: "Click ID",
};

const columnAccessors = {
trigger: (r: Row) => r.click.trigger,
event: (r: LeadEvent | SaleEvent) => r.eventName,
url: (r: ClickEvent) => r.click.url,
link: (r: Row) => r.domain + (r.key === "_root" ? "" : `/${r.key}`),
country: (r: Row) =>
r.country ? COUNTRIES[r.country] ?? r.country : r.country,
referer: (r: ClickEvent) => r.click.referer,
refererUrl: (r: ClickEvent) => r.click.refererUrl,
customer: (r: LeadEvent | SaleEvent) =>
r.customer.name + (r.customer.email ? ` <${r.customer.email}>` : ""),
invoiceId: (r: SaleEvent) => r.sale.invoiceId,
saleAmount: (r: SaleEvent) => "$" + (r.sale.amount / 100).toFixed(2),
clickId: (r: ClickEvent) => r.click.id,
};

// GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events
export const GET = withPartnerProfile(
async ({ partner, params, searchParams }) => {
const { program, links, totalCommissions, customerDataSharingEnabledAt } =
await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId: params.programId,
include: {
program: true,
links: true,
},
});

if (
LARGE_PROGRAM_IDS.includes(program.id) &&
totalCommissions < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS
) {
throw new DubApiError({
code: "forbidden",
message: "This feature is not available for your program.",
});
}

const parsedParams = partnerProfileEventsQuerySchema
.extend({
columns: z
.string()
.optional()
.transform((c) => (c ? c.split(",") : []))
.pipe(z.string().array()),
})
.parse(searchParams);

const { event, columns: columnsParam } = parsedParams;
let { linkId, domain, key, ...rest } = parsedParams;

// Default columns based on event type if not provided
const defaultColumns: Record<string, string[]> = {
clicks: ["timestamp", "link", "referer", "country", "device"],
leads: ["timestamp", "event", "link", "customer", "referer"],
sales: [
"timestamp",
"saleAmount",
"event",
"customer",
"referer",
"link",
],
};

const columns =
columnsParam.length > 0
? columnsParam
: defaultColumns[event] || defaultColumns.clicks;

if (linkId) {
if (!links.some((link) => link.id === linkId)) {
throw new DubApiError({
code: "not_found",
message: "Link not found",
});
}
} else if (domain && key) {
const foundLink = links.find(
(link) => link.domain === domain && link.key === key,
);
if (!foundLink) {
throw new DubApiError({
code: "not_found",
message: "Link not found",
});
}

linkId = foundLink.id;
}

if (links.length === 0) {
return new Response("", {
headers: {
"Content-Type": "application/csv",
"Content-Disposition": `attachment; filename=${event}_export.csv`,
},
});
}

const events = await getEvents({
...rest,
workspaceId: program.workspaceId,
...(linkId
? { linkId }
: links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING
? { partnerId: partner.id }
: { linkIds: links.map((link) => link.id) }),
dataAvailableFrom: program.startedAt ?? program.createdAt,
limit: 100000,
});

// Apply partner profile data transformations similar to the main events route
const transformedEvents = events.map((event) => {
// don't return ip address for partner profile
// @ts-ignore – ip is deprecated but present in the data
const { ip, click, customer, ...eventRest } = event;
const { ip: _, ...clickRest } = click;

return {
...eventRest,
click: clickRest,
link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null,
...(customer && {
customer: z
.object({
id: z.string(),
email: z.string(),
...(customerDataSharingEnabledAt && { name: z.string() }),
})
.parse({
...customer,
email: customer.email
? customerDataSharingEnabledAt
? customer.email
: customer.email.replace(/(?<=^.).+(?=.@)/, "****")
: customer.name || generateRandomName(),
...(customerDataSharingEnabledAt && {
name: customer.name || generateRandomName(),
}),
}),
}),
};
});

const data = transformedEvents.map((row) =>
Object.fromEntries(
columns.map((c) => [
columnNames?.[c] ?? capitalize(c),
columnAccessors[c]?.(row) ?? row?.[c],
]),
),
);

const csvData = convertToCSV(data);

return new Response(csvData, {
headers: {
"Content-Type": "application/csv",
"Content-Disposition": `attachment; filename=${event}_export.csv`,
},
});
},
);
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import Events from "@/ui/analytics/events";
import { EventsProvider } from "@/ui/analytics/events/events-provider";
import { PageContent } from "@/ui/layout/page-content";

export default function ProgramEvents() {
return (
<PageContent title="Events">
<Events />
<EventsProvider>
<Events />
</EventsProvider>
</PageContent>
);
}
9 changes: 7 additions & 2 deletions apps/web/ui/analytics/analytics-export-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ export function AnalyticsExportButton({
setOpenPopover: Dispatch<SetStateAction<boolean>>;
}) {
const [loading, setLoading] = useState(false);
const { queryString } = useContext(AnalyticsContext);
const { queryString, baseApiPath, partnerPage } = useContext(AnalyticsContext);

async function exportData() {
setLoading(true);
try {
const response = await fetch(`/api/analytics/export?${queryString}`, {
// Use partner profile export endpoint if on partner page, otherwise use regular export endpoint
const exportPath = partnerPage
? `${baseApiPath}/export`
: "/api/analytics/export";

const response = await fetch(`${exportPath}?${queryString}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Expand Down
Loading