-
Couldn't load subscription status.
- Fork 2.7k
Network ranking algorithm v2 #2960
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
base: main
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds program similarity calculators (category, partner, performance), a cron route to batch compute and persist ProgramSimilarity, a partner-ranking engine that leverages similar programs, removes several partner filters in APIs/UI in favor of categories, updates partner stats aggregation, adds a backfill script for program categories, and updates Prisma schemas and Vercel cron. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Cron as Vercel Cron
participant API as /api/cron/calculate-program-similarities
participant DB as Prisma/DB
participant Q as QStash
Cron->>API: GET (signed)
API->>DB: findNextProgram(currentProgramId)
API->>DB: Fetch candidate programs (batch, cursor)
par For each candidate program
API->>DB: fetch categories, partners, performance aggregates
API->>API: calculateCategorySimilarity(...)
API->>API: calculatePartnerSimilarity(...)
API->>API: calculatePerformanceSimilarity(...)
API->>API: compute weighted similarityScore
end
API->>DB: delete existing ProgramSimilarity rows for batch
API->>DB: bulk insert new ProgramSimilarity rows (skipDuplicates)
alt More work remains
API->>Q: Publish next job (POST with cursor)
else Done
API-->>Cron: 200 JSON (completed)
end
sequenceDiagram
autonumber
actor Client
participant API as /api/network/partners (GET)
participant DB as Prisma/DB
participant Rank as calculatePartnerRanking
Client->>API: request with filters
API->>DB: fetch program (include similarPrograms where similarityScore>0.3 limit 5)
API->>Rank: call calculatePartnerRanking(programId, filters, similarPrograms)
Rank->>DB: raw SQL query (filters, joins, optional similar-program metrics)
DB-->>Rank: ranked partners + metrics
Rank-->>API: ranked results (categories, scores, timestamps)
API-->>Client: partners list
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx (1)
673-681: Fix SWR type for fetching by partnerIds (returns array)You index into
fetchedPartners?.[0]but the SWR type is singular. Use an array type.- const { data: fetchedPartners, isLoading } = useSWR<NetworkPartnerProps>( + const { data: fetchedPartners, isLoading } = useSWR<NetworkPartnerProps[]>( fetchPartnerId && `/api/network/partners?workspaceId=${workspaceId}&partnerIds=${fetchPartnerId}`, fetcher, { keepPreviousData: true, }, );
🧹 Nitpick comments (16)
apps/web/lib/zod/schemas/partner-network.ts (2)
100-101: Make categories resilient: default to empty arrayIf the route misses categories for any record, the UI will crash on
.map. Default to [] to harden parsing.- categories: z.array(z.string()), + categories: z.array(z.string()).default([]),
73-77: Optional: trim unused partner fields from the public schemaUI no longer uses industryInterests, preferredEarningStructures, or salesChannels. Dropping them reduces payload and keeps API surface aligned.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx (1)
376-385: Defensive mapping when categories might be missingEven with schema defaults, be safe client-side.
- const categoriesData = useMemo( - () => - partner - ? partner.categories.map((category) => ({ + const categoriesData = useMemo( + () => + partner + ? (partner.categories ?? []).map((category) => ({ label: category.replace(/_/g, " "), })) : null, [partner], );apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx (2)
55-67: Simplify filters now that multi-filters are removedDrop multiFilters and the branching logic to reduce complexity.
- const multiFilters = useMemo(() => ({}), []); - - const activeFilters = useMemo(() => { - const { country } = searchParamsObj; - - return [ - ...Object.entries(multiFilters) - .map(([key, value]) => ({ key, value })) - .filter(({ value }) => value.length > 0), - - ...(country ? [{ key: "country", value: country }] : []), - ]; - }, [searchParamsObj, multiFilters]); + const activeFilters = useMemo(() => { + const { country } = searchParamsObj; + return country ? [{ key: "country", value: country }] : []; + }, [searchParamsObj]); @@ - const onSelect = useCallback( - (key: string, value: any) => - queryParams({ - set: Object.keys(multiFilters).includes(key) - ? { - [key]: multiFilters[key].concat(value).join(","), - } - : { - [key]: value, - }, - del: "page", - }), - [queryParams, multiFilters], - ); + const onSelect = useCallback( + (key: string, value: any) => + queryParams({ set: { [key]: value }, del: "page" }), + [queryParams], + ); @@ - const onRemove = useCallback( - (key: string, value: any) => { - if ( - Object.keys(multiFilters).includes(key) && - !(multiFilters[key].length === 1 && multiFilters[key][0] === value) - ) { - queryParams({ - set: { - [key]: multiFilters[key].filter((id) => id !== value).join(","), - }, - del: "page", - }); - } else { - queryParams({ - del: [key, "page"], - }); - } - }, - [queryParams, multiFilters], - ); + const onRemove = useCallback( + (key: string) => queryParams({ del: [key, "page"] }), + [queryParams], + );Also applies to: 69-103
108-109: onRemoveAll: consider clearing “search” if presentIf search is a filter, optionally clear it with others.
apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-category-similarity.ts (1)
8-26: LGTM; add DB indexes for scaleLogic is sound. Ensure indexes on ProgramCategory(programId) (and possibly (programId, category)) to keep reads fast at scale.
Also applies to: 28-43
apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-partner-similarity.ts (1)
10-15: Early return when comparing the same programAvoid unnecessary query for identical ids.
export async function calculatePartnerSimilarity( program1Id: string, program2Id: string, ): Promise<number> { + if (program1Id === program2Id) return 1; const [result] = await prisma.$queryRaw<PartnerSimilarityResult[]>`apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1)
19-25: Simplify empty batch check
entriesis an array;entries.length === 0is clearer and cheaper.- if (!entries || Object.keys(entries).length === 0) { + if (!entries || entries.length === 0) { return { success: true, updates: [], processedEntryIds: [], }; }apps/web/app/(ee)/api/network/partners/route.ts (3)
24-31: Order similar programs to make “top 5” deterministicWithout an orderBy, take: 5 returns arbitrary rows. Sort by similarityScore desc.
similarPrograms: { where: { similarityScore: { gt: 0.3, }, }, + orderBy: { + similarityScore: "desc", + }, take: 5, },
26-28: De-duplicate the similarity threshold constant0.3 is also hard-coded in the cron route. Extract a shared constant (e.g., libs/config) and import here to avoid drift.
69-71: Normalize dates and filter empty categoriesParse lastConversionAt/recruitedAt to Date and drop empty category strings to satisfy schema reliably.
partners.map((partner) => ({ ...partner, conversionScore: getConversionScore(partner.conversionRate || 0), starredAt: partner.starredAt ? new Date(partner.starredAt) : null, ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null, invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null, + lastConversionAt: partner.lastConversionAt + ? new Date(partner.lastConversionAt) + : null, + recruitedAt: partner.recruitedAt + ? new Date(partner.recruitedAt) + : null, - categories: partner.categories - ? partner.categories.split(",").map((c: string) => c.trim()) - : [], + categories: partner.categories + ? partner.categories + .split(",") + .map((c: string) => c.trim()) + .filter(Boolean) + : [], }))apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts (2)
177-189: Limit deletion to current-program/batch pairs to avoid wiping unrelated similaritiesCurrent deleteMany removes all similarities where programId ∈ batch programs, which can temporarily drop unrelated data. Scope deletes to pairs between currentProgram and the batch, both directions.
- await tx.programSimilarity.deleteMany({ - where: { - programId: { - in: programIds, - }, - }, - }); + await tx.programSimilarity.deleteMany({ + where: { + OR: [ + { + programId: currentProgram.id, + similarProgramId: { in: programIds }, + }, + { + programId: { in: programIds }, + similarProgramId: currentProgram.id, + }, + ], + }, + });
25-28: Centralize the similarity thresholdSIMILARITY_SCORE_THRESHOLD = 0.3 is duplicated in the partners route include filter. Extract to a shared config and import in both places.
apps/web/lib/api/network/partner-ranking.ts (3)
167-213: Prefer a typed result instead of ArrayDefine a PartnerRow type matching selected columns to catch drift at compile time.
type PartnerRow = { id: string; country: string | null; // ...p.* fields you actually rely on lastConversionAt: Date | null; conversionRate: number | null; starredAt: Date | null; ignoredAt: Date | null; invitedAt: Date | null; categories: string | null; recruitedAt: Date | null; finalScore: number; }; const partners = await prisma.$queryRaw<PartnerRow[]>`...`;
196-205: Operational: add supporting indexesFor this query pattern, ensure indexes exist on:
- ProgramEnrollment(programId, partnerId, status)
- DiscoveredPartner(programId, partnerId, starredAt, invitedAt, ignoredAt)
- ProgramCategory(programId, category)
186-190: ReplaceArray<any>with a typed interface
Use a defined type for the query result to catch regressions. Joins are safe (unique constraint onProgramEnrollment(partnerId, programId)) and performant (@@index(programId)).
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (16)
apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-category-similarity.ts(1 hunks)apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-partner-similarity.ts(1 hunks)apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-performance-similarity.ts(1 hunks)apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts(1 hunks)apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts(7 hunks)apps/web/app/(ee)/api/network/partners/count/route.ts(1 hunks)apps/web/app/(ee)/api/network/partners/route.ts(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx(3 hunks)apps/web/lib/api/network/partner-ranking.ts(1 hunks)apps/web/lib/zod/schemas/partner-network.ts(1 hunks)apps/web/scripts/migrations/backfill-program-categories.ts(1 hunks)apps/web/vercel.json(1 hunks)packages/prisma/schema/network.prisma(1 hunks)packages/prisma/schema/partner.prisma(1 hunks)packages/prisma/schema/program.prisma(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts (5)
apps/web/lib/cron/verify-vercel.ts (1)
verifyVercelSignature(3-20)apps/web/lib/api/errors.ts (1)
handleApiError(124-173)apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-category-similarity.ts (1)
calculateCategorySimilarity(4-43)apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-partner-similarity.ts (1)
calculatePartnerSimilarity(10-44)apps/web/app/(ee)/api/cron/calculate-program-similarities/calculate-performance-similarity.ts (1)
calculatePerformanceSimilarity(12-65)
apps/web/app/(ee)/api/network/partners/route.ts (2)
apps/web/lib/zod/schemas/partner-network.ts (2)
getNetworkPartnersQuerySchema(36-50)NetworkPartnerSchema(63-102)apps/web/lib/api/network/partner-ranking.ts (1)
calculatePartnerRanking(40-213)
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (3)
apps/web/app/(ee)/api/partners/export/route.ts (1)
GET(21-99)apps/web/app/(ee)/api/cron/streams/update-workspace-clicks/route.ts (1)
GET(182-216)apps/web/lib/cron/verify-vercel.ts (1)
verifyVercelSignature(3-20)
apps/web/app/(ee)/api/network/partners/count/route.ts (2)
apps/web/lib/api/errors.ts (1)
DubApiError(75-92)apps/web/lib/zod/schemas/partner-network.ts (1)
getNetworkPartnersCountQuerySchema(52-61)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (1)
apps/web/app/(ee)/api/network/partners/count/route.ts (1)
42-57: Discover where-clause logic looks good; add tests for starred permutationsThe OR branches for starred=true/false/undefined are subtle. Recommend adding unit tests for the three cases to lock behavior.
| gt: currentProgramId, | ||
| notIn: [ACME_PROGRAM_ID], | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Using currentProgramId instead of currentProgram.id
If currentProgramId is undefined, this breaks the query. Use the resolved currentProgram.id.
- id: {
- gt: currentProgramId,
- notIn: [ACME_PROGRAM_ID],
- },
+ id: {
+ gt: currentProgram.id,
+ notIn: [ACME_PROGRAM_ID],
+ },📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| gt: currentProgramId, | |
| notIn: [ACME_PROGRAM_ID], | |
| }, | |
| }, | |
| id: { | |
| gt: currentProgram.id, | |
| notIn: [ACME_PROGRAM_ID], | |
| }, |
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts around
lines 85 to 88, the query is using currentProgramId which may be undefined;
replace it with the resolved currentProgram.id and guard against currentProgram
being undefined before building the query. Update the code to reference
currentProgram.id (e.g., notIn: [ACME_PROGRAM_ID], gt: currentProgram.id) and
add an early return or error if currentProgram is not found so the query never
runs with an undefined id.
| // Otherwise, find the first/next program | ||
| return await prisma.program.findFirst({ | ||
| where: { | ||
| ...(afterProgramId && { | ||
| id: { | ||
| gt: afterProgramId, | ||
| notIn: [ACME_PROGRAM_ID], | ||
| }, | ||
| }), | ||
| workspace: { | ||
| plan: { | ||
| in: ["advanced", "enterprise"], | ||
| }, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ACME exclusion missing when afterProgramId is undefined
findNextProgram should always exclude ACME. The current condition only applies when afterProgramId exists.
return await prisma.program.findFirst({
where: {
- ...(afterProgramId && {
- id: {
- gt: afterProgramId,
- notIn: [ACME_PROGRAM_ID],
- },
- }),
+ ...(afterProgramId
+ ? {
+ id: {
+ gt: afterProgramId,
+ notIn: [ACME_PROGRAM_ID],
+ },
+ }
+ : {
+ id: {
+ notIn: [ACME_PROGRAM_ID],
+ },
+ }),
workspace: {
plan: {
in: ["advanced", "enterprise"],
},
},
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Otherwise, find the first/next program | |
| return await prisma.program.findFirst({ | |
| where: { | |
| ...(afterProgramId && { | |
| id: { | |
| gt: afterProgramId, | |
| notIn: [ACME_PROGRAM_ID], | |
| }, | |
| }), | |
| workspace: { | |
| plan: { | |
| in: ["advanced", "enterprise"], | |
| }, | |
| }, | |
| }, | |
| // Otherwise, find the first/next program | |
| return await prisma.program.findFirst({ | |
| where: { | |
| ...(afterProgramId | |
| ? { | |
| id: { | |
| gt: afterProgramId, | |
| notIn: [ACME_PROGRAM_ID], | |
| }, | |
| } | |
| : { | |
| id: { | |
| notIn: [ACME_PROGRAM_ID], | |
| }, | |
| }), | |
| workspace: { | |
| plan: { | |
| in: ["advanced", "enterprise"], | |
| }, | |
| }, | |
| }, |
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts around
lines 242 to 256, the current where clause only excludes ACME_PROGRAM_ID when
afterProgramId is provided; change the predicate so ACME is always excluded
(e.g., remove the id.notIn from inside the afterProgramId block and add a
top-level id condition that always excludes ACME_PROGRAM_ID, or add a top-level
NOT/NOT IN for ACME alongside the workspace.plan filter) so findFirst will never
return the ACME program whether or not afterProgramId is set.
| // Calculate consistency score based on days since last conversion | ||
| let consistencyScore = 50; | ||
| if (lastConversionAt && enrollment.daysSinceLastConversion) { | ||
| if (enrollment.daysSinceLastConversion <= 7) { | ||
| consistencyScore = 100; | ||
| } else if (enrollment.daysSinceLastConversion <= 30) { | ||
| consistencyScore = 85; | ||
| } else if (enrollment.daysSinceLastConversion <= 90) { | ||
| consistencyScore = 70; | ||
| } else if (enrollment.daysSinceLastConversion <= 180) { | ||
| consistencyScore = 55; | ||
| } else { | ||
| consistencyScore = 40; | ||
| } | ||
| } | ||
|
|
||
| enrollment.consistencyScore = consistencyScore; | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: 0 daysSinceLastConversion is treated as falsy
When last conversion is today (0 days), block is skipped and score stays at 50. Check for undefined instead of truthiness.
- let consistencyScore = 50;
- if (lastConversionAt && enrollment.daysSinceLastConversion) {
+ let consistencyScore = 50;
+ if (
+ lastConversionAt &&
+ enrollment.daysSinceLastConversion !== undefined
+ ) {
if (enrollment.daysSinceLastConversion <= 7) {
consistencyScore = 100;
} else if (enrollment.daysSinceLastConversion <= 30) {
consistencyScore = 85;
} else if (enrollment.daysSinceLastConversion <= 90) {
consistencyScore = 70;
} else if (enrollment.daysSinceLastConversion <= 180) {
consistencyScore = 55;
} else {
consistencyScore = 40;
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Calculate consistency score based on days since last conversion | |
| let consistencyScore = 50; | |
| if (lastConversionAt && enrollment.daysSinceLastConversion) { | |
| if (enrollment.daysSinceLastConversion <= 7) { | |
| consistencyScore = 100; | |
| } else if (enrollment.daysSinceLastConversion <= 30) { | |
| consistencyScore = 85; | |
| } else if (enrollment.daysSinceLastConversion <= 90) { | |
| consistencyScore = 70; | |
| } else if (enrollment.daysSinceLastConversion <= 180) { | |
| consistencyScore = 55; | |
| } else { | |
| consistencyScore = 40; | |
| } | |
| } | |
| enrollment.consistencyScore = consistencyScore; | |
| }); | |
| // Calculate consistency score based on days since last conversion | |
| let consistencyScore = 50; | |
| if ( | |
| lastConversionAt && | |
| enrollment.daysSinceLastConversion !== undefined | |
| ) { | |
| if (enrollment.daysSinceLastConversion <= 7) { | |
| consistencyScore = 100; | |
| } else if (enrollment.daysSinceLastConversion <= 30) { | |
| consistencyScore = 85; | |
| } else if (enrollment.daysSinceLastConversion <= 90) { | |
| consistencyScore = 70; | |
| } else if (enrollment.daysSinceLastConversion <= 180) { | |
| consistencyScore = 55; | |
| } else { | |
| consistencyScore = 40; | |
| } | |
| } | |
| enrollment.consistencyScore = consistencyScore; |
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts around lines
166 to 183, the condition treats 0 as falsy so a same-day conversion (0
daysSinceLastConversion) is skipped; change the if check to test for
undefined/null explicitly (e.g., use enrollment.daysSinceLastConversion != null)
so 0 is accepted, i.e. replace the truthy check with one that ensures
daysSinceLastConversion is not null/undefined while preserving the
lastConversionAt guard.
| const expanded = results.flatMap((result) => | ||
| result.categories.map((category) => ({ | ||
| programId: result.programId, | ||
| category, | ||
| })), | ||
| ); | ||
|
|
||
| console.log("expanded", JSON.stringify(expanded, null, 2)); | ||
|
|
||
| await prisma.programCategory.createMany({ | ||
| data: expanded, | ||
| skipDuplicates: true, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against empty createMany payloads
If every program fails categorization (or the AI returns no valid categories), expanded will be empty and Prisma will throw because createMany requires at least one record. Handle the empty case before calling createMany.
- await prisma.programCategory.createMany({
- data: expanded,
- skipDuplicates: true,
- });
+ if (expanded.length === 0) {
+ console.log("No categories to insert; skipping createMany.");
+ } else {
+ await prisma.programCategory.createMany({
+ data: expanded,
+ skipDuplicates: true,
+ });
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const expanded = results.flatMap((result) => | |
| result.categories.map((category) => ({ | |
| programId: result.programId, | |
| category, | |
| })), | |
| ); | |
| console.log("expanded", JSON.stringify(expanded, null, 2)); | |
| await prisma.programCategory.createMany({ | |
| data: expanded, | |
| skipDuplicates: true, | |
| }); | |
| const expanded = results.flatMap((result) => | |
| result.categories.map((category) => ({ | |
| programId: result.programId, | |
| category, | |
| })), | |
| ); | |
| console.log("expanded", JSON.stringify(expanded, null, 2)); | |
| if (expanded.length === 0) { | |
| console.log("No categories to insert; skipping createMany."); | |
| } else { | |
| await prisma.programCategory.createMany({ | |
| data: expanded, | |
| skipDuplicates: true, | |
| }); | |
| } |
🤖 Prompt for AI Agents
In apps/web/scripts/migrations/backfill-program-categories.ts around lines 214
to 226, the code calls prisma.programCategory.createMany with an array that can
be empty which causes Prisma to throw; add a guard to check if expanded.length
is 0 and if so log a message and skip/return early instead of calling
createMany, otherwise proceed to call createMany with the existing data and
skipDuplicates: true.
| model ProgramSimilarity { | ||
| id String @id @default(cuid()) | ||
| programId String | ||
| similarProgramId String | ||
| similarityScore Float | ||
| categorySimilarityScore Float | ||
| partnerSimilarityScore Float | ||
| performanceSimilarityScore Float | ||
| program Program @relation(fields: [programId], references: [id], onDelete: Cascade) | ||
| @@unique([programId, similarProgramId]) | ||
| @@index([programId, similarityScore]) | ||
| @@index(similarProgramId) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add relation for similarProgramId
Expose a second relation to Program for similarProgramId so you can include: { similarProgram: true } and document deletes. Otherwise it’s just a raw string.
model ProgramSimilarity {
id String @id @default(cuid())
programId String
similarProgramId String
similarityScore Float
categorySimilarityScore Float
partnerSimilarityScore Float
performanceSimilarityScore Float
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
+ similarProgram Program @relation("SimilarProgram", fields: [similarProgramId], references: [id], onDelete: Cascade)
@@unique([programId, similarProgramId])
@@index([programId, similarityScore])
@@index(similarProgramId)
}Note: add the corresponding back relation on Program in program.prisma if you intend to navigate from Program to its similar programs. Based on learnings
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| model ProgramSimilarity { | |
| id String @id @default(cuid()) | |
| programId String | |
| similarProgramId String | |
| similarityScore Float | |
| categorySimilarityScore Float | |
| partnerSimilarityScore Float | |
| performanceSimilarityScore Float | |
| program Program @relation(fields: [programId], references: [id], onDelete: Cascade) | |
| @@unique([programId, similarProgramId]) | |
| @@index([programId, similarityScore]) | |
| @@index(similarProgramId) | |
| } | |
| model ProgramSimilarity { | |
| id String @id @default(cuid()) | |
| programId String | |
| similarProgramId String | |
| similarityScore Float | |
| categorySimilarityScore Float | |
| partnerSimilarityScore Float | |
| performanceSimilarityScore Float | |
| program Program @relation(fields: [programId], references: [id], onDelete: Cascade) | |
| similarProgram Program @relation("SimilarProgram", fields: [similarProgramId], references: [id], onDelete: Cascade) | |
| @@unique([programId, similarProgramId]) | |
| @@index([programId, similarityScore]) | |
| @@index(similarProgramId) | |
| } |
🤖 Prompt for AI Agents
In packages/prisma/schema/network.prisma around lines 47 to 61, the model
ProgramSimilarity only defines a relation for programId but leaves
similarProgramId as a raw string; add a second relation field (e.g.,
similarProgram Program @relation(fields: [similarProgramId], references: [id],
onDelete: Cascade)) to expose the related Program so callers can use include: {
similarProgram: true }, keep the existing program relation (optionally name
relations if you want explicit relation names), and update the Program model in
packages/prisma/schema/program.prisma to add the corresponding back-relation
field (e.g., similarPrograms or incomingSimilarities) so navigation from Program
to its similar programs works and deletes are documented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we still going to remove partner industry interests and sales channels from the UI? Will that be done in this PR?
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx
Outdated
Show resolved
Hide resolved
| similarPrograms: { | ||
| where: { | ||
| similarityScore: { | ||
| gt: 0.3, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this value use a shared SIMILARITY_SCORE_THRESHOLD constant?
| similarPrograms = [], | ||
| }: PartnerRankingParams) { | ||
| const conditions: Prisma.Sql[] = [ | ||
| // Removed discoverableAt requirement to show all partners |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we removing discoverableAt entirely or is this just for testing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx (1)
61-93: Refactor to remove dead code.Since
multiFiltersis now always empty, the following code segments are unreachable and should be removed for maintainability:
- Lines 61-63:
Object.entries(multiFilters)always returns an empty array- Lines 72-74: The condition
Object.keys(multiFilters).includes(key)is always false- Lines 87-93: The same condition and multi-value removal logic never executes
Apply this diff to simplify the logic:
const activeFilters = useMemo(() => { const { country } = searchParamsObj; return [ - ...Object.entries(multiFilters) - .map(([key, value]) => ({ key, value })) - .filter(({ value }) => value.length > 0), - ...(country ? [{ key: "country", value: country }] : []), ]; - }, [searchParamsObj, multiFilters]); + }, [searchParamsObj]); const onSelect = useCallback( - (key: string, value: any) => + (key: string, value: any) => { queryParams({ - set: Object.keys(multiFilters).includes(key) - ? { - [key]: multiFilters[key].concat(value).join(","), - } - : { - [key]: value, - }, + set: { + [key]: value, + }, del: "page", - }), - [queryParams, multiFilters], + }) + }, + [queryParams], ); const onRemove = useCallback( (key: string, value: any) => { - if ( - Object.keys(multiFilters).includes(key) && - !(multiFilters[key].length === 1 && multiFilters[key][0] === value) - ) { - queryParams({ - set: { - [key]: multiFilters[key].filter((id) => id !== value).join(","), - }, - del: "page", - }); - } else { - queryParams({ - del: [key, "page"], - }); - } + queryParams({ + del: [key, "page"], + }); }, - [queryParams, multiFilters], + [queryParams], );You can also remove the now-unused
multiFiltersdefinition on line 55.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx(3 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx (2)
55-55: Behavioral change aligns with PR objectives.The simplification of
multiFiltersto an empty object effectively removes the multi-value filter logic (industryInterests, salesChannels, preferredEarningStructures), which aligns with the categories-focused refactor described in the PR objectives.
105-111: ****The "starred" parameter is fully functional and correctly referenced in the
onRemoveAllcallback. The search results confirm that "starred" is: (1) defined as a query parameter schema, (2) actively managed via a UI toggle switch inpage-client.tsx(lines 207–211), and (3) properly handled by the backend for filtering. The reason "starred" doesn't appear in thefiltersarray is intentional—that array contains only dropdown-style filters, while "starred" is managed separately as a query parameter toggle. When users click "Remove All", both types of filters should be cleared, which is exactly what theonRemoveAllcallback does by deleting both "country" and "starred".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/prisma/schema/program.prisma (2)
185-191: Add index onprogramIdforProgramCategoryqueries.While the composite unique key
[programId, category]provides uniqueness, queries filtering only onprogramIdwill benefit from an explicit index to avoid table scans.Apply this diff to add the index:
model ProgramCategory { programId String category Category program Program @relation(fields: [programId], references: [id], onDelete: Cascade) @@unique([programId, category]) + @@index(programId) }
26-38: Inconsistent enum value naming: standardize casing/separators.The
Categoryenum mixes naming conventions. Most values use underscores (Artificial_Intelligence,E_Commerce), butEcommercebreaks this pattern. Standardize to either all underscores or camelCase for consistency and predictability.Apply this diff to standardize to underscores:
enum Category { Artificial_Intelligence Development Design Productivity Finance Marketing - Ecommerce + E_Commerce Security Education Health Consumer }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
packages/prisma/schema/program.prisma(4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (1)
packages/prisma/schema/program.prisma (1)
114-120: Review comment is incorrect: synchronization mechanism exists and is properly designed.The calculated stats are intentionally denormalized and refreshed by a cron job running every 5 minutes (
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts, line 297). All six fields are recalculated from their source fields (totalClicks, totalConversions, etc.) within a Prisma transaction to ensure atomicity. The stale data window between cron runs is a known, intentional tradeoff for the performance gains of denormalization in the ranking algorithm.Source fields (
totalClicks,totalConversions,totalSaleAmount,totalLeads) are present in the schema and actively maintained via webhooks (invoice-paid, checkout-session-completed, etc.). The design is sound and follows standard caching patterns.Likely an incorrect or invalid review comment.
| similarPrograms ProgramSimilarity[] | ||
| categories ProgramCategory[] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Verify ProgramSimilarity model completeness.
The similarPrograms ProgramSimilarity[] relation is added to the Program model, but the ProgramSimilarity model definition is not visible in this file. Confirm that:
- The
ProgramSimilaritymodel exists and is properly defined (likely in another schema file). - The back-relation from
ProgramSimilaritytoProgramis correctly configured. - The relation aligns with the new category/performance similarity calculators mentioned in the PR objectives.
Run the following script to verify the ProgramSimilarity model:
🏁 Script executed:
#!/bin/bash
# Description: Verify ProgramSimilarity model exists and has correct relation to Program.
# Expected: ProgramSimilarity model definition with fields and relations.
rg -n -A 10 'model ProgramSimilarity' packages/prisma/schema/Length of output: 833
I need to gather more information to fully verify the model completeness. Let me check the complete ProgramSimilarity model definition and confirm the back-relation in the Program model.
---
🏁 Script executed:
```shell
#!/bin/bash
# Retrieve complete ProgramSimilarity model definition
rg -n -A 20 'model ProgramSimilarity' packages/prisma/schema/network.prisma
# Verify back-relation in Program model
echo "---"
echo "Checking Program model's similarPrograms field:"
rg -n -B 2 -A 2 'similarPrograms' packages/prisma/schema/program.prisma
Length of output: 799
Add relation for similarProgramId field in ProgramSimilarity model.
The ProgramSimilarity model is defined in packages/prisma/schema/network.prisma and has the correct back-relation to Program via the program field. However, the model has a similarProgramId field (line 50) that lacks a corresponding relation definition. This field should have a relation to reference the similar program, similar to how programId relates to the primary program via the program field. Add the missing relation to ensure referential integrity and data consistency.
🤖 Prompt for AI Agents
In packages/prisma/schema/program.prisma around lines 86-87, add a proper
relation on the ProgramSimilarity side for similarProgramId: open
packages/prisma/schema/network.prisma where the ProgramSimilarity model is
defined, locate the similarProgramId field and the similarProgram field, and add
a relation attribute on the similarProgram field that points to the Program
model (e.g. relation(fields: [similarProgramId], references: [id]) — optionally
name the relation to match the existing program relation). After updating the
model, run prisma format/generate and create a migration to persist the schema
change.
Summary by CodeRabbit
New Features
Enhancements
Performance
Chores