Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
14450ce
FEAT: Application Analytics (`ProgramApplicationEvent`)
steven-tey Apr 16, 2026
5b51d6f
finalize columns
steven-tey Apr 17, 2026
add8102
Merge branch 'main' into application-analytics
steven-tey Apr 17, 2026
fa5d38e
add more columns
steven-tey Apr 17, 2026
af782a9
Merge branch 'main' into application-analytics
steven-tey Apr 17, 2026
3e40e8e
Implement application event tracking API and enhance partner profile …
devkiran Apr 17, 2026
aa11858
Create track-application-event.ts
devkiran Apr 17, 2026
3bcdde3
Create constants.ts
devkiran Apr 17, 2026
2487e12
Implement application event tracking with new schema and analytics co…
devkiran Apr 17, 2026
3a95f5f
Refactor application event tracking to use programSlug and enhance an…
devkiran Apr 17, 2026
ec75711
Merge branch 'main' into application-analytics
devkiran Apr 20, 2026
1cafb31
Track application visit and start events
devkiran Apr 20, 2026
630d230
Update route.ts
devkiran Apr 20, 2026
5556cd0
Update route.ts
devkiran Apr 20, 2026
7b1f1ec
Implement event tracking for partner application updates
devkiran Apr 20, 2026
6e0be5a
Merge branch 'main' into application-analytics
devkiran Apr 21, 2026
750966f
Track application event request context and partner username
devkiran Apr 21, 2026
2bd6761
Enhance application event tracking by adding country context and refa…
devkiran Apr 21, 2026
c68df4c
Refactor application event tracking to use markApplicationEvents for …
devkiran Apr 21, 2026
8bfbc0a
Refactor application event tracking by moving cookie constants to the…
devkiran Apr 21, 2026
b67d54e
Refactor application analytics by consolidating tracking logic into a…
devkiran Apr 21, 2026
e5be863
Update update-partner-profile.ts
devkiran Apr 21, 2026
1c4fa29
Update complete-program-applications.ts
devkiran Apr 21, 2026
abf5b87
Fix maxAge
devkiran Apr 21, 2026
d3cdc1c
Fix eventId
devkiran Apr 21, 2026
b21afd1
Update route.ts
devkiran Apr 21, 2026
bfac283
Add application events and analytics API routes with shared schemas
devkiran Apr 22, 2026
09b101f
program analytics commissions tab ui only
pepeladeira Apr 22, 2026
ddb61b3
fetch data for commissions tab
pepeladeira Apr 22, 2026
3bcdfaf
fix build error
pepeladeira Apr 22, 2026
b5ca4c3
ui improvements
pepeladeira Apr 22, 2026
9004a31
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 23, 2026
f7c30c6
change commission analytics tab
pepeladeira Apr 23, 2026
88ee12f
remove country and location
pepeladeira Apr 23, 2026
57dd289
code improvements
pepeladeira Apr 23, 2026
8c6d5b2
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
37ccbb7
ui improvements
pepeladeira Apr 24, 2026
ee7e69e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
8469530
code improvements
pepeladeira Apr 24, 2026
ea2dabe
code improvements
pepeladeira Apr 24, 2026
2aae04f
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
ff8b97e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
3040a7e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
4b43b21
code improvements
pepeladeira Apr 24, 2026
01ccbb6
code improvements
pepeladeira Apr 24, 2026
e598b40
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 24, 2026
c7e770f
small nits
pepeladeira Apr 24, 2026
c86113b
change performance chart colors
pepeladeira Apr 24, 2026
3652d55
merge conflicts
steven-tey Apr 25, 2026
9f9a721
fix inefficient code
steven-tey Apr 25, 2026
b68c0fd
Merge branch 'main' into program-analytics-commission-tab
pepeladeira Apr 27, 2026
ae5197c
Merge branch 'main' into program-analytics-commission-tab
steven-tey Apr 27, 2026
695bb51
ui improvements
pepeladeira Apr 27, 2026
58c5daf
code improvements
pepeladeira Apr 27, 2026
e0b6429
Merge branch 'main' into application-analytics
devkiran Apr 28, 2026
4f058f3
Merge branch 'application-analytics' into application-analytics-ui
devkiran Apr 28, 2026
15f7fb1
Merge branch 'main' into program-analytics-commission-tab
devkiran Apr 28, 2026
121be77
Merge branch 'program-analytics-commission-tab' into application-anal…
devkiran Apr 28, 2026
bc9d1ff
Refactor application analytics API to support event-based queries and…
devkiran Apr 28, 2026
719bc89
Implement application events table and enhance event filtering. Added…
devkiran Apr 28, 2026
c840beb
Enhance Program Analytics with Applications Tab and Filtering. Added …
devkiran Apr 28, 2026
b414604
Add useApplicationEvents hook for improved event data fetching. Moved…
devkiran Apr 28, 2026
a92cb9e
Add Applications Breakdown Cards and Analytics Hook. Introduced a new…
devkiran Apr 28, 2026
8fba898
Updated the analytics query schema to support these new groupings,
devkiran Apr 28, 2026
bb7ba4e
Refactor analytics API to streamline partner group handling
devkiran Apr 28, 2026
579bede
Fix the count
devkiran Apr 28, 2026
fd5e914
Updated types
devkiran Apr 28, 2026
7583b68
address CR comments
devkiran Apr 28, 2026
d200fa3
Update page-client.tsx
devkiran Apr 28, 2026
7a8edea
Remove console.log
devkiran Apr 28, 2026
3fd0be6
Format
devkiran Apr 28, 2026
64d2e81
Update route.ts
devkiran Apr 28, 2026
73c7968
Rename with trackApplicationEvents
devkiran Apr 28, 2026
7e1a166
Fix build
devkiran Apr 28, 2026
25d6fe3
Merge branch 'main' into application-analytics
steven-tey Apr 29, 2026
68fccb7
fix merge conflicts
steven-tey Apr 29, 2026
96f26ee
Merge branch 'main' into application-analytics
steven-tey Apr 29, 2026
2448409
stash
steven-tey Apr 29, 2026
e9eb354
Merge branch 'main' into application-analytics
steven-tey Apr 30, 2026
65f4b0a
rearrange types
steven-tey Apr 30, 2026
5f79f17
Merge branch 'main' into application-analytics
devkiran Apr 30, 2026
3c6a6a1
Add multi filter
devkiran Apr 30, 2026
d1ea87c
Rename folder
devkiran Apr 30, 2026
fa5b9ff
Refactor analytics data processing to improve partner event mapping a…
devkiran Apr 30, 2026
f44d5a9
Update use-applications-analytics-filters.tsx
devkiran Apr 30, 2026
5152f87
card display by adding value-based filtering for partners, referral s…
devkiran Apr 30, 2026
9630966
Merge branch 'main' into application-analytics
steven-tey May 1, 2026
4f23dc3
simplified implementation
steven-tey May 1, 2026
3b4fe3e
standardize
steven-tey May 2, 2026
1f5f084
fix timeseries, simplify other
steven-tey May 2, 2026
db1a92c
fix View button
steven-tey May 2, 2026
ca181de
Merge branch 'main' into application-analytics
steven-tey May 2, 2026
ca9f276
Merge branch 'main' into application-analytics
steven-tey May 2, 2026
f261d7a
remove groupId
steven-tey May 2, 2026
9607fc6
fix ts error
steven-tey May 2, 2026
f540016
improve naming/icons to match designs
steven-tey May 2, 2026
ef49dca
Update applications-analytics-cards.tsx
steven-tey May 2, 2026
a5d8165
address coderabbit feedback
steven-tey May 2, 2026
4084d0a
address coderabbit feedback
steven-tey May 3, 2026
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
359 changes: 359 additions & 0 deletions apps/web/app/(ee)/api/applications/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import {
applicationEventAnalyticsQuerySchema,
applicationEventAnalyticsSchema,
} from "@/lib/application-events/schema";
import { withWorkspace } from "@/lib/auth";
import { sqlGranularityMap } from "@/lib/planetscale/granularity";
import { ApplicationEventAnalyticsQuery } from "@/lib/types";
import { TZDate, tz } from "@date-fns/tz";
import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { parseFilterValue } from "@dub/utils";
import { format } from "date-fns/format";
import { NextResponse } from "next/server";
import * as z from "zod/v4";

