-
Notifications
You must be signed in to change notification settings - Fork 3k
FEAT: Application Analytics (ProgramApplicationEvent)
#3763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 98 commits
Commits
Show all changes
99 commits
Select commit
Hold shift + click to select a range
14450ce
FEAT: Application Analytics (`ProgramApplicationEvent`)
steven-tey 5b51d6f
finalize columns
steven-tey add8102
Merge branch 'main' into application-analytics
steven-tey fa5d38e
add more columns
steven-tey af782a9
Merge branch 'main' into application-analytics
steven-tey 3e40e8e
Implement application event tracking API and enhance partner profile …
devkiran aa11858
Create track-application-event.ts
devkiran 3bcdde3
Create constants.ts
devkiran 2487e12
Implement application event tracking with new schema and analytics co…
devkiran 3a95f5f
Refactor application event tracking to use programSlug and enhance an…
devkiran ec75711
Merge branch 'main' into application-analytics
devkiran 1cafb31
Track application visit and start events
devkiran 630d230
Update route.ts
devkiran 5556cd0
Update route.ts
devkiran 7b1f1ec
Implement event tracking for partner application updates
devkiran 6e0be5a
Merge branch 'main' into application-analytics
devkiran 750966f
Track application event request context and partner username
devkiran 2bd6761
Enhance application event tracking by adding country context and refa…
devkiran c68df4c
Refactor application event tracking to use markApplicationEvents for …
devkiran 8bfbc0a
Refactor application event tracking by moving cookie constants to the…
devkiran b67d54e
Refactor application analytics by consolidating tracking logic into a…
devkiran e5be863
Update update-partner-profile.ts
devkiran 1c4fa29
Update complete-program-applications.ts
devkiran abf5b87
Fix maxAge
devkiran d3cdc1c
Fix eventId
devkiran b21afd1
Update route.ts
devkiran bfac283
Add application events and analytics API routes with shared schemas
devkiran 09b101f
program analytics commissions tab ui only
pepeladeira ddb61b3
fetch data for commissions tab
pepeladeira 3bcdfaf
fix build error
pepeladeira b5ca4c3
ui improvements
pepeladeira 9004a31
Merge branch 'main' into program-analytics-commission-tab
pepeladeira f7c30c6
change commission analytics tab
pepeladeira 88ee12f
remove country and location
pepeladeira 57dd289
code improvements
pepeladeira 8c6d5b2
Merge branch 'main' into program-analytics-commission-tab
pepeladeira 37ccbb7
ui improvements
pepeladeira ee7e69e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira 8469530
code improvements
pepeladeira ea2dabe
code improvements
pepeladeira 2aae04f
Merge branch 'main' into program-analytics-commission-tab
pepeladeira ff8b97e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira 3040a7e
Merge branch 'main' into program-analytics-commission-tab
pepeladeira 4b43b21
code improvements
pepeladeira 01ccbb6
code improvements
pepeladeira e598b40
Merge branch 'main' into program-analytics-commission-tab
pepeladeira c7e770f
small nits
pepeladeira c86113b
change performance chart colors
pepeladeira 3652d55
merge conflicts
steven-tey 9f9a721
fix inefficient code
steven-tey b68c0fd
Merge branch 'main' into program-analytics-commission-tab
pepeladeira ae5197c
Merge branch 'main' into program-analytics-commission-tab
steven-tey 695bb51
ui improvements
pepeladeira 58c5daf
code improvements
pepeladeira e0b6429
Merge branch 'main' into application-analytics
devkiran 4f058f3
Merge branch 'application-analytics' into application-analytics-ui
devkiran 15f7fb1
Merge branch 'main' into program-analytics-commission-tab
devkiran 121be77
Merge branch 'program-analytics-commission-tab' into application-anal…
devkiran bc9d1ff
Refactor application analytics API to support event-based queries and…
devkiran 719bc89
Implement application events table and enhance event filtering. Added…
devkiran c840beb
Enhance Program Analytics with Applications Tab and Filtering. Added …
devkiran b414604
Add useApplicationEvents hook for improved event data fetching. Moved…
devkiran a92cb9e
Add Applications Breakdown Cards and Analytics Hook. Introduced a new…
devkiran 8fba898
Updated the analytics query schema to support these new groupings,
devkiran bb7ba4e
Refactor analytics API to streamline partner group handling
devkiran 579bede
Fix the count
devkiran fd5e914
Updated types
devkiran 7583b68
address CR comments
devkiran d200fa3
Update page-client.tsx
devkiran 7a8edea
Remove console.log
devkiran 3fd0be6
Format
devkiran 64d2e81
Update route.ts
devkiran 73c7968
Rename with trackApplicationEvents
devkiran 7e1a166
Fix build
devkiran 25d6fe3
Merge branch 'main' into application-analytics
steven-tey 68fccb7
fix merge conflicts
steven-tey 96f26ee
Merge branch 'main' into application-analytics
steven-tey 2448409
stash
steven-tey e9eb354
Merge branch 'main' into application-analytics
steven-tey 65f4b0a
rearrange types
steven-tey 5f79f17
Merge branch 'main' into application-analytics
devkiran 3c6a6a1
Add multi filter
devkiran d1ea87c
Rename folder
devkiran fa5b9ff
Refactor analytics data processing to improve partner event mapping a…
devkiran f44d5a9
Update use-applications-analytics-filters.tsx
devkiran 5152f87
card display by adding value-based filtering for partners, referral s…
devkiran 9630966
Merge branch 'main' into application-analytics
steven-tey 4f23dc3
simplified implementation
steven-tey 3b4fe3e
standardize
steven-tey 1f5f084
fix timeseries, simplify other
steven-tey db1a92c
fix View button
steven-tey ca181de
Merge branch 'main' into application-analytics
steven-tey ca9f276
Merge branch 'main' into application-analytics
steven-tey f261d7a
remove groupId
steven-tey 9607fc6
fix ts error
steven-tey f540016
improve naming/icons to match designs
steven-tey ef49dca
Update applications-analytics-cards.tsx
steven-tey a5d8165
address coderabbit feedback
steven-tey 4084d0a
address coderabbit feedback
steven-tey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,355 @@ | ||
| 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, | ||
| }, | ||
| }; | ||
|
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 events = await prisma.programApplicationEvent.groupBy({ | ||
| by: [groupByColumnMap[groupBy]], | ||
| where, | ||
| ...aggregations, | ||
| orderBy: { | ||
| _count: { | ||
| [groupBy]: "desc", | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const results = events.map((row) => ({ | ||
| [groupBy]: row[groupByColumnMap[groupBy]], | ||
| ...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), | ||
| ); | ||
|
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, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.