Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 15, 2025

Summary by CodeRabbit

  • New Features

    • Automatic program similarity scoring (category, partner, performance) with scheduled background processing and batching.
    • Program categories support and similar-programs surfaced for partner discovery and ranking.
  • Enhancements

    • Partner network: new ranking using similar programs; partner list now shows categories; simplified filters and refined starred/ignored logic.
    • Enrollment stats now include last-conversion date, conversion rates, average lifetime value, days-since-last-conversion, and a consistency score.
  • Performance

    • Faster partner queries via country indexing.
  • Chores

    • Backfill script to populate program categories.

@vercel
Copy link
Contributor

vercel bot commented Oct 15, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 24, 2025 3:15pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 15, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Program similarity calculators
apps/web/app/(ee)/api/cron/calculate-program-similarities/*
Add three similarity helpers: category Jaccard, partner Jaccard (raw SQL), and performance cosine similarity; exported for use by cron route.
Program similarity cron route & schedule
apps/web/app/(ee)/api/cron/calculate-program-similarities/route.ts, apps/web/vercel.json
New GET/POST cron route to batch compute weighted similarities, persist ProgramSimilarity rows, and schedule follow-ups via QStash; Vercel cron scheduled every 12 hours.
Partner ranking engine
apps/web/lib/api/network/partner-ranking.ts
New calculatePartnerRanking with filter handling, pagination, optional similar-program weighting, and a raw SQL aggregation that computes finalScore and metadata.
Partner network API and count changes
apps/web/app/(ee)/api/network/partners/route.ts, apps/web/app/(ee)/api/network/partners/count/route.ts
Listing now delegates to calculatePartnerRanking and includes similarPrograms; removed filters (industryInterests, salesChannels, preferredEarningStructures); adjusted starred/status logic and returned partner fields (adds categories, drops removed filters).
Zod schemas
apps/web/lib/zod/schemas/partner-network.ts
Removed industryInterests/salesChannels/preferredEarningStructures from query schema; added categories: string[] to partner output schema.
Dashboard UI & filters
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx
Replace industry/sales UI with categories rendering; remove multi-filters and related icons; narrow onRemoveAll to only country and starred.
Partner stats stream update
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts
Extend enrollment stats with derived metrics (lastConversionAt, conversionRate, averageLifetimeValue, leadConversionRate, daysSinceLastConversion, consistencyScore); format Date values for SQL; adjust processing response behavior.
Category backfill script
apps/web/scripts/migrations/backfill-program-categories.ts
New migration script: scrape program pages, call AI classifier to map pages to 1–3 categories, bulk-insert ProgramCategory rows (skip duplicates), and emit summary.
Prisma: program & network schema
packages/prisma/schema/program.prisma, packages/prisma/schema/network.prisma
Add Category enum and ProgramCategory model; add ProgramSimilarity model; extend Program and ProgramEnrollment with relations/derived fields; expand IndustryInterest enum and (re)include DiscoveredPartner.
Prisma: partner schema
packages/prisma/schema/partner.prisma
Add @@index(country) on Partner.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

Suggested reviewers

  • steven-tey

Poem

"I nibble at the schema, hop through rows of light,
I stitch categories and scores beneath the cron's soft bite.
Partners twirl in rankings, programs hum in rhyme,
A rabbit's code is tiny — yet it hops through space and time. 🐇"

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Network ranking algorithm v2" directly relates to the primary consumer-facing change in this PR—a comprehensive redesign of the partner ranking system. The changeset implements a new calculatePartnerRanking function that incorporates program similarity metrics, along with supporting infrastructure including three new similarity calculation functions, program categorization, database schema expansions, and API route modifications. While the title doesn't explicitly mention the program similarity system that underpins the ranking algorithm, it accurately identifies the main deliverable from a user perspective: an improved (v2) ranking algorithm for the partner network. The title is clear, specific, and avoids vague terminology.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch network-v2

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@devkiran devkiran marked this pull request as ready for review October 15, 2025 16:22
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 array

If 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 schema

UI 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 missing

Even 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 removed

Drop 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 present

If 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 scale

Logic 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 program

Avoid 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

entries is an array; entries.length === 0 is 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” deterministic

Without 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 constant

0.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 categories

Parse 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 similarities

Current 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 threshold

SIMILARITY_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 Array

Define 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 indexes

For this query pattern, ensure indexes exist on:

  • ProgramEnrollment(programId, partnerId, status)
  • DiscoveredPartner(programId, partnerId, starredAt, invitedAt, ignoredAt)
  • ProgramCategory(programId, category)

186-190: Replace Array<any> with a typed interface
Use a defined type for the query result to catch regressions. Joins are safe (unique constraint on ProgramEnrollment(partnerId, programId)) and performant (@@index(programId)).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee7d693 and 7d19db0.

📒 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 permutations

The OR branches for starred=true/false/undefined are subtle. Recommend adding unit tests for the three cases to lock behavior.

Comment on lines +85 to +88
gt: currentProgramId,
notIn: [ACME_PROGRAM_ID],
},
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines +242 to +256
// 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"],
},
},
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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.

Comment on lines +166 to +183
// 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;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines +214 to +226
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,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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.

Comment on lines +47 to +61
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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Copy link
Collaborator

@TWilson023 TWilson023 left a 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?

similarPrograms: {
where: {
similarityScore: {
gt: 0.3,
Copy link
Collaborator

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
Copy link
Collaborator

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?

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 multiFilters is 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 multiFilters definition on line 55.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d19db0 and fb09229.

📒 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 multiFilters to 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 onRemoveAll callback. The search results confirm that "starred" is: (1) defined as a query parameter schema, (2) actively managed via a UI toggle switch in page-client.tsx (lines 207–211), and (3) properly handled by the backend for filtering. The reason "starred" doesn't appear in the filters array 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 the onRemoveAll callback does by deleting both "country" and "starred".

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 on programId for ProgramCategory queries.

While the composite unique key [programId, category] provides uniqueness, queries filtering only on programId will 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 Category enum mixes naming conventions. Most values use underscores (Artificial_Intelligence, E_Commerce), but Ecommerce breaks 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

📥 Commits

Reviewing files that changed from the base of the PR and between fb09229 and 75b5513.

📒 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.

Comment on lines +86 to +87
similarPrograms ProgramSimilarity[]
categories ProgramCategory[]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 ProgramSimilarity model exists and is properly defined (likely in another schema file).
  • The back-relation from ProgramSimilarity to Program is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants