Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 8 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,16 +48,16 @@ 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);

console.log("parsedParams", parsedParams);

const { event, domain, interval, start, end, columns, key, folderId } =
parsedParams;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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;
}

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,200 @@
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);

console.log("parsedParams", parsedParams);

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
17 changes: 12 additions & 5 deletions apps/web/ui/analytics/events/events-export-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import useWorkspace from "@/lib/swr/use-workspace";
import { Button, Download, TooltipContent } from "@dub/ui";
import { Dispatch, SetStateAction, useContext } from "react";
import { toast } from "sonner";
import { AnalyticsContext } from "../analytics-provider";
import { EventsContext } from "./events-provider";

export function EventsExportButton({
Expand All @@ -10,17 +11,23 @@ export function EventsExportButton({
setOpenPopover: Dispatch<SetStateAction<boolean>>;
}) {
const { exportQueryString } = useContext(EventsContext);
const { eventsApiPath } = useContext(AnalyticsContext);
const { slug, plan } = useWorkspace();

const needsHigherPlan = plan === "free" || plan === "pro";

console.log({ exportQueryString });

async function exportData() {
const response = await fetch(`/api/events/export?${exportQueryString}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${eventsApiPath}/export?${exportQueryString}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
});
);

if (!response.ok) {
throw new Error(response.statusText);
Expand Down
Loading