type TimeseriesApplicationRow = {
start: string | Date;
visits: bigint;
starts: bigint;
submissions: bigint;
approvals: bigint;
rejections: bigint;
};

const aggregations = {
_count: {
visitedAt: true,
startedAt: true,
submittedAt: true,
approvedAt: true,
rejectedAt: true,
},
} as const;

// GET /api/applications/analytics
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const parsedFilters =
applicationEventAnalyticsQuerySchema.parse(searchParams);

const {
groupBy,
partnerId,
country,
referralSource,
start,
end,
interval,
timezone: timezoneParam,
} = parsedFilters;

// Align with CONVERT_TZ in raw SQL and analyticsQuerySchema default (UTC when omitted).
const timezone = timezoneParam ?? "UTC";

const { startDate, endDate } = getStartEndDates({
interval,
start,
end,
timezone,
});

const partnerFilter = parseFilterValue(partnerId);
const countryFilter = parseFilterValue(country);
const referralSourceFilter = parseFilterValue(referralSource);

const where: Prisma.ProgramApplicationEventWhereInput = {
programId,
...(partnerFilter && {
referredByPartnerId:
partnerFilter.sqlOperator === "NOT IN"
? { notIn: partnerFilter.values }
: { in: partnerFilter.values },
}),
...(referralSourceFilter && {
referralSource:
referralSourceFilter.sqlOperator === "NOT IN"
? { notIn: referralSourceFilter.values }
: { in: referralSourceFilter.values },
}),
...(countryFilter && {
country:
countryFilter.sqlOperator === "NOT IN"
? { notIn: countryFilter.values }
: { in: countryFilter.values },
}),
visitedAt: {
gte: startDate,
lt: endDate,
},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const responseSchema = applicationEventAnalyticsSchema[groupBy];

// Get the absolute counts
if (groupBy === "count") {
const { _count } = await prisma.programApplicationEvent.aggregate({
where,
...aggregations,
});

return NextResponse.json(responseSchema.parse(formatCounts(_count)));
}

// Get the counts grouped by the specified column
if (["referralSource", "country"].includes(groupBy)) {
const groupByColumnMap = {
referralSource: "referralSource",
country: "country",
};

const groupByColumn = groupByColumnMap[groupBy];

const events = await prisma.programApplicationEvent.groupBy({
by: [groupByColumn],
where,
...aggregations,
orderBy: {
_count: {
[groupBy]: "desc",
},
},
});

const results = events
.filter((row) => row[groupByColumn] !== null)
.map((row) => ({
[groupBy]: row[groupByColumn],
...formatCounts(row._count),
}));

return NextResponse.json(z.array(responseSchema).parse(results));
}

// Get the counts grouped by the partner
if (groupBy === "partnerId") {
return byPartnerId({
where,
});
}

// Get the timeseries
if (groupBy === "timeseries") {
return byTimeseries({
...parsedFilters,
programId,
timezone,
});
}

return NextResponse.json(null);
});

async function byPartnerId({
where,
}: {
where: Prisma.ProgramApplicationEventWhereInput;
}) {
const events = await prisma.programApplicationEvent.groupBy({
by: ["referredByPartnerId"],
where,
...aggregations,
});

const partnerIds = events
.map(({ referredByPartnerId }) => referredByPartnerId)
.filter((id): id is string => Boolean(id));

const partners =
partnerIds.length > 0
? await prisma.partner.findMany({
where: {
id: {
in: partnerIds,
},
},
select: {
id: true,
name: true,
image: true,
email: true,
},
})
: [];

const eventCountByPartnerId = new Map(
events.map(({ referredByPartnerId, _count }) => [
referredByPartnerId,
_count,
]),
);

const results = partners
.map((partner) => {
const partnerEvents = eventCountByPartnerId.get(partner.id);

if (!partnerEvents) {
return null;
}

return {
partner,
...formatCounts(partnerEvents),
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);

return NextResponse.json(
z.array(applicationEventAnalyticsSchema["partnerId"]).parse(results),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function byTimeseries({
programId,
partnerId,
country,
referralSource,
timezone,
interval,
start,
end,
}: ApplicationEventAnalyticsQuery & { programId: string }) {
const tzId = timezone ?? "UTC";

const { startDate, endDate, granularity } = getStartEndDates({
interval,
start,
end,
timezone: tzId,
});

const { dateFormat, dateIncrement, startFunction, formatString } =
sqlGranularityMap[granularity];

const partnerFilter = parseFilterValue(partnerId);
const countryFilter = parseFilterValue(country);
const referralSourceFilter = parseFilterValue(referralSource);

const conditions: Prisma.Sql[] = [
Prisma.sql`e.programId = ${programId}`,
Prisma.sql`e.visitedAt >= ${startDate}`,
Prisma.sql`e.visitedAt < ${endDate}`,
];

if (partnerFilter) {
const list = Prisma.join(partnerFilter.values.map((v) => Prisma.sql`${v}`));
conditions.push(
partnerFilter.sqlOperator === "NOT IN"
? Prisma.sql`e.partnerId NOT IN (${list})`
: Prisma.sql`e.partnerId IN (${list})`,
);
}

if (referralSourceFilter) {
const list = Prisma.join(
referralSourceFilter.values.map((v) => Prisma.sql`${v}`),
);
conditions.push(
referralSourceFilter.sqlOperator === "NOT IN"
? Prisma.sql`e.referralSource NOT IN (${list})`
: Prisma.sql`e.referralSource IN (${list})`,
);
}

if (countryFilter) {
const list = Prisma.join(countryFilter.values.map((v) => Prisma.sql`${v}`));
conditions.push(
countryFilter.sqlOperator === "NOT IN"
? Prisma.sql`e.country NOT IN (${list})`
: Prisma.sql`e.country IN (${list})`,
);
}

const whereClause = Prisma.join(conditions, " AND ");

const rows = await prisma.$queryRaw<TimeseriesApplicationRow[]>(
Prisma.sql`
SELECT
DATE_FORMAT(CONVERT_TZ(e.visitedAt, "UTC", ${tzId}), ${dateFormat}) AS start,
COUNT(e.visitedAt) AS visits,
COUNT(e.startedAt) AS starts,
COUNT(e.submittedAt) AS submissions,
COUNT(e.approvedAt) AS approvals,
COUNT(e.rejectedAt) AS rejections
FROM ProgramApplicationEvent e
WHERE ${whereClause}
GROUP BY start
ORDER BY start ASC`,
);

const periodKeyFromSql = (start: TimeseriesApplicationRow["start"]) =>
typeof start === "string"
? start
: format(new TZDate(start, tzId), formatString, {
in: tz(tzId),
});

const lookup = Object.fromEntries(
rows.map((r) => [
periodKeyFromSql(r.start),
{
visits: Number(r.visits),
starts: Number(r.starts),
submissions: Number(r.submissions),
approvals: Number(r.approvals),
rejections: Number(r.rejections),
},
]),
);

const tzStartDate = new TZDate(startDate, tzId);
const tzEndDate = new TZDate(endDate, tzId);

let currentDate = startFunction(tzStartDate);
const timeseries: z.infer<
(typeof applicationEventAnalyticsSchema)["timeseries"]
>[] = [];

while (currentDate < tzEndDate) {
const periodKey = format(currentDate, formatString, {
in: tz(tzId),
});

timeseries.push({
start: currentDate.toISOString(),
...(lookup[periodKey] ?? {
visits: 0,
starts: 0,
submissions: 0,
approvals: 0,
rejections: 0,
}),
});

currentDate = dateIncrement(currentDate);
}

return NextResponse.json(
z.array(applicationEventAnalyticsSchema["timeseries"]).parse(timeseries),
);
}

function formatCounts(c: {
visitedAt: number;
startedAt: number;
submittedAt: number;
approvedAt: number;
rejectedAt: number;
}) {
return {
visits: c.visitedAt,
starts: c.startedAt,
submissions: c.submittedAt,
approvals: c.approvedAt,
rejections: c.rejectedAt,
};
}
Loading
Loading