Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Oct 11, 2025

Summary by CodeRabbit

  • New Features

    • Auto-sync partner link stats on clicks, leads, and sales from integrations, imports, and webhooks.
    • Partner activity events and a partner-activity stream for near‑real‑time processing.
    • New scheduled stream-backed job/API to process and apply aggregated partner stats.
  • Refactor

    • Public metrics standardized to total* fields (totalClicks, totalLeads, totalConversions, totalSales, totalSaleAmount) with polyfills for compatibility.
    • Enrollment fetches now return richer related data to simplify callers.
  • Chores

    • Side-effect work moved to parallel syncs for faster, more resilient processing.

@vercel
Copy link
Contributor

vercel bot commented Oct 11, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 12, 2025 2:53am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 11, 2025

Walkthrough

Adds partner-activity streaming and publishers, new ProgramEnrollment total* fields, emits partner activity from click/lead/sale/commission paths (with DB fallbacks), introduces a stream-consumer cron to aggregate/update enrollments, refactors getProgramEnrollmentOrThrow to generic includes, renames UI/schema metrics to total*, and parallelizes workflow + stats-sync side effects.

Changes

Cohort / File(s) Summary of changes
Stream infra & events
apps/web/lib/upstash/redis-streams.ts
Added PartnerActivity stream (PARTNER_ACTIVITY_STREAM_KEY), PartnerActivityEvent type, partnerActivityStream, and publishPartnerActivityEvent; changed publishClickEvent shape; made one workspace constant non-exported.
DB schema: enrollment totals
packages/prisma/schema/program.prisma
Added ProgramEnrollment fields: totalClicks, totalLeads, totalConversions, totalSales, totalSaleAmount (Int, default 0).
Partner stats sync functions
apps/web/lib/api/partners/sync-partner-links-stats.ts, apps/web/lib/api/partners/sync-total-commissions.ts
New syncPartnerLinksStats exported; syncTotalCommissions now tries publishing a PartnerActivity "commission" and falls back to Prisma aggregation/update on publish failure.
Event publication from tracking paths
apps/web/lib/tinybird/record-click.ts, apps/web/app/(ee)/api/track/click/route.ts, apps/web/app/(ee)/api/track/open/route.ts, apps/web/lib/middleware/link.ts
Threaded programId/partnerId into recordClick; when present recordClick publishes partner "click" event via Redis stream with DB fallback that increments totalClicks.
Parallel workflows + stats sync
apps/web/lib/api/conversions/*, apps/web/lib/actions/partners/*, apps/web/lib/integrations/shopify/*, apps/web/app/(ee)/api/stripe/integration/webhook/*, importer files
Replaced sequential executeWorkflows calls with Promise.allSettled to run executeWorkflows in parallel with syncPartnerLinksStats (eventType "lead"/"sale"/"sale" etc.) across handlers and importers.
Importers: propagate partner/program & sync
apps/web/lib/firstpromoter/*, apps/web/lib/partnerstack/*, apps/web/lib/rewardful/*, apps/web/lib/tolt/*
Extended link selections to include partnerId/programId; conditionally call syncPartnerLinksStats after lead/sale processing; several Promise.all → allSettled changes.
Partners API refactor & schema renames
apps/web/lib/api/partners/get-partners.ts, apps/web/lib/api/partners/get-partner-for-program.ts, apps/web/app/(ee)/api/partners/route.ts, apps/web/lib/zod/schemas/partners.ts
Replaced raw SQL with Prisma queries, simplified mappings, added include: { partner, links } usage; renamed public metric fields to total* and polyfilled deprecated fields in API response; added timing instrumentation in route.
Cron: stream consumer + scheduling
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts, apps/web/vercel.json
New cron route to consume PartnerActivity stream, aggregate by programId:partnerId, query link & commission aggregates, batch-update ProgramEnrollment; cron schedule added */5 * * * *.
UI and scripts to total fields*
apps/web/app/.../partners/partners-table.tsx, apps/web/app/.../[partnerId]/partner-stats.tsx, apps/web/ui/partners/partner-info-stats.tsx, apps/web/ui/links/link-builder/link-partner-details.tsx, apps/web/scripts/partners/*, apps/web/scripts/cal/backfill-referral-links.ts
Replaced UI and script usages of clicks/leads/conversions/sales/saleAmount with totalClicks/totalLeads/totalConversions/totalSales/totalSaleAmount, updated formatting, sort keys, column keys and storage key.
Commissions / webhook orchestration
apps/web/lib/partners/create-partner-commission.ts, apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts, importer commission files
Adjusted local totalCommissions computation, moved syncTotalCommissions into async/background (Promise.allSettled) or publish-first flow; removed timing calls around sync loop and updated log messages.
getProgramEnrollmentOrThrow refactor
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
Replaced many boolean include flags with generic include: T extends Prisma.ProgramEnrollmentInclude; returns Prisma.ProgramEnrollmentGetPayload<{ include: T }> and uses finalInclude for Prisma queries; simplified not-found handling.
Widespread callsite include updates
Multiple under apps/web/app/(ee)/api/..., apps/web/lib/actions/partners/*, scripts
Many call sites updated to pass include: { ... } or include: {} to match the new getProgramEnrollmentOrThrow signature.
Tests/schema updates & CSV tweak
apps/web/lib/zod/schemas/partners.ts, apps/web/tests/partners/resource.ts, apps/web/app/(ee)/api/partners/export/route.ts
Zod schema updated to expose total* metrics and update sort defaults; bannedAt added to test schema; CSV export now conditionally ISO-converts Date values.
Groups & UI column renames
apps/web/app/.../groups/groups-table.tsx, apps/web/lib/zod/schemas/groups.ts, apps/web/ui/modals/delete-group-modal.tsx
Renamed group field partnerstotalPartners, removed partnersCount, updated sort options and UI hook parameter types.
New scripts: aggregate / seeding
apps/web/scripts/partners/aggregate-stats.ts, apps/web/scripts/partners/aggregate-stats-seeding.ts
Added PoC/seed scripts to aggregate partner activity/events and publish PartnerActivityEvent batches.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User as Visitor
  participant MW as Link Middleware
  participant Route as Track Route
  participant TB as recordClick
  participant Stream as PartnerActivityStream
  participant DB as Prisma

  User->>MW: open partner link
  MW->>Route: forward request (includes programId, partnerId)
  Route->>TB: recordClick(..., programId, partnerId)
  alt programId & partnerId present
    TB->>Stream: publishPartnerActivityEvent(type="click", programId, partnerId, timestamp)
    note right of Stream #DFF3E3: publish may fail
    Stream--x TB: publish error
    TB->>DB: fallback: increment ProgramEnrollment.totalClicks
  end
  TB-->>Route: click persisted
  Route-->>User: redirect/OK
Loading
sequenceDiagram
  autonumber
  participant Source as Lead/Sale Source
  participant App as Service
  participant WF as executeWorkflows
  participant Sync as syncPartnerLinksStats

  Source->>App: record lead/sale
  par Parallel side-effects
    App->>WF: trigger leadRecorded/saleRecorded
    App->>Sync: syncPartnerLinksStats({ partnerId, programId, eventType })
  and
    note over WF,Sync #FFF7E6: run via Promise.allSettled (non-blocking)
  end
  App-->>Source: Continue processing
Loading
sequenceDiagram
  autonumber
  participant Cron as /api/cron/streams/update-partner-stats
  participant Stream as PartnerActivityStream
  participant DB as Prisma

  Cron->>Stream: XREAD batch
  Stream-->>Cron: events
  Cron->>Cron: group by programId:partnerId (linkStats, commissionStats)
  par DB aggregates
    Cron->>DB: groupBy links -> sums
    Cron->>DB: groupBy commissions -> sums
  end
  Cron->>DB: batch update ProgramEnrollment totals (sub-batches)
  Cron-->>Caller: JSON summary (updates/errors)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

A rabbit taps the stream at night,
Counts clicks, leads, and sales in flight.
Totals hop to rows anew,
Cron batches hum and syncs them too.
Hop, publish, settle — stats in sight. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.24% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Sync partner link stats to ProgramEnrollment table” accurately and concisely describes the primary change in this PR, which introduces a syncPartnerLinksStats function and integrates partner link statistics synchronization into various workflows and APIs to update the ProgramEnrollment table. It clearly highlights the main functionality without extraneous detail.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch programenrollment-stats

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.

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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1)

136-138: Remove unused variable.

The clickEnrollments variable is defined but never used in the subsequent code.

Apply this diff to remove the unused code:

-    // getting a list of click reward program enrollments
-    const clickEnrollments = clickRewardsWithEnrollments.flatMap(
-      ({ clickEnrollments }) => clickEnrollments,
-    );
-
     for (const { partnerId, programId } of commissionsToCreate) {
apps/web/lib/partnerstack/import-customers.ts (1)

271-305: Use Promise.allSettled for consistency and fault tolerance.

This file uses Promise.all (line 271) while similar import files (firstpromoter, tolt, rewardful) use Promise.allSettled. Using Promise.all means that if syncPartnerLinksStats fails, the entire customer creation is rolled back, including the recorded lead and link stats update.

Promise.allSettled is more fault-tolerant—it allows the core operation (customer creation, lead recording) to succeed even if the stats sync fails, which aligns with the eventual consistency model.

Apply this diff for consistency:

-    await Promise.all([
+    await Promise.allSettled([
       recordLeadWithTimestamp({
🧹 Nitpick comments (5)
apps/web/lib/zod/schemas/partners.ts (1)

121-132: Remove commented-out code for cleanliness.

The commented-out old field names in the sortBy enum should be removed rather than left as comments. They add clutter and could confuse future maintainers about which fields are valid.

Apply this diff to clean up the commented code:

     sortBy: z
       .enum([
         "createdAt",
-        // "clicks",
-        // "leads",
-        // "conversions",
-        // "sales",
-        // "saleAmount",
         "totalClicks",
         "totalLeads",
         "totalConversions",
         "totalSales",
         "totalSaleAmount",
         "totalCommissions",
-        // "netRevenue",
       ])
apps/web/lib/firstpromoter/import-customers.ts (1)

291-300: Clarify the purpose of the conditional logic.

The comment "partner links should always have a partnerId and programId, but we're doing this to make TS happy" is misleading. The conditional check isn't just for TypeScript—it's a runtime safety guard against links that might not have these fields set (e.g., non-partner links or data migration edge cases).

Apply this diff to clarify the comment:

-      // partner links should always have a partnerId and programId, but we're doing this to make TS happy
+      // Sync stats only for partner links (links with both partnerId and programId set)
       ...(link.partnerId && link.programId
apps/web/lib/tolt/import-customers.ts (1)

268-277: Clarify the purpose of the conditional logic.

The comment "partner links should always have a partnerId and programId, but we're doing this to make TS happy" is misleading. The conditional check serves as a runtime safety guard against links that might not have these fields set (e.g., non-partner links or data migration edge cases), not just for TypeScript.

Apply this diff to clarify the comment:

-      // partner links should always have a partnerId and programId, but we're doing this to make TS happy
+      // Sync stats only for partner links (links with both partnerId and programId set)
       ...(link.partnerId && link.programId
apps/web/lib/partnerstack/import-customers.ts (1)

295-304: Clarify the purpose of the conditional logic.

The comment "partner links should always have a partnerId and programId, but we're doing this to make TS happy" is misleading. The conditional check serves as a runtime safety guard against links that might not have these fields set (e.g., non-partner links or data migration edge cases), not just for TypeScript.

Apply this diff to clarify the comment:

-      // partner links should always have a partnerId and programId, but we're doing this to make TS happy
+      // Sync stats only for partner links (links with both partnerId and programId set)
       ...(link.partnerId && link.programId
apps/web/lib/upstash/redis-streams.ts (1)

70-73: Consider making the error message generic.

The error message "Failed to read workspace usage updates from stream:" is specific to workspace usage, but RedisStream is a generic class that can be used for any stream (e.g., partner activity). Consider making the message generic or parameterizing it.

Apply this diff to make the error message generic:

     } catch (error) {
       console.error(
-        "Failed to read workspace usage updates from stream:",
+        `Failed to read from stream ${this.streamKey}:`,
         error,
       );
       throw error;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 20fb776 and f66e25a.

📒 Files selected for processing (27)
  • apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/usage/update-partner-stats/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/route.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (5 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (2 hunks)
  • apps/web/lib/api/conversions/track-lead.ts (2 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (3 hunks)
  • apps/web/lib/api/partners/get-partners-new.ts (1 hunks)
  • apps/web/lib/api/partners/sync-partner-links-stats.ts (1 hunks)
  • apps/web/lib/api/partners/sync-total-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-customers.ts (4 hunks)
  • apps/web/lib/integrations/shopify/create-sale.ts (2 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (2 hunks)
  • apps/web/lib/partnerstack/import-commissions.ts (2 hunks)
  • apps/web/lib/partnerstack/import-customers.ts (4 hunks)
  • apps/web/lib/rewardful/import-commissions.ts (2 hunks)
  • apps/web/lib/rewardful/import-customers.ts (3 hunks)
  • apps/web/lib/tolt/import-commissions.ts (2 hunks)
  • apps/web/lib/tolt/import-customers.ts (5 hunks)
  • apps/web/lib/upstash/redis-streams.ts (1 hunks)
  • apps/web/lib/zod/schemas/partners.ts (2 hunks)
  • apps/web/scripts/partners/aggregate-stats.ts (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/rewardful/import-customers.ts
🧬 Code graph analysis (24)
apps/web/lib/api/conversions/track-lead.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/rewardful/import-commissions.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • publishPartnerActivityEvent (178-188)
apps/web/lib/api/partners/sync-total-commissions.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • publishPartnerActivityEvent (178-188)
apps/web/lib/firstpromoter/import-customers.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/tolt/import-customers.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/integrations/shopify/create-sale.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/rewardful/import-customers.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/scripts/partners/aggregate-stats.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • PartnerActivityEvent (171-176)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/api/partners/get-partners-new.ts (1)
apps/web/lib/zod/schemas/partners.ts (1)
  • getPartnersQuerySchemaExtended (174-182)
apps/web/lib/partnerstack/import-customers.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/(ee)/api/cron/usage/update-partner-stats/route.ts (3)
apps/web/lib/upstash/redis-streams.ts (3)
  • partnerActivityStream (168-170)
  • PartnerActivityEvent (171-176)
  • workspaceUsageStream (143-145)
apps/web/lib/cron/verify-vercel.ts (1)
  • verifyVercelSignature (3-20)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-178)
apps/web/lib/api/conversions/track-sale.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/upstash/redis-streams.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
apps/web/app/(ee)/api/partners/route.ts (2)
apps/web/lib/api/partners/get-partners-new.ts (1)
  • getPartnersNew (9-72)
apps/web/lib/zod/schemas/partners.ts (1)
  • EnrolledPartnerSchema (353-426)
apps/web/lib/partners/create-partner-commission.ts (1)
apps/web/lib/api/partners/sync-total-commissions.ts (1)
  • syncTotalCommissions (5-50)
apps/web/lib/tolt/import-commissions.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
apps/web/lib/firstpromoter/import-commissions.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/partnerstack/import-commissions.ts (1)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
⏰ 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 (26)
apps/web/scripts/partners/aggregate-stats.ts (2)

1-77: LGTM! Well-structured PoC script.

The script correctly segregates partner activity events into linkStats and commissionStats categories, preparing them for separate aggregation queries. The use of Set to deduplicate program-partner pairs is appropriate.


78-169: LGTM! Aggregation logic is sound.

The script correctly:

  • Aggregates link stats (clicks, leads, conversions, sales, saleAmount) using groupBy.
  • Aggregates commission stats (earnings) with appropriate filters (non-zero earnings and valid statuses).
  • Merges both sets of stats into a unified map before output.
apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

5-5: LGTM! Stats sync correctly integrated.

The syncPartnerLinksStats call is appropriately placed within the sale processing flow and parallelized with workflow execution using Promise.allSettled, ensuring partner link statistics are updated without blocking the primary flow.

Also applies to: 456-460

apps/web/lib/tolt/import-commissions.ts (1)

10-10: LGTM! Stats sync integrated into import flow.

The syncPartnerLinksStats call is correctly placed in the commission creation flow, ensuring partner stats are synchronized during the Tolt import process. Running in parallel with other database operations is appropriate.

Also applies to: 380-384

apps/web/lib/rewardful/import-commissions.ts (1)

10-10: LGTM! Stats sync integrated into Rewardful import.

The syncPartnerLinksStats call is correctly integrated into the Rewardful commission creation flow, maintaining consistency with other import modules.

Also applies to: 373-377

packages/prisma/schema/program.prisma (1)

99-107: LGTM! Well-designed schema extension.

The new total* fields are properly structured with:

  • Clear naming convention using the "total" prefix
  • Appropriate default values (0) to avoid null handling
  • Logical grouping under a "partner stats" comment section
  • Consistency with aggregation logic seen in other parts of the PR
apps/web/lib/partnerstack/import-commissions.ts (1)

8-8: LGTM! Stats sync integrated into PartnerStack import.

The syncPartnerLinksStats call is correctly integrated into the PartnerStack commission creation flow, maintaining consistency with other import modules.

Also applies to: 366-370

apps/web/lib/firstpromoter/import-commissions.ts (1)

10-10: LGTM! Stats sync integrated into FirstPromoter import.

The syncPartnerLinksStats call is correctly integrated into the FirstPromoter commission creation flow, maintaining consistency with other import modules. The use of Promise.allSettled is appropriate for handling parallel side-effects.

Also applies to: 373-377

apps/web/lib/integrations/shopify/create-sale.ts (1)

157-175: Consider logging failures from Promise.allSettled.

Promise.allSettled silently swallows rejections. If either executeWorkflows or syncPartnerLinksStats fails, you won't see any error logs or alerts. This can mask issues in production.

Consider adding error logging:

     waitUntil(
-      Promise.allSettled([
+      Promise.allSettled([
         executeWorkflows({
           trigger: WorkflowTrigger.saleRecorded,
           context: {
             programId: link.programId,
             partnerId: link.partnerId,
             current: {
               saleAmount: saleData.amount,
               conversions: firstConversionFlag ? 1 : 0,
             },
           },
         }),
         syncPartnerLinksStats({
           partnerId: link.partnerId,
           programId: link.programId,
           eventType: "sale",
         }),
-      ]),
+      ]).then((results) => {
+        results.forEach((result, index) => {
+          if (result.status === "rejected") {
+            console.error(
+              `Failed to ${index === 0 ? "execute workflows" : "sync partner stats"}:`,
+              result.reason
+            );
+          }
+        });
+      }),
     );
apps/web/app/(ee)/api/partners/route.ts (2)

21-26: LGTM! Performance monitoring added.

The console.time/timeEnd calls provide useful insights for optimizing this endpoint.


28-50: LGTM! Backward compatibility handled correctly.

The approach ensures existing API consumers continue to work while transitioning to the new total* field naming convention. The use of Zod schema extension with defaults provides type safety.

apps/web/lib/rewardful/import-customers.ts (1)

224-253: Consider logging failures from Promise.allSettled.

Similar to other files in this PR, Promise.allSettled will silently ignore failures. Since this is in an import flow, silent failures could lead to inconsistent partner statistics without visibility.

Add error logging to catch and report failures:

   await Promise.allSettled([
     recordLeadWithTimestamp({
       ...clickEvent,
       event_id: nanoid(16),
       event_name: "Sign up",
       customer_id: customerId,
       timestamp: new Date(referral.became_lead_at).toISOString(),
     }),

     prisma.link.update({
       where: { id: link.id },
       data: {
         leads: { increment: 1 },
         lastLeadAt: updateLinkStatsForImporter({
           currentTimestamp: link.lastLeadAt,
           newTimestamp: new Date(referral.became_lead_at),
         }),
       },
     }),
     // partner links should always have a partnerId and programId, but we're doing this to make TS happy
     ...(link.partnerId && link.programId
       ? [
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
         ]
       : []),
-  ]);
+  ]).then((results) => {
+    results.forEach((result, index) => {
+      if (result.status === "rejected") {
+        console.error(
+          `[importCustomers] Operation ${index} failed:`,
+          result.reason
+        );
+      }
+    });
+  });
apps/web/lib/actions/partners/create-manual-commission.ts (1)

521-542: Consider logging failures from Promise.allSettled.

The parallel execution is appropriate, but Promise.allSettled silently handles rejections. In the context of manual commission creation, failures in workflow execution or stats syncing should be visible to help debug issues.

Add error logging:

         if (["lead", "sale"].includes(commissionType)) {
-          await Promise.allSettled([
+          const results = await Promise.allSettled([
             executeWorkflows({
               trigger:
                 commissionType === "lead"
                   ? WorkflowTrigger.leadRecorded
                   : WorkflowTrigger.saleRecorded,
               context: {
                 programId,
                 partnerId,
                 current: {
                   leads: commissionType === "lead" ? 1 : 0,
                   saleAmount: saleAmount ?? totalSaleAmount,
                   conversions: firstConversionFlag ? 1 : 0,
                 },
               },
             }),
             syncPartnerLinksStats({
               partnerId,
               programId,
               eventType: commissionType as "lead" | "sale",
             }),
           ]);
+
+          results.forEach((result, index) => {
+            if (result.status === "rejected") {
+              console.error(
+                `[createManualCommission] Failed to ${index === 0 ? "execute workflows" : "sync partner stats"}:`,
+                result.reason
+              );
+            }
+          });
         }
apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (1)

139-157: Consider logging failures from Promise.allSettled.

The parallel execution pattern is consistent with the PR's approach, but Promise.allSettled won't log failures. This is particularly important for webhook processing where silent failures can be hard to debug.

Add error logging:

   waitUntil(
-    Promise.allSettled([
+    Promise.allSettled([
       sendWorkspaceWebhook({
         trigger: "lead.created",
         workspace,
         data: transformLeadEventData({
           ...clickData,
           eventName,
           link: linkUpdated,
           customer,
           partner: webhookPartner,
           metadata: null,
         }),
       }),

       ...(link.programId && link.partnerId
         ? [
             executeWorkflows({
               trigger: WorkflowTrigger.leadRecorded,
               context: {
                 programId: link.programId,
                 partnerId: link.partnerId,
                 current: {
                   leads: 1,
                 },
               },
             }),
             syncPartnerLinksStats({
               partnerId: link.partnerId,
               programId: link.programId,
               eventType: "lead",
             }),
           ]
         : []),
-    ]),
+    ]).then((results) => {
+      results.forEach((result, index) => {
+        if (result.status === "rejected") {
+          console.error(
+            `[createNewCustomer] Operation ${index} failed:`,
+            result.reason
+          );
+        }
+      });
+    }),
   );
apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

235-252: Consider logging failures from Promise.allSettled.

Invoice payment is a critical flow. Silent failures in workflow execution or stats syncing could lead to incorrect partner metrics and missed commissions.

Add error logging:

     waitUntil(
-      Promise.allSettled([
+      Promise.allSettled([
         executeWorkflows({
           trigger: WorkflowTrigger.saleRecorded,
           context: {
             programId: link.programId,
             partnerId: link.partnerId,
             current: {
               saleAmount: saleData.amount,
               conversions: firstConversionFlag ? 1 : 0,
             },
           },
         }),
         syncPartnerLinksStats({
           partnerId: link.partnerId,
           programId: link.programId,
           eventType: "sale",
         }),
-      ]),
+      ]).then((results) => {
+        results.forEach((result, index) => {
+          if (result.status === "rejected") {
+            console.error(
+              `[invoicePaid] Failed to ${index === 0 ? "execute workflows" : "sync partner stats"} for invoice ${invoiceId}:`,
+              result.reason
+            );
+          }
+        });
+      }),
     );
apps/web/lib/api/conversions/track-sale.ts (2)

371-387: Consider logging failures from Promise.allSettled.

In the lead tracking flow, silent failures could result in missed workflow executions or inconsistent partner stats.

Add error logging:

-        await Promise.allSettled([
+        const results = await Promise.allSettled([
           executeWorkflows({
             trigger: WorkflowTrigger.leadRecorded,
             context: {
               programId: link.programId,
               partnerId: link.partnerId,
               current: {
                 leads: 1,
               },
             },
           }),
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
         ]);
+
+        results.forEach((result, index) => {
+          if (result.status === "rejected") {
+            console.error(
+              `[trackLead] Failed to ${index === 0 ? "execute workflows" : "sync partner stats"}:`,
+              result.reason
+            );
+          }
+        });

551-568: Consider logging failures from Promise.allSettled.

In the sale tracking flow, failures in workflow execution or stats syncing should be logged for debugging and monitoring.

Add error logging:

-        await Promise.allSettled([
+        const results = await Promise.allSettled([
           executeWorkflows({
             trigger: WorkflowTrigger.saleRecorded,
             context: {
               programId: link.programId,
               partnerId: link.partnerId,
               current: {
                 saleAmount: saleData.amount,
                 conversions: firstConversionFlag ? 1 : 0,
               },
             },
           }),
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "sale",
           }),
         ]);
+
+        results.forEach((result, index) => {
+          if (result.status === "rejected") {
+            console.error(
+              `[trackSale] Failed to ${index === 0 ? "execute workflows" : "sync partner stats"}:`,
+              result.reason
+            );
+          }
+        });
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (3)

72-89: LGTM! Field naming updated consistently.

The update from clicks/leads/conversions/sales/saleAmount to totalClicks/totalLeads/totalConversions/totalSales/totalSaleAmount aligns with the backend schema changes and improves clarity.


227-259: LGTM! Accessor functions updated correctly.

All column accessors properly reference the new total* field names. The formatting functions (nFormatter, currencyFormatter) are applied appropriately.


307-313: netRevenue sorting disabled — verify backend support.

The comment indicates netRevenue sorting will be re-enabled once backend support is available. Ensure there's a tracking issue for this.

Can you verify:

  1. Is there a tracking issue for re-enabling netRevenue sorting?
  2. What backend change is needed to support this?
apps/web/lib/api/partners/sync-total-commissions.ts (1)

12-49: Consider rate limiting and circuit breaker for the fallback path.

The event-driven approach with fallback is well-designed, but consider these edge cases:

  1. Thundering herd: If Redis/event publish is temporarily unavailable, all concurrent commission creation requests will fall back to direct DB aggregation. This could overwhelm the database with identical queries.

  2. Event ordering: There's no guarantee that events are processed in order. A commission created at time T1 might be processed after a commission created at T2, causing temporary inconsistencies.

  3. Eventual consistency window: The webhook receives locally computed totals, but the actual DB value might differ until the event is processed.

Consider implementing:

  • Circuit breaker pattern to fail fast when Redis is down
  • Rate limiting on the fallback aggregation path
  • Idempotency keys on the event stream to handle duplicates
  • Documentation of the eventual consistency model for consumers
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)

5-62: LGTM! Consistent event-driven pattern with graceful fallback.

The implementation follows the same event-first, fallback-to-DB pattern as syncTotalCommissions, which promotes consistency across the codebase. The use of undefined instead of null for missing aggregation values (lines 43-47) allows Prisma to handle the update correctly.

Note: The same concerns raised for syncTotalCommissions regarding thundering herd, circuit breakers, and rate limiting apply here as well.

apps/web/lib/api/partners/get-partners-new.ts (1)

69-70: Document behavior for negative netRevenue. The expression programEnrollment.totalSaleAmount - programEnrollment.totalCommissions can yield negative values (e.g., refunds or clawbacks). Clarify in the API docs whether negative netRevenue is expected or should be clamped to zero.

apps/web/lib/zod/schemas/partners.ts (1)

47-58: Do not rename column IDs: exportPartnerColumns uses clicks, leads, conversions, sales, and saleAmount to match the response object properties returned by getPartners (which maps the DB aliases totalClicks, totalLeads, etc. back to those keys).

Likely an incorrect or invalid review comment.

apps/web/lib/upstash/redis-streams.ts (2)

141-164: LGTM on workspace usage stream refactor.

Making WORKSPACE_USAGE_UPDATES_STREAM_KEY a local constant and updating publishClickEvent to use the object payload pattern with redis.xadd are good improvements. The error handling is consistent and appropriate.


166-177: LGTM on partner activity stream infrastructure.

The new partner activity stream infrastructure is well-designed:

  • Clear separation of concerns with PARTNER_ACTIVITY_STREAM_KEY constant and partnerActivityStream export
  • PartnerActivityEvent interface is properly typed with appropriate event types
  • Consistent with the existing workspace usage stream pattern

Comment on lines 140 to 149
for (const { partnerId, programId } of commissionsToCreate) {
// Sync total commissions for each partner that we created commissions for
await syncTotalCommissions({
partnerId,
programId,
});
}
console.timeEnd("syncTotalCommissions");
console.log(
`Updated total commissions count for ${commissionsToCreate.length} partners`,
`Synced total commissions count for ${commissionsToCreate.length} partners`,
);
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

Deduplicate partner/program pairs and add error handling.

The current implementation has several issues:

  1. Duplicate syncs: If a partner has multiple links with commissions, syncTotalCommissions is called multiple times for the same partnerId/programId pair, which is wasteful and may cause race conditions.
  2. Misleading log: The log message reports commissionsToCreate.length as "partners" but this is the count of commissions, not unique partners.
  3. No error handling: If syncTotalCommissions fails for one partner, the entire cron job fails and subsequent partners won't be synced.

Apply this diff to deduplicate, parallelize, and add error handling:

-    for (const { partnerId, programId } of commissionsToCreate) {
-      // Sync total commissions for each partner that we created commissions for
-      await syncTotalCommissions({
-        partnerId,
-        programId,
-      });
-    }
-    console.log(
-      `Synced total commissions count for ${commissionsToCreate.length} partners`,
-    );
+    // Deduplicate partner/program pairs
+    const uniquePairs = Array.from(
+      new Map(
+        commissionsToCreate.map(({ partnerId, programId }) => [
+          `${partnerId}-${programId}`,
+          { partnerId, programId },
+        ]),
+      ).values(),
+    );
+
+    // Sync total commissions in parallel with error handling
+    const results = await Promise.allSettled(
+      uniquePairs.map(({ partnerId, programId }) =>
+        syncTotalCommissions({ partnerId, programId }),
+      ),
+    );
+
+    const failures = results.filter((r) => r.status === "rejected");
+    if (failures.length > 0) {
+      await log({
+        message: `Failed to sync commissions for ${failures.length}/${uniquePairs.length} partners`,
+        type: "errors",
+        mention: true,
+      });
+    }
+
+    console.log(
+      `Synced total commissions count for ${uniquePairs.length} partners`,
+    );
📝 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
for (const { partnerId, programId } of commissionsToCreate) {
// Sync total commissions for each partner that we created commissions for
await syncTotalCommissions({
partnerId,
programId,
});
}
console.timeEnd("syncTotalCommissions");
console.log(
`Updated total commissions count for ${commissionsToCreate.length} partners`,
`Synced total commissions count for ${commissionsToCreate.length} partners`,
);
// Deduplicate partner/program pairs
const uniquePairs = Array.from(
new Map(
commissionsToCreate.map(({ partnerId, programId }) => [
`${partnerId}-${programId}`,
{ partnerId, programId },
]),
).values(),
);
// Sync total commissions in parallel with error handling
const results = await Promise.allSettled(
uniquePairs.map(({ partnerId, programId }) =>
syncTotalCommissions({ partnerId, programId }),
),
);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
await log({
message: `Failed to sync commissions for ${failures.length}/${uniquePairs.length} partners`,
type: "errors",
mention: true,
});
}
console.log(
`Synced total commissions count for ${uniquePairs.length} partners`,
);
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts around lines 140 to 149,
the loop calls syncTotalCommissions for every commission row causing duplicate
partnerId/programId calls, uses a misleading log count, and has no per-partner
error handling; fix by first deduplicating into a Set or map of unique
partnerId|programId pairs, then run syncs in parallel using Promise.allSettled
(or Promise.all with per-item try/catch) so failures for one pair don't abort
others, log the number of unique pairs synced (and number of failures) instead
of commissionsToCreate.length, and ensure each failure is logged with context
(partnerId, programId, and error).

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 (3)
apps/web/lib/middleware/link.ts (1)

282-284: DRY the partner/program args across branches

The repeated programId/partnerId spreads are correct but duplicated. Consider extracting once:

  • const partnerCtx = { programId: cachedLink.programId, partnerId: cachedLink.partnerId };
  • recordClick({ ..., ...partnerCtx })

Keeps all branches consistent and reduces future churn.

Also applies to: 335-337, 375-377, 417-419, 488-490, 524-526, 560-562

apps/web/lib/tinybird/record-click.ts (2)

217-231: Reuse clickData.timestamp for event and verify fallback behavior

  • Use the same timestamp as the rest of the pipeline for consistency/correlation.
-            timestamp: new Date().toISOString(),
+            timestamp: clickData.timestamp,
  • If ProgramEnrollment may not exist yet for (programId, partnerId), the fallback UPDATE won’t increment anything. Is enrollment creation guaranteed before any click? If not, consider an upsert or at least logging when no rows are affected to avoid silent drops.

251-256: Label all Promise.allSettled operations for clearer logs

Add a label for the final Tinybird events call to avoid “Operation 5” in logs.

               const operations = [
                 "Tinybird click event ingestion",
                 "recordClickCache set",
                 "Link clicks increment",
                 "Workspace usage increment",
                 "Program enrollment totalClicks increment",
+                "Tinybird click event ingestion (NEW)",
               ];
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f66e25a and 2b18a68.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/track/click/route.ts (1 hunks)
  • apps/web/app/(ee)/api/track/open/route.ts (1 hunks)
  • apps/web/lib/middleware/link.ts (7 hunks)
  • apps/web/lib/tinybird/record-click.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/tinybird/record-click.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • publishPartnerActivityEvent (178-188)
⏰ 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/(ee)/api/track/open/route.ts (1)

117-118: Partner/program context propagation looks good

Passing programId/partnerId aligns with recordClick’s optional params and event path.

Confirm RedisLinkProps/formatRedisLink always include programId/partnerId for deep links so events fire when expected.

apps/web/app/(ee)/api/track/click/route.ts (1)

139-141: Correctly threads partner/program into click recording

Matches the new recordClick signature and integrates with existing dedupe/caching.

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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2b18a68 and 413c063.

📒 Files selected for processing (2)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx (1 hunks)
  • apps/web/ui/partners/partner-info-stats.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx
  • apps/web/ui/partners/partner-info-stats.tsx
🧬 Code graph analysis (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx (1)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
apps/web/ui/partners/partner-info-stats.tsx (1)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
⏰ 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/partners/[partnerId]/partner-stats.tsx (1)

11-37: LGTM! Field migration is clean and consistent.

The migration from legacy field names (clicks, leads, conversions, saleAmount) to total-prefixed fields (totalClicks, totalLeads, totalConversions, totalSaleAmount) is correctly implemented. The NaN checks and formatting logic remain appropriate, and the null coalescing operator on line 34 provides explicit safety for totalSaleAmount.

apps/web/ui/partners/partner-info-stats.tsx (1)

21-35: Field migration looks good.

The migration from legacy field names (clicks, leads, conversions) to total-prefixed fields (totalClicks, totalLeads, totalConversions) is correctly implemented with appropriate NaN checks and formatting.

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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 413c063 and 41fe9ea.

📒 Files selected for processing (1)
  • apps/web/scripts/cal/backfill-referral-links.ts (1 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

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: 4

♻️ Duplicate comments (1)
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1)

158-164: Critical: malformed & unsafe UPDATE construction

UPDATE ProgramEnrollment WHERE … SET … is invalid SQL (SET must precede WHERE). Interpolating raw values also blows up for string IDs, writes undefined, and opens SQL injection. Build the assignment list from defined stats and pass everything as bound params.

-              await conn.execute(
-                `UPDATE ProgramEnrollment WHERE programId = ${programId} AND partnerId = ${partnerId} SET ${Object.entries(
-                  stats,
-                )
-                  .map(([key, value]) => `${key} = ${value}`)
-                  .join(", ")}`,
-              );
+              const definedStats = Object.entries(stats).filter(
+                ([, value]) => value !== undefined,
+              );
+              if (!definedStats.length) {
+                return;
+              }
+
+              const assignments = definedStats
+                .map(([key]) => `${key} = ?`)
+                .join(", ");
+              const values = definedStats.map(([, value]) => value);
+
+              await conn.execute(
+                `UPDATE ProgramEnrollment SET ${assignments} WHERE programId = ? AND partnerId = ?`,
+                [...values, programId, partnerId],
+              );
🧹 Nitpick comments (4)
apps/web/scripts/partners/aggregate-stats.ts (4)

66-76: Consider optimizing the Set operations.

The reduce creates a new Set from the array on every iteration, which is inefficient. Consider initializing with Sets and converting to arrays once at the end.

Apply this diff to optimize:

-  const programEnrollmentActivity = entries.reduce(
-    (acc, entry) => {
-      const { programId, partnerId, eventType } = entry.data;
-      const key = eventType === "commission" ? "commissionStats" : "linkStats";
-      const eventTypesSet = new Set(acc[key]);
-      eventTypesSet.add(`${programId}:${partnerId}`);
-      acc[key] = Array.from(eventTypesSet);
-      return acc;
-    },
-    { linkStats: [], commissionStats: [] } as Record<string, string[]>,
-  );
+  const programEnrollmentActivity = entries.reduce(
+    (acc, entry) => {
+      const { programId, partnerId, eventType } = entry.data;
+      const key = eventType === "commission" ? "commissionStats" : "linkStats";
+      acc[key].add(`${programId}:${partnerId}`);
+      return acc;
+    },
+    { linkStats: new Set<string>(), commissionStats: new Set<string>() },
+  );
+
+  const linkStatsArray = Array.from(programEnrollmentActivity.linkStats);
+  const commissionStatsArray = Array.from(programEnrollmentActivity.commissionStats);

Then update subsequent references to use linkStatsArray and commissionStatsArray.


91-96: Optimize the extraction of programIds and partnerIds.

The code splits each composite key twice—once for programIds and again for partnerIds. This is inefficient and duplicates work.

Apply this diff to extract both values in a single pass:

-    const programIds = programEnrollmentActivity.linkStats.map(
-      (p) => p.split(":")[0],
-    );
-    const partnerIds = programEnrollmentActivity.linkStats.map(
-      (p) => p.split(":")[1],
-    );
+    const { programIds, partnerIds } = programEnrollmentActivity.linkStats.reduce(
+      (acc, key) => {
+        const [programId, partnerId] = key.split(":");
+        acc.programIds.push(programId);
+        acc.partnerIds.push(partnerId);
+        return acc;
+      },
+      { programIds: [] as string[], partnerIds: [] as string[] }
+    );

118-122: Remove redundant ?? undefined coalescing.

Using ?? undefined is redundant since the object properties are already typed as optional. If _sum.clicks is null, it will be assigned as null, which is acceptable for optional fields. This pattern appears throughout the mapping.

Apply this diff:

       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-        totalClicks: p._sum.clicks ?? undefined,
-        totalLeads: p._sum.leads ?? undefined,
-        totalConversions: p._sum.conversions ?? undefined,
-        totalSales: p._sum.sales ?? undefined,
-        totalSaleAmount: p._sum.saleAmount ?? undefined,
+        totalClicks: p._sum.clicks || undefined,
+        totalLeads: p._sum.leads || undefined,
+        totalConversions: p._sum.conversions || undefined,
+        totalSales: p._sum.sales || undefined,
+        totalSaleAmount: p._sum.saleAmount || undefined,
       };

Or simply omit the coalescing if null is acceptable:

       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-        totalClicks: p._sum.clicks ?? undefined,
-        totalLeads: p._sum.leads ?? undefined,
-        totalConversions: p._sum.conversions ?? undefined,
-        totalSales: p._sum.sales ?? undefined,
-        totalSaleAmount: p._sum.saleAmount ?? undefined,
+        ...(p._sum.clicks !== null && { totalClicks: p._sum.clicks }),
+        ...(p._sum.leads !== null && { totalLeads: p._sum.leads }),
+        ...(p._sum.conversions !== null && { totalConversions: p._sum.conversions }),
+        ...(p._sum.sales !== null && { totalSales: p._sum.sales }),
+        ...(p._sum.saleAmount !== null && { totalSaleAmount: p._sum.saleAmount }),
       };

128-133: Optimize the extraction of programIds and partnerIds.

Same inefficiency as in the linkStats section—splitting each composite key twice. Apply the same reduce-based optimization suggested earlier.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09555e0 and 9d0aaa0.

📒 Files selected for processing (6)
  • apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/route.ts (1 hunks)
  • apps/web/lib/api/partners/get-partner-for-program.ts (1 hunks)
  • apps/web/lib/api/partners/get-partners.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats.ts (1 hunks)
  • apps/web/vercel.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/partners/route.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (3)
apps/web/lib/upstash/redis-streams.ts (2)
  • partnerActivityStream (168-170)
  • PartnerActivityEvent (171-176)
apps/web/lib/cron/verify-vercel.ts (1)
  • verifyVercelSignature (3-20)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-178)
apps/web/scripts/partners/aggregate-stats.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • PartnerActivityEvent (171-176)
⏰ 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 (3)
apps/web/scripts/partners/aggregate-stats.ts (3)

1-3: LGTM!

The imports are appropriate for the script's purpose. Using dotenv-flow/config for side effects is the correct pattern for loading environment variables.


158-169: LGTM!

The conversion to array format and the use of console.table for output is appropriate for a PoC/test script. The immediate invocation of main() is the correct pattern for a standalone script.


137-137: Earnings field non-nullable with default 0
The Prisma schema defines earnings as a non-nullable Int with @default(0), so null values aren’t possible and earnings: { not: 0 } correctly filters out zero earnings.

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 (8)
apps/web/app/(ee)/api/partners/export/route.ts (1)

67-78: Simplify date handling by converting all Date values

Rather than maintaining a hard-coded list of fields, drop the column === … check and simply convert any Date instance:

let value = partner[column] ?? "";
if (value instanceof Date) {
  value = value.toISOString();
}
result[columnIdToLabel[column]] = value;

This guarantees every date/time field on the partner object is formatted correctly without manual upkeep.

apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (7)

13-14: Consider moving SUB_BATCH_SIZE to top-level constants.

The SUB_BATCH_SIZE constant is currently defined within the function scope at line 139. For consistency and easier configuration management, consider declaring it alongside BATCH_SIZE at the top level.

Apply this diff:

 const BATCH_SIZE = 10000;
+const SUB_BATCH_SIZE = 50;

Then remove the declaration at line 139:

-      const SUB_BATCH_SIZE = 50;

26-37: Optimize Set usage in aggregation.

The current approach converts arrays to Sets and back on each iteration (lines 31-33), which is inefficient. Consider using Sets throughout the reduce operation and converting to arrays only at the end, or better yet, keep them as Sets if that works with downstream logic.

Apply this diff:

       const programEnrollmentActivity = entries.reduce(
         (acc, entry) => {
           const { programId, partnerId, eventType } = entry.data;
           const key =
             eventType === "commission" ? "commissionStats" : "linkStats";
-          const eventTypesSet = new Set(acc[key]);
-          eventTypesSet.add(`${programId}:${partnerId}`);
-          acc[key] = Array.from(eventTypesSet);
+          acc[key].add(`${programId}:${partnerId}`);
           return acc;
         },
-        { linkStats: [], commissionStats: [] } as Record<string, string[]>,
+        { linkStats: new Set<string>(), commissionStats: new Set<string>() },
       );

Then convert to arrays before using them:

+      const linkStatsArray = Array.from(programEnrollmentActivity.linkStats);
+      const commissionStatsArray = Array.from(programEnrollmentActivity.commissionStats);
       
-      if (programEnrollmentActivity.linkStats.length > 0) {
-        const programIds = programEnrollmentActivity.linkStats.map(
+      if (linkStatsArray.length > 0) {
+        const programIds = linkStatsArray.map(

77-85: Use forEach instead of map when not returning a value.

The .map() call doesn't return a new array—it's used only for side effects (populating programEnrollmentsToUpdate). Use .forEach() instead for clarity.

Apply this diff:

-        partnerLinkStats.map((p) => {
+        partnerLinkStats.forEach((p) => {
           programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {

79-83: Remove redundant ?? undefined operators.

The nullish coalescing operator (??) already returns the right operand when the left is null or undefined. Explicitly returning undefined is redundant and can be simplified.

Apply this diff:

             totalClicks: p._sum.clicks ?? undefined,
             totalLeads: p._sum.leads ?? undefined,
             totalConversions: p._sum.conversions ?? undefined,
             totalSales: p._sum.sales ?? undefined,
             totalSaleAmount: p._sum.saleAmount ?? undefined,
+            totalClicks: p._sum.clicks ?? undefined,
+            totalLeads: p._sum.leads ?? undefined,
+            totalConversions: p._sum.conversions ?? undefined,
+            totalSales: p._sum.sales ?? undefined,
+            totalSaleAmount: p._sum.saleAmount ?? undefined,

Actually, you can simply omit ?? undefined entirely:

-            totalClicks: p._sum.clicks ?? undefined,
-            totalLeads: p._sum.leads ?? undefined,
-            totalConversions: p._sum.conversions ?? undefined,
-            totalSales: p._sum.sales ?? undefined,
-            totalSaleAmount: p._sum.saleAmount ?? undefined,
+            totalClicks: p._sum.clicks,
+            totalLeads: p._sum.leads,
+            totalConversions: p._sum.conversions,
+            totalSales: p._sum.sales,
+            totalSaleAmount: p._sum.saleAmount,

51-117: Execute link stats and commission stats queries in parallel.

Currently, the link stats query completes before the commission stats query begins (sequential execution). Since these queries are independent, they can run concurrently to reduce total processing time.

Apply this diff:

-      if (programEnrollmentActivity.linkStats.length > 0) {
-        const programIds = programEnrollmentActivity.linkStats.map(
-          (p) => p.split(":")[0],
-        );
-        const partnerIds = programEnrollmentActivity.linkStats.map(
-          (p) => p.split(":")[1],
-        );
-        const partnerLinkStats = await prisma.link.groupBy({
+      const [partnerLinkStats, partnerCommissionStats] = await Promise.all([
+        programEnrollmentActivity.linkStats.length > 0
+          ? prisma.link.groupBy({
-          by: ["programId", "partnerId"],
-          where: {
-            programId: {
-              in: programIds,
-            },
-            partnerId: {
-              in: partnerIds,
-            },
-          },
-          _sum: {
-            clicks: true,
-            leads: true,
-            conversions: true,
-            sales: true,
-            saleAmount: true,
-          },
-        });
-
-        partnerLinkStats.map((p) => {
-          programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-            totalClicks: p._sum.clicks ?? undefined,
-            totalLeads: p._sum.leads ?? undefined,
-            totalConversions: p._sum.conversions ?? undefined,
-            totalSales: p._sum.sales ?? undefined,
-            totalSaleAmount: p._sum.saleAmount ?? undefined,
-          };
-        });
-      }
-
-      if (programEnrollmentActivity.commissionStats.length > 0) {
-        const programIds = programEnrollmentActivity.commissionStats.map(
-          (p) => p.split(":")[0],
-        );
-        const partnerIds = programEnrollmentActivity.commissionStats.map(
-          (p) => p.split(":")[1],
-        );
-        const partnerCommissionStats = await prisma.commission.groupBy({
+              by: ["programId", "partnerId"],
+              where: {
+                programId: {
+                  in: programEnrollmentActivity.linkStats.map((p) => p.split(":")[0]),
+                },
+                partnerId: {
+                  in: programEnrollmentActivity.linkStats.map((p) => p.split(":")[1]),
+                },
+              },
+              _sum: {
+                clicks: true,
+                leads: true,
+                conversions: true,
+                sales: true,
+                saleAmount: true,
+              },
+            })
+          : Promise.resolve([]),
+        programEnrollmentActivity.commissionStats.length > 0
+          ? prisma.commission.groupBy({
-          by: ["programId", "partnerId"],
-          where: {
-            earnings: { not: 0 },
-            programId: {
-              in: programIds,
-            },
-            partnerId: {
-              in: partnerIds,
-            },
-            status: { in: ["pending", "processed", "paid"] },
-          },
-          _sum: {
-            earnings: true,
-          },
-        });
-        partnerCommissionStats.map((p) => {
-          programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-            ...programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`], // need to keep the other stats
-            totalCommissions: p._sum.earnings ?? undefined,
-          };
-        });
-      }
+              by: ["programId", "partnerId"],
+              where: {
+                earnings: { not: 0 },
+                programId: {
+                  in: programEnrollmentActivity.commissionStats.map((p) => p.split(":")[0]),
+                },
+                partnerId: {
+                  in: programEnrollmentActivity.commissionStats.map((p) => p.split(":")[1]),
+                },
+                status: { in: ["pending", "processed", "paid"] },
+              },
+              _sum: {
+                earnings: true,
+              },
+            })
+          : Promise.resolve([]),
+      ]);
+
+      partnerLinkStats.forEach((p) => {
+        programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
+          totalClicks: p._sum.clicks,
+          totalLeads: p._sum.leads,
+          totalConversions: p._sum.conversions,
+          totalSales: p._sum.sales,
+          totalSaleAmount: p._sum.saleAmount,
+        };
+      });
+
+      partnerCommissionStats.forEach((p) => {
+        programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
+          ...programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`],
+          totalCommissions: p._sum.earnings,
+        };
+      });

127-127: Consider removing or gating verbose logging in production.

console.table() could log thousands of partner/program ID combinations in production, potentially exposing sensitive information or cluttering logs. Consider removing this line or adding a debug flag to control it.

Apply this diff:

-      console.table(programEnrollmentsToUpdateArray);
+      if (process.env.DEBUG === "true") {
+        console.table(programEnrollmentsToUpdateArray);
+      }

165-194: The parameterized UPDATE query looks correct.

The SQL update statement is now properly formatted with SET before WHERE and uses parameterized queries with bound values, which prevents SQL injection. The filtering of undefined values (lines 165-167) also ensures only valid stats are included in the update.

One optional improvement: Consider extracting the update logic into a separate function for better testability:

async function updateProgramEnrollment(
  programId: string,
  partnerId: string,
  stats: Record<string, number>
) {
  const finalStatsToUpdate = Object.entries(stats).filter(
    ([_, value]) => value !== undefined,
  );

  if (finalStatsToUpdate.length === 0) {
    return;
  }

  await conn.execute(
    `UPDATE ProgramEnrollment SET ${finalStatsToUpdate
      .map(([key, _]) => `${key} = ?`)
      .join(", ")} WHERE programId = ? AND partnerId = ?`,
    [
      ...finalStatsToUpdate.map(([_, value]) => value),
      programId,
      partnerId,
    ],
  );
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d0aaa0 and 701e4f7.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/export/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (3)
apps/web/lib/upstash/redis-streams.ts (2)
  • partnerActivityStream (168-170)
  • PartnerActivityEvent (171-176)
apps/web/lib/cron/verify-vercel.ts (1)
  • verifyVercelSignature (3-20)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-178)
⏰ 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/(ee)/api/cron/streams/update-partner-stats/route.ts (2)

199-211: LGTM! Error handling and logging are well-structured.

The success rate calculation, progress logging, and limited error reporting (first 5 errors) provide good observability without overwhelming the logs.


228-261: LGTM! The endpoint handler is well-structured.

The GET handler properly verifies the request signature, processes the stream batch, handles the no-updates case, and returns a structured response with monitoring information. Error handling is consistent with the codebase standards.

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

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/(ee)/app.dub.co/embed/referrals/utils.ts (1)

95-100: Type assertion violates type safety—only a subset of properties is present.

The object literal includes only id, additionalLinks, maxPartnerLinks, and linkStructure, but the as PartnerGroupProps assertion claims it contains all properties from the full PartnerGroup type. This is unsafe: consumers relying on the type contract may access missing properties and encounter runtime errors.

Apply this diff to use a proper subset type:

-    } as PartnerGroupProps,
+    } as Pick<PartnerGroupProps, 'id' | 'additionalLinks' | 'maxPartnerLinks' | 'linkStructure'>,

Alternatively, if consumers genuinely need the full PartnerGroupProps, spread the entire group object instead:

-    group: {
-      id: group.id,
-      additionalLinks: group.additionalLinks,
-      maxPartnerLinks: group.maxPartnerLinks,
-      linkStructure: group.linkStructure,
-    } as PartnerGroupProps,
+    group: group as PartnerGroupProps,
♻️ Duplicate comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)

265-269: Webhook total remains racy under concurrent commissions.

programEnrollment.totalCommissions + commission.earnings still derives from a stale enrollment snapshot, so concurrent commission inserts can emit conflicting totals (e.g., 150 vs. 130 while the real total is 180). Please switch to an atomic DB increment (e.g., UPDATE ... SET totalCommissions = totalCommissions + :earnings RETURNING totalCommissions) or wait for syncTotalCommissions to reconcile before publishing the webhook so the payload reflects the authoritative value.

🧹 Nitpick comments (3)
apps/web/scripts/migrations/backfill-click-commissions.ts (1)

15-18: The refactor aligns with the broader pattern, but links inclusion is unused.

The change from a boolean flag to a nested include object is correct and consistent with the refactor mentioned in the AI summary. However, links: true is now included but programEnrollment.links is never referenced in this script. The script continues to fetch a link separately at lines 26-31.

Since this is a migration script, the unused inclusion is minor. However, if desired, you could optimize by using programEnrollment.links[0] (or similar logic) instead of the separate prisma.link.findFirst call.

apps/web/scripts/partners/aggregate-stats-seeding.ts (2)

3-3: Remove or uncomment the import statement.

The commented-out import suggests this script is in an incomplete state. Either remove the comment and implement the event publishing functionality (lines 47-58), or remove both the import and the commented code if event publishing is not needed for this seeding script.


5-32: Add error handling and clarify the script's purpose.

The comment states this script tests the cron job /api/cron/streams/update-partner-stats, but the implementation only queries and logs aggregated data without actually invoking or simulating the cron job behavior. Additionally, the script lacks error handling for the database query.

Consider these improvements:

-// PoC script to test /api/cron/streams/update-partner-stats cron job
+// PoC script to seed and verify partner link statistics aggregation
 async function main() {
+  try {
     const partnerLinksWithActivity = await prisma.link.groupBy({
       by: ["partnerId", "programId"],
       where: {
         programId: {
           not: null,
         },
         partnerId: {
           not: null,
         },
         clicks: {
           gt: 0,
         },
       },
       _sum: {
         clicks: true,
         leads: true,
         conversions: true,
         sales: true,
         saleAmount: true,
       },
       orderBy: {
         _sum: {
           saleAmount: "desc",
         },
       },
     });
+  } catch (error) {
+    console.error("Error aggregating partner stats:", error);
+    throw error;
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 701e4f7 and 8134579.

📒 Files selected for processing (37)
  • apps/web/app/(ee)/api/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/commissions/[commissionId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/discount-codes/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (2 hunks)
  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1 hunks)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (4 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (6 hunks)
  • apps/web/lib/actions/partners/archive-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/create-clawback.ts (1 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
  • apps/web/lib/actions/partners/create-partner-comment.ts (1 hunks)
  • apps/web/lib/actions/partners/deactivate-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/mark-program-messages-read.ts (1 hunks)
  • apps/web/lib/actions/partners/message-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/message-program.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-enrollment.ts (1 hunks)
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1 hunks)
  • apps/web/lib/api/partners/backfill-link-commissions.ts (1 hunks)
  • apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (3 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (3 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-click-commissions.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats-seeding.ts (1 hunks)
  • apps/web/scripts/tella/update-commissions.ts (1 hunks)
  • apps/web/tests/partners/resource.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-67)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (5-16)
apps/web/lib/actions/partners/deactivate-partner.ts (2)
apps/web/lib/api/links/cache.ts (1)
  • linkCache (113-113)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
  • queueDiscountCodeDeletion (13-41)
apps/web/lib/partners/create-partner-commission.ts (1)
apps/web/lib/api/partners/sync-total-commissions.ts (1)
  • syncTotalCommissions (5-50)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (1)
apps/web/lib/types.ts (1)
  • PartnerGroupProps (600-602)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts (1)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-67)
⏰ 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 (25)
apps/web/app/(ee)/api/discount-codes/route.ts (1)

28-30: LGTM! Clean refactoring to explicit include pattern.

The migration from boolean flags (includeDiscountCodes, includeDiscount) to explicit nested include objects improves clarity about which relations are loaded. The destructuring pattern in the POST handler enhances readability while maintaining identical behavior and null safety.

Also applies to: 71-80

apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (1)

22-31: LGTM! Include object consolidation improves API consistency.

The refactor from multiple boolean flags to a single include object is cleaner and aligns with the broader refactoring patterns across the PR.

apps/web/scripts/partners/aggregate-stats-seeding.ts (1)

34-45: LGTM!

The result logging is appropriate for a PoC script. The top 10 slice and table format provide a good overview of the aggregated partner statistics.

apps/web/lib/actions/partners/message-program.ts (1)

22-26: LGTM!

The addition of include: {} aligns with the broader refactor to standardize the getProgramEnrollmentOrThrow API. The empty include is appropriate here since the code only needs the enrollment's programId and doesn't require any related data.

apps/web/lib/actions/partners/mark-program-messages-read.ts (1)

19-23: LGTM!

The addition of include: {} is consistent with the PR's refactoring pattern. The empty include is appropriate since only partnerId and programId are destructured and used.

apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)

36-41: LGTM!

The addition of include: {} aligns with the standardized include pattern. The empty include is correct since the code only accesses the enrollment's own fields (status, groupId) without needing related entities.

apps/web/app/(ee)/api/bounties/route.ts (1)

37-45: LGTM!

The migration from includeProgram: true to include: { program: true } correctly transitions to the standardized include object pattern. The included program data is necessary for accessing programEnrollment.program.defaultGroupId on line 63.

apps/web/lib/actions/partners/archive-partner.ts (1)

20-24: LGTM!

The addition of include: {} is consistent with the refactoring pattern. The empty include is appropriate since only the status field from the enrollment itself is accessed.

apps/web/lib/actions/partners/create-partner-comment.ts (1)

21-25: LGTM!

The addition of include: {} follows the standardized pattern. The empty include is appropriate since the call is only validating that the enrollment exists (no data from the result is used).

apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

56-65: LGTM!

The migration to include: { program: true, partner: true, links: true } correctly transitions to the standardized include object pattern. The included data is necessary for the destructuring on line 56 and subsequent usage throughout the workflow steps.

apps/web/lib/actions/partners/create-manual-commission.ts (2)

71-79: LGTM!

The migration to include: { partner: true, links: true } correctly transitions to the standardized include object pattern. The included partner and links data are necessary for the destructuring on line 71 and subsequent usage throughout the function.


523-546: LGTM! Good parallelization pattern.

The use of Promise.allSettled to run executeWorkflows and syncPartnerLinksStats in parallel is a good practice that improves performance. The type assertion commissionType as "lead" | "sale" on line 543 is safe because it's guarded by the conditional check on line 523 that filters to only these two values.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1)

16-18: LGTM! Consistent refactoring to structured include.

The migration from implicit includes to an explicit include: { program: true } makes the data requirements clear at the call site without changing any logic.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1)

15-18: LGTM! Explicit includes for program and links.

The structured include ensures that both program and links are fetched alongside the enrollment, supporting downstream usage in bounty filtering (line 26) and aggregation (line 83).

apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1)

26-28: LGTM! Clear data requirements.

The explicit include: { program: true } ensures program data is available for event filtering (lines 54, 56) without side effects.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1)

18-21: LGTM! Necessary includes for customer retrieval.

Both program and links are required downstream: program for validation (line 30) and links for event filtering (line 39). The structured include makes this explicit.

apps/web/lib/api/partners/backfill-link-commissions.ts (1)

29-33: LGTM! Migration to structured include.

Replaces the includeSaleReward boolean flag with an explicit include object. All three relations (program, links, saleReward) are used downstream for determining rewards and creating commissions.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1)

19-21: LGTM! Explicit program include.

The structured include ensures program data is available for the earnings query (line 50) without altering control flow.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1)

24-37: LGTM! Structured include with clear aliasing.

The migration from includeGroup: true to include: { program: true, links: true, partnerGroup: true } improves clarity, and the destructuring alias partnerGroup: group maintains readable variable names for downstream validation (lines 46, 82, 86).

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1)

17-20: LGTM! Explicit includes for timeseries.

Both program and links are required: program for date boundaries (line 40) and query filtering (line 59), and links for groupBy aggregation (line 109). The structured include makes these dependencies explicit.

apps/web/tests/partners/resource.ts (1)

30-30: Approve bannedAt field addition
The bannedAt field is defined in program.prisma, mirrored in Zod schemas (programs.ts), and handled in all partner actions—tests align with production code.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (3)

65-91: LGTM! Column definitions consistently updated.

The column definitions have been updated consistently to use total* prefixed fields, aligning with the data model changes. The netRevenue field appropriately remains without the total prefix as it appears to be a calculated metric.


146-146: LGTM! Column visibility key versioning.

The version bump in the column visibility key prevents issues with stale user preferences that reference the old column names. This is the correct approach for a breaking schema change.


306-315: LGTM! Sortable columns updated with temporary netRevenue limitation noted.

The sortable columns have been correctly updated to use the total* prefixed fields. The comment on line 314 clearly indicates that netRevenue sorting is temporarily disabled and will be re-enabled when backend support is available.

apps/web/lib/partners/determine-partner-reward.ts (1)

16-22: Verify getProgramEnrollmentOrThrow selects links Confirm that the getProgramEnrollmentOrThrow query includes the links relation so every programEnrollment passed into determinePartnerReward satisfies the now-required links field.

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

♻️ Duplicate comments (3)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)

17-35: Preserve caller include args when adding defaults.

The finalInclude construction still overwrites any nested include, select, or orderBy that the caller supplies for links and discountCodes. When include.links is truthy (either true or an object), the code replaces the entire caller payload with just { orderBy: { createdAt: "asc" } }. Similarly, discountCodes gets replaced with only { where: { discountId: { not: null } } }. This breaks callers who pass nested relations like links: { include: { tags: true } } or discountCodes: { select: { code: true, discountId: true } }.

Apply this diff to merge defaults with caller payloads instead of replacing them:

  const finalInclude = {
    ...include,
-    links: include.links
-      ? {
-          orderBy: {
-            createdAt: "asc",
-          },
-        }
-      : false,
-    discountCodes: include.discountCodes
-      ? {
-          where: {
-            discountId: {
-              not: null,
-            },
-          },
-        }
-      : false,
+    links:
+      include.links === undefined || include.links === false
+        ? false
+        : {
+            ...(include.links === true ? {} : include.links),
+            orderBy: {
+              createdAt: "asc",
+            },
+          },
+    discountCodes:
+      include.discountCodes === undefined || include.discountCodes === false
+        ? false
+        : {
+            ...(include.discountCodes === true ? {} : include.discountCodes),
+            where: {
+              ...(include.discountCodes !== true && include.discountCodes.where
+                ? include.discountCodes.where
+                : {}),
+              discountId: {
+                not: null,
+              },
+            },
+          },
  };
apps/web/scripts/partners/aggregate-stats-seeding.ts (2)

67-67: Add proper error handling and cleanup.

The script should handle errors from main() and properly close the Prisma connection to avoid hanging connections or incomplete execution.

Apply this diff to add proper error handling and cleanup:

-main();
+main()
+  .catch((error) => {
+    console.error("Script failed:", error);
+    process.exit(1);
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });

52-61: Active event publishing loses activity type granularity and misrepresents timestamps.

The code now actively publishes only "click" events for all aggregated partner activity (clicks, leads, conversions, sales), resulting in:

  1. Data loss: Leads, conversions, and sales metrics are not published as events
  2. Misleading timestamps: Historical activity is stamped with the current time (new Date().toISOString())
  3. No error handling: Publishing failures are silent

This differs from the earlier commented-out code—these issues are now live in production seeding.

Consider one of these approaches:

Option 1: Publish separate events per activity type

   await Promise.all(
     batchedPartnerLinksWithActivity.map(async (partnerLink) => {
-      await publishPartnerActivityEvent({
-        partnerId: partnerLink.partnerId!,
-        programId: partnerLink.programId!,
-        eventType: "click",
-        timestamp: new Date().toISOString(),
-      });
+      const events = [];
+      
+      if (partnerLink._sum.clicks) {
+        events.push(
+          publishPartnerActivityEvent({
+            partnerId: partnerLink.partnerId!,
+            programId: partnerLink.programId!,
+            eventType: "click",
+            timestamp: new Date().toISOString(),
+          })
+        );
+      }
+      
+      if (partnerLink._sum.leads) {
+        events.push(
+          publishPartnerActivityEvent({
+            partnerId: partnerLink.partnerId!,
+            programId: partnerLink.programId!,
+            eventType: "lead",
+            timestamp: new Date().toISOString(),
+          })
+        );
+      }
+      
+      // Similar blocks for conversions and sales...
+      
+      try {
+        await Promise.all(events);
+      } catch (error) {
+        console.error(`Failed to publish events for partner ${partnerLink.partnerId}:`, error);
+      }
     }),
   );

Option 2: If these events are only needed to trigger aggregation, publish one event per partner-program pair and let the aggregation logic compute totals from the database

Additionally, consider whether historical timestamps can be preserved (e.g., from the most recent link activity) rather than using the current time.

🧹 Nitpick comments (1)
apps/web/scripts/partners/aggregate-stats-seeding.ts (1)

47-51: Parameterize the batch number for operational flexibility.

The hard-coded BATCH = 9 value requires manual code modification for each batch run. Consider accepting the batch number as a command-line argument or environment variable.

Apply this diff to accept batch number from command line:

+const BATCH = parseInt(process.argv[2] || "0", 10);
+
 async function main() {
+  if (isNaN(BATCH) || BATCH < 0) {
+    console.error("Invalid BATCH number. Usage: ts-node script.ts <batch_number>");
+    process.exit(1);
+  }
+  console.log(`Processing batch ${BATCH} (records ${BATCH * 5000} to ${(BATCH + 1) * 5000})`);
+
   const partnerLinksWithActivity = await prisma.link.groupBy({

Then run with: ts-node aggregate-stats-seeding.ts 9

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8134579 and 31e6fb2.

📒 Files selected for processing (2)
  • apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (3 hunks)
  • apps/web/scripts/partners/aggregate-stats-seeding.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/scripts/partners/aggregate-stats-seeding.ts (1)
apps/web/lib/upstash/redis-streams.ts (1)
  • publishPartnerActivityEvent (178-188)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
⏰ 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 (3)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)

57-62: LGTM! Slug lookup issue resolved.

The error check now only verifies programEnrollment presence, which correctly handles slug-based lookups without requiring the program relation to be included. This fixes the 404 issue from the previous review.

apps/web/scripts/partners/aggregate-stats-seeding.ts (2)

1-3: LGTM!

The imports are appropriate for this seeding script.


6-45: LGTM!

The query logic correctly aggregates partner activity metrics and the logging provides useful visibility into the top performers.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 12, 2025

✅ Actions performed

Full review triggered.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/lib/rewardful/import-customers.ts (1)

224-253: Don't swallow lead/stat sync failures.

Switching from Promise.all to Promise.allSettled means any rejection from the lead recorder, link update, or the new syncPartnerLinksStats call is silently ignored—formerly these propagated and aborted the import. That leaves customers created without corresponding lead stats or partner totals, and we never notice. Please keep propagating failures (e.g. stick with Promise.all) or at least surface the rejected results so we can log and throw.

-  await Promise.allSettled([
+  const results = await Promise.allSettled([
     recordLeadWithTimestamp({
       ...clickEvent,
       event_id: nanoid(16),
       event_name: "Sign up",
       customer_id: customerId,
       timestamp: new Date(referral.became_lead_at).toISOString(),
     }),

     prisma.link.update({
       where: { id: link.id },
       data: {
         leads: { increment: 1 },
         lastLeadAt: updateLinkStatsForImporter({
           currentTimestamp: link.lastLeadAt,
           newTimestamp: new Date(referral.became_lead_at),
         }),
       },
     }),
     // partner links should always have a partnerId and programId, but we're doing this to make TS happy
     ...(link.partnerId && link.programId
       ? [
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
         ]
       : []),
-  ]);
+  ]);
+
+  const rejected = results.filter(
+    (result): result is PromiseRejectedResult => result.status === "rejected",
+  );
+
+  if (rejected.length) {
+    rejected.forEach(({ reason }) =>
+      console.error("Rewardful import side effect failed:", reason),
+    );
+    throw rejected[0].reason;
+  }
apps/web/lib/zod/schemas/partners.ts (1)

165-170: Update description to reference total-prefixed fields.

The includeExpandedFields description references outdated field names (clicks, leads, conversions, sales, saleAmount, commissions, netRevenue). Update to match the new total* field names.

Apply this diff:

     includeExpandedFields: booleanQuerySchema
       .optional()
       .describe(
-        "Whether to include stats fields on the partner (`clicks`, `leads`, `conversions`, `sales`, `saleAmount`, `commissions`, `netRevenue`). If false, those fields will be returned as 0.",
+        "Whether to include stats fields on the partner (`totalClicks`, `totalLeads`, `totalConversions`, `totalSales`, `totalSaleAmount`, `totalCommissions`, `netRevenue`). If false, those fields will be returned as 0.",
       )
       .openapi({ example: "true" }),
♻️ Duplicate comments (13)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)

17-35: The include merge issue from previous review remains unresolved.

This code still overwrites nested include, select, or orderBy properties that callers supply for links and discountCodes. For example, if a caller passes include: { links: { include: { tags: true } } }, the current implementation replaces it with { orderBy: { createdAt: "asc" } }, losing the tags relation.

Please implement the merge logic suggested in the previous review to preserve caller-supplied nested properties while adding defaults.

apps/web/lib/api/partners/get-partner-for-program.ts (1)

36-40: Duplicate: Handle Prisma Decimal arithmetic when deriving netRevenue.

This issue was already flagged in a previous review. ProgramEnrollment.totalSaleAmount and .totalCommissions are stored as Prisma Decimals. Using the native - operator will fail at runtime. Convert both values to numbers (e.g., .toNumber() with ?? 0 fallbacks) or use the Decimal API (.sub(...)).

apps/web/scripts/partners/aggregate-stats.ts (2)

116-124: Duplicate: Replace .map() with .forEach().

This was already flagged in a previous review. Since the return value is discarded and you're mutating programEnrollmentsToUpdate for side effects, .forEach() more clearly expresses intent.


150-155: Duplicate: Replace .map() with .forEach().

This was already flagged in a previous review. Replace .map() with .forEach() since the return value is discarded.

apps/web/lib/api/conversions/track-lead.ts (1)

319-335: Critical issue already flagged: incorrect eventType.

This issue was already identified in the previous review. Line 333 incorrectly uses eventType: "sale" in the track-lead flow, which should be eventType: "lead" to match the context.

apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1)

140-149: Critical issue from previous review remains unresolved.

The loop still calls syncTotalCommissions for every commission, causing duplicate syncs for partners with multiple commissions. The log message count is misleading (it's commission count, not unique partners), and there's no error handling for sync failures.

This is the same critical issue flagged in the previous review. Please apply the suggested fix to deduplicate partner/program pairs, parallelize with error handling, and log accurate counts.

apps/web/app/(ee)/api/commissions/[commissionId]/route.ts (1)

88-95: Critical issue from previous review remains unresolved.

The program relation is still missing from the include object, which will cause getProgramEnrollmentOrThrow to throw an error. The previous review clearly identified this as a critical issue.

Apply this diff to include the program relation:

       include: {
+        program: true,
         links: true,
         saleReward: true,
       },
apps/web/scripts/partners/aggregate-stats-seeding.ts (2)

53-59: Seed every aggregated metric, not just a single click.

We aggregate clicks, leads, conversions, and sales above, yet the seeding loop emits only one "click" event per link with a flat count of 1. That leaves every non-click metric unsynchronized and drops the true counts we just computed. Please fan out events for each non-zero _sum field (and pass the summed count) or otherwise apply the totals directly so ProgramEnrollment stats stay accurate.


67-67: Add error handling and close Prisma before exiting.

The bare main() call still omits a catch/finally, so rejected promises go unreported and Prisma connections stay open—exactly what we flagged earlier. Wrap the invocation to log failures, exit non-zero, and always await prisma.$disconnect().

-main();
+main()
+  .catch((error) => {
+    console.error("Script failed:", error);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
apps/web/lib/partners/create-partner-commission.ts (1)

265-269: Webhook totalCommissions is now stale under concurrency.

By switching to programEnrollment.totalCommissions + commission.earnings we’re using the pre-insert snapshot of totals without waiting for an authoritative recalculation. If two commissions are created in parallel (or if the enrollment row hasn’t been reconciled yet), both executions read the same baseline (e.g., 1000) and each webhook now emits 1100, even though the real total after both inserts is 1200. Previously we awaited syncTotalCommissions and used its aggregate result, so the payload reflected the actual persisted total. Please keep sourcing an authoritative total before building the webhook payload (e.g., await the sync and reuse its result, or otherwise fetch the definitive post-update value) so downstream consumers keep receiving correct numbers.

apps/web/lib/api/partners/get-partners.ts (1)

31-50: Verify case-insensitive search behavior.

The search filtering uses contains/equals without mode: "insensitive". A previous review flagged this as a regression from ILIKE, suggesting PostgreSQL usage where case-insensitivity must be explicit.

However, based on learnings, MySQL with standard collations (utf8_general_ci, utf8mb4_unicode_ci) has case-insensitive LIKE by default, making mode: "insensitive" unnecessary.

Confirm which database is in use and test the search behavior:

#!/bin/bash
# Check database provider in Prisma schema
rg -n "provider\s*=\s*" -g "*.prisma" -A2

# Search for database configuration
rg -n "DATABASE_URL" -g ".env*" -g "*.ts" -g "*.js"

If using PostgreSQL, apply this diff:

               ...(search && {
                 OR: [
-                  { id: { contains: search } },
-                  { name: { contains: search } },
-                  { email: { contains: search } },
+                  { id: { contains: search, mode: "insensitive" } },
+                  { name: { contains: search, mode: "insensitive" } },
+                  { email: { contains: search, mode: "insensitive" } },
                 ],
               }),
-              email,
+              ...(email && {
+                email: { equals: email, mode: "insensitive" },
+              }),
apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts (1)

16-23: Critical: Add program relation to the include object.

getProgramEnrollmentOrThrow requires the program relation to be loaded. Without it, the function will throw a not_found error even when the enrollment exists. This issue was previously flagged but remains unresolved.

Apply this diff:

   const { links, discountCodes } = await getProgramEnrollmentOrThrow({
     partnerId: partner.id,
     programId: params.programId,
     include: {
+      program: true,
       links: true,
       discountCodes: true,
     },
   });
apps/web/lib/upstash/redis-streams.ts (1)

178-188: Critical: Add error handling to publishPartnerActivityEvent.

This function lacks error handling while publishClickEvent (lines 152-164) includes it. Without a try-catch, Redis failures will throw unhandled exceptions, potentially breaking caller flows. This issue was previously flagged but remains unresolved.

Apply this diff:

 export const publishPartnerActivityEvent = async (
   event: PartnerActivityEvent,
 ) => {
   const { programId, partnerId, timestamp, eventType } = event;
-  return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, "*", {
-    programId,
-    partnerId,
-    timestamp,
-    eventType,
-  });
+  try {
+    return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, "*", {
+      programId,
+      partnerId,
+      timestamp,
+      eventType,
+    });
+  } catch (error) {
+    console.error("Failed to publish partner activity event to stream:", error);
+    throw error;
+  }
 };
🧹 Nitpick comments (10)
apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (1)

5-5: LGTM! Clean refactor from individual flags to include object.

The changes improve code maintainability:

  • Import of PartnerGroupProps is properly utilized
  • Replacing individual include* boolean flags with a structured include object is more scalable and aligns with the broader PR refactor
  • Renaming partnerGroup to group in destructuring is consistent with its usage

Also applies to: 22-31, 60-60

apps/web/lib/actions/partners/create-partner-comment.ts (1)

21-25: Consider removing the empty include object if optional.

The include: {} parameter appears to add no value. If the include parameter is optional in getProgramEnrollmentOrThrow, consider omitting it entirely for cleaner code.

If the parameter is required by the function signature, this is acceptable as-is.

apps/web/lib/actions/partners/create-bounty-submission.ts (1)

44-49: Consider removing the empty include object if optional.

The include: {} parameter appears to add no value. If the include parameter is optional in getProgramEnrollmentOrThrow, consider omitting it entirely for cleaner code.

If the parameter is required by the function signature, this is acceptable as-is.

apps/web/lib/api/conversions/track-sale.ts (2)

371-387: Consider logging errors from Promise.allSettled results.

Promise.allSettled silently swallows errors from both operations. If syncPartnerLinksStats or executeWorkflows fail, the error won't be visible. Consider adding error logging for observability:

         await Promise.allSettled([
           executeWorkflows({
             trigger: WorkflowTrigger.leadRecorded,
             context: {
               programId: link.programId,
               partnerId: link.partnerId,
               current: {
                 leads: 1,
               },
             },
           }),
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
-        ]);
+        ]).then((results) => {
+          results.forEach((result, index) => {
+            if (result.status === 'rejected') {
+              console.error(`Partner lead workflow ${index} failed:`, result.reason);
+            }
+          });
+        });

The same pattern should be applied to the sale path at lines 551-568.


551-568: Consider logging errors from Promise.allSettled results.

Same concern as the lead path: Promise.allSettled silently swallows errors. Consider adding error logging for the sale workflow as well to match the recommendation at lines 371-387.

apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

235-252: Consider logging errors from Promise.allSettled results.

Similar to the track-sale flow, Promise.allSettled silently swallows errors from both executeWorkflows and syncPartnerLinksStats. Consider adding error logging for observability:

       Promise.allSettled([
         executeWorkflows({
           trigger: WorkflowTrigger.saleRecorded,
           context: {
             programId: link.programId,
             partnerId: link.partnerId,
             current: {
               saleAmount: saleData.amount,
               conversions: firstConversionFlag ? 1 : 0,
             },
           },
         }),
         syncPartnerLinksStats({
           partnerId: link.partnerId,
           programId: link.programId,
           eventType: "sale",
         }),
-      ]),
+      ]).then((results) => {
+        results.forEach((result, index) => {
+          if (result.status === 'rejected') {
+            console.error(`Stripe invoice partner workflow ${index} failed:`, result.reason);
+          }
+        });
+      }),
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (2)

77-85: Consider replacing .map() with .forEach().

Similar to the patterns flagged in the aggregate-stats.ts script, .map() is used here while discarding the return value. Since you're performing side effects by mutating programEnrollmentsToUpdate, .forEach() would more clearly express intent.

Apply this diff:

-    partnerLinkStats.map((p) => {
+    partnerLinkStats.forEach((p) => {
       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
         totalClicks: p._sum.clicks ?? undefined,
         totalLeads: p._sum.leads ?? undefined,
         totalConversions: p._sum.conversions ?? undefined,
         totalSales: p._sum.sales ?? undefined,
         totalSaleAmount: p._sum.saleAmount ?? undefined,
       };
     });

111-116: Consider replacing .map() with .forEach().

Similar to line 77, .map() is used for side effects. .forEach() would be more appropriate.

Apply this diff:

-    partnerCommissionStats.map((p) => {
+    partnerCommissionStats.forEach((p) => {
       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
         ...programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`],
         totalCommissions: p._sum.earnings ?? undefined,
       };
     });
apps/web/app/(ee)/api/partners/route.ts (2)

21-26: Remove console timing instrumentation before production.

The console.time and console.timeEnd calls are useful for debugging but should be removed before merging to production to avoid cluttering logs.

Apply this diff to remove the timing instrumentation:

-    console.time("getPartners");
     const partners = await getPartners({
       ...parsedParams,
       programId,
     });
-    console.timeEnd("getPartners");

28-50: Consider removing redundant schema validation.

The response data is validated with a Zod schema after mapping deprecated fields. If the partners array returned by getPartners is already validated and the field mapping is straightforward, this additional validation may be unnecessary overhead.

If the data is already validated, consider simplifying to:

-    // polyfill deprecated fields for backward compatibility
-    return NextResponse.json(
-      z
-        .array(
-          EnrolledPartnerSchema.extend({
-            clicks: z.number().default(0),
-            leads: z.number().default(0),
-            conversions: z.number().default(0),
-            sales: z.number().default(0),
-            saleAmount: z.number().default(0),
-          }),
-        )
-        .parse(
-          partners.map((partner) => ({
-            ...partner,
-            clicks: partner.totalClicks,
-            leads: partner.totalLeads,
-            conversions: partner.totalConversions,
-            sales: partner.totalSales,
-            saleAmount: partner.totalSaleAmount,
-          })),
-        ),
-    );
+    return NextResponse.json(
+      partners.map((partner) => ({
+        ...partner,
+        clicks: partner.totalClicks ?? 0,
+        leads: partner.totalLeads ?? 0,
+        conversions: partner.totalConversions ?? 0,
+        sales: partner.totalSales ?? 0,
+        saleAmount: partner.totalSaleAmount ?? 0,
+      })),
+    );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eaec736 and 31e6fb2.

📒 Files selected for processing (72)
  • apps/web/app/(ee)/api/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/commissions/[commissionId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1 hunks)
  • apps/web/app/(ee)/api/discount-codes/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partners/export/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (2 hunks)
  • apps/web/app/(ee)/api/track/click/route.ts (1 hunks)
  • apps/web/app/(ee)/api/track/open/route.ts (1 hunks)
  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1 hunks)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (4 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (6 hunks)
  • apps/web/lib/actions/partners/archive-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/create-clawback.ts (1 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
  • apps/web/lib/actions/partners/create-partner-comment.ts (1 hunks)
  • apps/web/lib/actions/partners/deactivate-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/mark-program-messages-read.ts (1 hunks)
  • apps/web/lib/actions/partners/message-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/message-program.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-enrollment.ts (1 hunks)
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1 hunks)
  • apps/web/lib/api/conversions/track-lead.ts (2 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (3 hunks)
  • apps/web/lib/api/partners/backfill-link-commissions.ts (1 hunks)
  • apps/web/lib/api/partners/get-partner-for-program.ts (1 hunks)
  • apps/web/lib/api/partners/get-partners.ts (1 hunks)
  • apps/web/lib/api/partners/sync-partner-links-stats.ts (1 hunks)
  • apps/web/lib/api/partners/sync-total-commissions.ts (2 hunks)
  • apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (3 hunks)
  • apps/web/lib/firstpromoter/import-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-customers.ts (4 hunks)
  • apps/web/lib/integrations/shopify/create-sale.ts (2 hunks)
  • apps/web/lib/middleware/link.ts (7 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (3 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (1 hunks)
  • apps/web/lib/partnerstack/import-commissions.ts (2 hunks)
  • apps/web/lib/partnerstack/import-customers.ts (4 hunks)
  • apps/web/lib/rewardful/import-commissions.ts (2 hunks)
  • apps/web/lib/rewardful/import-customers.ts (3 hunks)
  • apps/web/lib/tinybird/record-click.ts (5 hunks)
  • apps/web/lib/tolt/import-commissions.ts (2 hunks)
  • apps/web/lib/tolt/import-customers.ts (5 hunks)
  • apps/web/lib/upstash/redis-streams.ts (1 hunks)
  • apps/web/lib/zod/schemas/partners.ts (2 hunks)
  • apps/web/scripts/cal/backfill-referral-links.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-click-commissions.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats-seeding.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats.ts (1 hunks)
  • apps/web/scripts/tella/update-commissions.ts (1 hunks)
  • apps/web/tests/partners/resource.ts (1 hunks)
  • apps/web/ui/links/link-builder/link-partner-details.tsx (1 hunks)
  • apps/web/ui/partners/partner-info-stats.tsx (1 hunks)
  • apps/web/vercel.json (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/rewardful/import-customers.ts
📚 Learning: 2025-08-14T04:46:08.721Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/(ee)/api/groups/count/route.ts:17-31
Timestamp: 2025-08-14T04:46:08.721Z
Learning: MySQL string comparisons using LIKE (which Prisma's `contains` maps to) are case-insensitive by default unless the column has a case-sensitive collation. Adding `mode: "insensitive"` to Prisma queries is unnecessary for MySQL databases with standard collations like utf8_general_ci or utf8mb4_unicode_ci.

Applied to files:

  • apps/web/lib/api/partners/get-partners.ts
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/ui/partners/partner-info-stats.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx
🔇 Additional comments (47)
apps/web/lib/actions/partners/create-clawback.ts (1)

17-21: LGTM! API consistency change with no functional impact.

The include: {} parameter has no effect here since the result of getProgramEnrollmentOrThrow is unused—the call is purely for validation (it throws if the enrollment doesn't exist). However, adding this parameter maintains API consistency across the codebase as part of the broader refactor to standardize include patterns.

apps/web/ui/links/link-builder/link-partner-details.tsx (1)

59-61: LGTM! Field rename aligns with the data model refactoring.

The change from saleAmount to totalSaleAmount is consistent with the broader refactoring to use total-prefixed fields for aggregate partner metrics. The logic and currency formatting remain correct.

apps/web/tests/partners/resource.ts (1)

30-30: LGTM. bannedAt matches existing nullable date-field patterns; ensure your database model (e.g. Prisma schema and migrations) includes a corresponding bannedAt column.

apps/web/app/(ee)/api/discount-codes/route.ts (2)

28-30: LGTM! Include pattern standardization.

The refactor from includeDiscountCodes: true to include: { discountCodes: true } aligns with the PR's goal of standardizing data fetching across the codebase. The explicit include pattern improves clarity.


71-80: LGTM! Improved readability with explicit includes.

The changes correctly standardize the include pattern and add destructuring for cleaner code:

  • Explicitly includes all required relationships: links, discount, and discountCodes
  • Destructuring { links, discount } reduces verbosity in subsequent code
  • All included relationships are used in the handler logic

The explicit include pattern is a good practice as it makes data dependencies clear at the fetch site.

apps/web/scripts/cal/backfill-referral-links.ts (1)

91-93: Confirm createAndEnrollPartner returns totalClicks/totalLeads/totalSales. The mapping at lines 91–93 depends on these properties but they’re not present in the current implementation or TS definition—ensure the API response and return type have been updated accordingly.

apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (1)

95-100: Avoid unsafe as PartnerGroupProps cast. It bypasses TS checks—ensure you map all fields from PartnerGroup & { additionalLinks: … } or use satisfies PartnerGroupProps so missing or mismatched props are caught.

apps/web/lib/middleware/link.ts (1)

282-283: LGTM!

The addition of programId and partnerId to all recordClick calls is consistent across all redirect paths and correctly references cachedLink properties. This enables partner activity tracking as intended by the PR.

Also applies to: 335-336, 375-376, 417-418, 488-489, 524-525, 560-561

apps/web/lib/actions/partners/message-program.ts (1)

22-26: LGTM!

The addition of include: {} correctly adapts to the new function signature. Since only programId is used from the enrollment result, an empty include is appropriate.

apps/web/lib/actions/partners/message-partner.ts (1)

39-43: LGTM!

The addition of include: {} correctly adapts to the new function signature. Since the function only validates enrollment existence, an empty include is appropriate.

apps/web/lib/actions/partners/mark-program-messages-read.ts (1)

19-23: LGTM!

The addition of include: {} correctly adapts to the new function signature. Since only partnerId and programId are used from the result, an empty include is appropriate.

apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)

36-41: LGTM!

The addition of include: {} correctly adapts to the new function signature. Since only scalar fields (status, groupId) are used from the enrollment result, an empty include is appropriate.

apps/web/scripts/migrations/backfill-click-commissions.ts (1)

12-19: LGTM!

The migration from includeClickReward: true to include: { links: true, clickReward: true } correctly adapts to the new function signature. The included relations are appropriately used in the script (line 21 for reward determination and line 26 for link lookup).

apps/web/vercel.json (2)

16-17: LGTM!

The path rename from /api/cron/usage/update-workspace-clicks to /api/cron/streams/update-workspace-clicks aligns with the PR's shift to streams-based architecture for partner activity processing.


19-22: LGTM!

The new partner-stats cron running every 5 minutes (*/5 * * * *) appropriately supports the PR's goal of syncing partner link statistics to the ProgramEnrollment table. The frequency balances timely updates with system load.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1)

24-37: LGTM! Clean refactoring to structured include pattern.

The change from a boolean includeGroup flag to a structured include object improves clarity and aligns with the standardized pattern used across the codebase. The destructuring of partnerGroup to group maintains backward compatibility with downstream usage.

apps/web/app/(ee)/api/track/open/route.ts (1)

117-118: LGTM! Enhanced click tracking with program context.

Adding programId and partnerId from the cached link provides necessary context for downstream partner activity tracking and stats synchronization. This aligns with the broader partner link statistics infrastructure being introduced.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx (1)

4-52: LGTM! Clean migration to total-prefixed stats fields.

The updates consistently migrate from the original field names (clicks, leads, conversions, saleAmount) to the new total* prefixed fields, while adding support for totalCommissions and netRevenue. The formatting logic and NaN handling remain unchanged.

apps/web/lib/partners/determine-partner-reward.ts (1)

16-22: Verify callers include required links field

The links property on ProgramEnrollmentWithReward is now required (links: Link[] | null). Ensure every call to determinePartnerReward (e.g. in apps/web/scripts/*, create-partner-commission.ts, API routes under apps/web/lib/api, and app routes under apps/web/app) explicitly passes a links field—even if null.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (1)

10-22: LGTM!

The migration to the unified include object pattern is consistent with the broader PR changes and correctly includes all necessary related data (program, partner, links, rewards, partnerGroup).

apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1)

172-181: LGTM! SQL injection risk properly mitigated.

The UPDATE statement is now correctly parameterized with SET before WHERE, and all values are passed as bound parameters. This addresses the SQL injection and syntax issues flagged in previous reviews.

apps/web/lib/tinybird/record-click.ts (1)

217-230: LGTM! Partner activity event publishing with robust fallback.

The implementation correctly publishes partner activity events with a database fallback if the stream publish fails. The pattern is consistent with the workspace usage increment above, and the parameterized SQL prevents injection risks.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts (1)

11-18: LGTM!

The migration to include: { program: true, links: true } is consistent with the broader PR pattern and correctly provides the necessary data for downstream analytics processing.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts (1)

8-14: LGTM!

The addition of include: { program: true } is appropriate for this endpoint and aligns with the broader PR pattern of using unified include objects.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1)

11-19: LGTM!

The migration to include: { program: true, links: true } correctly provides the necessary data for bounty processing and partner stats aggregation.

apps/web/lib/actions/partners/update-partner-enrollment.ts (1)

31-33: LGTM! Clean refactor to generalized include pattern.

The change from includePartner: true to include: { partner: true } aligns with the broader refactoring effort across the codebase. This pattern provides better flexibility for including multiple relations without proliferating boolean flags.

apps/web/lib/tolt/import-commissions.ts (1)

380-384: LGTM! Partner stats sync integrated consistently.

The syncPartnerLinksStats call is appropriately added in parallel with other commission-related operations. This ensures partner enrollment statistics stay synchronized with sale events during the import flow.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1)

17-20: LGTM! Enriches enrollment data for downstream use.

The include object fetches program and links data, which are used later in the handler for query construction and response formatting (lines 40, 109-112).

apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1)

18-21: LGTM! Include pattern supports downstream operations.

The program and links data are appropriately used later in the handler (lines 30, 39) for validation and event retrieval.

apps/web/lib/rewardful/import-commissions.ts (1)

373-377: LGTM! Consistent partner stats synchronization.

The syncPartnerLinksStats call follows the same pattern as the Tolt imports, ensuring partner enrollment metrics remain in sync during commission imports.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1)

26-28: LGTM! Program data correctly included.

The program object is used downstream for event retrieval (lines 54, 56), making this include necessary and appropriate.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1)

16-18: LGTM! Program inclusion supports query construction.

The program data is used for building the earnings count query (line 42), making this a logical addition.

apps/web/lib/api/partners/backfill-link-commissions.ts (1)

29-33: LGTM! Refactor to comprehensive include pattern.

The migration from includeSaleReward: true to a structured include object that fetches program, links, and saleReward aligns with the codebase-wide refactoring. All included data is utilized downstream (lines 36-41).

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1)

19-21: LGTM! Consistent refactor pattern.

The change from a boolean flag to a structured include object aligns with the broader refactoring effort across the codebase. The program object is properly destructured and used downstream.

apps/web/lib/actions/partners/archive-partner.ts (1)

23-23: LGTM! Appropriate use of empty include.

The empty include object is correct here since only the programEnrollment status is needed for the archive operation, with no related entities required.

apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

60-64: LGTM! Properly structured include object.

The include object correctly fetches program, partner, and links, which are all used throughout the workflow steps (default link creation, email notification, webhook payload).

apps/web/app/(ee)/api/track/click/route.ts (1)

139-140: LGTM! Proper context propagation.

Adding programId and partnerId to the click event correctly propagates partner/program context for downstream partner activity tracking and statistics synchronization.

apps/web/app/(ee)/api/bounties/route.ts (1)

41-43: LGTM! Consistent refactor pattern.

The structured include object is used correctly to fetch the program relation, which is accessed later for filtering bounties by group (lines 62-63).

apps/web/lib/partnerstack/import-commissions.ts (1)

366-370: LGTM! Correct stats synchronization.

The syncPartnerLinksStats call with eventType: "sale" is correct for the PartnerStack commission import flow and properly parallelized with other stat updates.

apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

456-460: LGTM! Correct stats synchronization.

The syncPartnerLinksStats call with eventType: "sale" is correct for the Stripe checkout completion webhook and properly parallelized with workflow execution.

apps/web/lib/tolt/import-customers.ts (1)

244-278: LGTM! Proper error tolerance and conditional stats sync.

The migration to Promise.allSettled ensures that a failure in one operation (recording lead, updating link stats, or syncing partner stats) doesn't block the others. The type guard for partnerId and programId correctly handles the case where partner links may not have these fields.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (2)

65-90: LGTM! Consistent migration to total-prefixed fields.

The column definitions, accessors, and default sort configuration have been updated consistently from legacy fields to total* prefixed fields. The column visibility key change to "partners-table-columns-v2" ensures a clean migration for existing users.

Note: A previous review comment flagged that EnrolledPartnerProps may need updating to include these fields. Ensure the type definition in apps/web/lib/zod/schemas/partners.ts includes all total* fields to prevent runtime errors.


306-315: Confirm backend support for netRevenue sorting
No netRevenue field exists in the Prisma schema—ensure the API computes and exposes sortable netRevenue before uncommenting it.

apps/web/lib/api/partners/sync-partner-links-stats.ts (1)

42-48: Verify undefined fallback for null aggregates.

The aggregate mapping uses ?? undefined instead of ?? 0. If all links for a partner are deleted or have null stats, the update operation will not reset these fields to zero, potentially leaving stale data.

Confirm this is intentional behavior. If stats should be zeroed when no data exists, use ?? 0:

       const partnerLinkStats = {
-        totalClicks: res._sum.clicks ?? undefined,
-        totalLeads: res._sum.leads ?? undefined,
-        totalConversions: res._sum.conversions ?? undefined,
-        totalSales: res._sum.sales ?? undefined,
-        totalSaleAmount: res._sum.saleAmount ?? undefined,
+        totalClicks: res._sum.clicks ?? 0,
+        totalLeads: res._sum.leads ?? 0,
+        totalConversions: res._sum.conversions ?? 0,
+        totalSales: res._sum.sales ?? 0,
+        totalSaleAmount: res._sum.saleAmount ?? 0,
       };
apps/web/lib/actions/partners/ban-partner.ts (1)

29-38: LGTM! Consolidates data fetching with include object.

The migration to the unified include object pattern removes the need for a separate Prisma query to fetch links. This simplifies the code and ensures all related data is loaded in a single query.

apps/web/lib/api/partners/get-partners.ts (1)

63-71: LGTM! Clean data mapping with computed netRevenue.

The response mapping correctly spreads partner and programEnrollment data, preserves the links relation, and computes netRevenue from the aggregated stats. The id and createdAt field assignments ensure consistency with the expected return type.

apps/web/lib/zod/schemas/partners.ts (1)

374-416: LGTM! Schema correctly defines total-prefixed stats fields.

The EnrolledPartnerSchema correctly defines all total* fields with appropriate defaults and descriptions. This resolves the concern raised in a previous review about missing type definitions for the partners table.

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: 2

♻️ Duplicate comments (1)
apps/web/lib/api/partners/get-partners.ts (1)

52-59: Case-insensitive search filtering still needs attention.

This is the same issue flagged in a previous review: search filters on id, name, and email (lines 54-56) lack mode: "insensitive", making them case-sensitive. Additionally, the email filter (line 59) is not properly wrapped in a conditional.

If the database is PostgreSQL (as suggested by the previous ILIKE reference), apply the previously suggested fix. However, if using MySQL with a case-insensitive collation (utf8_general_ci or utf8mb4_unicode_ci), the mode parameter may be unnecessary per the retrieved learning.

Based on learnings

🧹 Nitpick comments (1)
apps/web/lib/api/groups/get-groups.ts (1)

65-84: Minor: In-memory join may mask data inconsistencies.

The filter((group) => group !== null) at line 84 silently removes entries where the group isn't found. While this handles the case where a group is deleted between queries, it also masks the filter mismatch issue flagged earlier.

After fixing the filter mismatch, consider logging or monitoring when nulls are filtered out, as it may indicate data integrity issues (orphaned program enrollments).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5affef5 and 1000d47.

📒 Files selected for processing (3)
  • apps/web/lib/api/groups/get-groups.ts (1 hunks)
  • apps/web/lib/api/partners/get-partners.ts (2 hunks)
  • apps/web/ui/partners/groups/group-selector.tsx (2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-14T04:46:08.721Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/(ee)/api/groups/count/route.ts:17-31
Timestamp: 2025-08-14T04:46:08.721Z
Learning: MySQL string comparisons using LIKE (which Prisma's `contains` maps to) are case-insensitive by default unless the column has a case-sensitive collation. Adding `mode: "insensitive"` to Prisma queries is unnecessary for MySQL databases with standard collations like utf8_general_ci or utf8mb4_unicode_ci.

Applied to files:

  • apps/web/lib/api/partners/get-partners.ts
🧬 Code graph analysis (1)
apps/web/lib/api/partners/get-partners.ts (1)
apps/web/lib/zod/schemas/partners.ts (1)
  • getPartnersQuerySchemaExtended (169-177)
⏰ 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 (4)
apps/web/ui/partners/groups/group-selector.tsx (1)

78-85: LGTM! Improved loading state UX with safer rendering.

The variant-driven skeleton loaders provide better visual feedback during loading states, and the consistent use of optional chaining (selectedOption?.icon, selectedOption?.label) safely handles null states without errors. The empty placeholder for the header variant is a reasonable design choice to maintain a cleaner header appearance.

Also applies to: 121-125

apps/web/lib/api/groups/get-groups.ts (1)

52-59: No additional sortBy validation neededsortBy is already constrained by a Zod enum in getGroupsQuerySchema and parsed before use.

apps/web/lib/api/partners/get-partners.ts (2)

80-88: Response mapping looks correct.

The spread order and explicit field assignments ensure the correct shape. The netRevenue calculation is straightforward and the createdAt type coercion is appropriate.


10-19: No action needed: secondarySortColumnMap is safe
The sortBy enum in getPartnersQuerySchema only allows keys present in secondarySortColumnMap, so no undefined lookup can occur.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 12, 2025

✅ Actions performed

Full review triggered.

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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1000d47 and c713d73.

📒 Files selected for processing (4)
  • apps/web/lib/api/conversions/track-lead.ts (2 hunks)
  • apps/web/lib/api/groups/get-groups.ts (1 hunks)
  • apps/web/lib/upstash/redis-streams.ts (1 hunks)
  • apps/web/lib/zod/schemas/groups.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/zod/schemas/groups.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/api/conversions/track-lead.ts (2)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • executeWorkflows (16-91)
apps/web/lib/api/partners/sync-partner-links-stats.ts (1)
  • syncPartnerLinksStats (5-62)
apps/web/lib/upstash/redis-streams.ts (1)
apps/web/lib/upstash/redis.ts (1)
  • redis (4-7)
⏰ 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 (4)
apps/web/lib/upstash/redis-streams.ts (4)

152-164: LGTM!

The refactoring simplifies the object construction while preserving the existing error handling. The change makes the code cleaner and more maintainable.


165-177: LGTM!

The partner activity stream setup follows the same pattern as the workspace usage stream. The PartnerActivityEvent interface is well-defined with clear field types and an appropriate union type for eventType.


178-193: LGTM! Previous issue resolved.

The error handling has been successfully added to publishPartnerActivityEvent, mirroring the structure of publishClickEvent. The function now properly handles Redis failures with try-catch, logging errors before re-throwing them.

This addresses the critical issue raised in the previous review.


141-145: Localizing WORKSPACE_USAGE_UPDATES_STREAM_KEY is safe
Ripgrep found no references outside this file—removing its export does not break any imports.

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: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/lib/rewardful/import-customers.ts (1)

224-253: Surface partner stats sync failures

Promise.allSettled lets recordLeadWithTimestamp, the link update, and the new syncPartnerLinksStats fail silently. When stats sync rejects we never notice, leaving enrollments stale. Capture the results (or switch to Promise.all) and at least log/throw on any rejected item so we don’t mute real failures.

-  await Promise.allSettled([
+  const results = await Promise.allSettled([
     recordLeadWithTimestamp({
       ...clickEvent,
       event_id: nanoid(16),
       event_name: "Sign up",
       customer_id: customerId,
       timestamp: new Date(referral.became_lead_at).toISOString(),
     }),
@@
     ...(link.partnerId && link.programId
       ? [
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
         ]
       : []),
   ]);
+  for (const result of results) {
+    if (result.status === "rejected") {
+      console.error(
+        "Rewardful customer import side effect failed",
+        { linkId: link.id, partnerId: link.partnerId, programId: link.programId },
+        result.reason,
+      );
+      throw result.reason;
+    }
+  }
apps/web/lib/tolt/import-customers.ts (1)

244-278: Don’t swallow critical import errors

Switching from Promise.all to Promise.allSettled makes failures in recordLeadWithTimestamp or prisma.link.update resolve silently, so the outer try/catch never logs or retries. Previously these failures bubbled and aborted the customer import, which is the safer behaviour. Restore the fail-fast path and append the stats sync separately (or wrap just that call) so lead recording and link mutations still throw when they break.

-    await Promise.allSettled([
-      recordLeadWithTimestamp({
+    await Promise.all([
+      recordLeadWithTimestamp({
         ...clickEvent,
         event_id: nanoid(16),
         event_name: "Sign up",
         customer_id: customerId,
         timestamp: new Date(customer.created_at).toISOString(),
       }),
 
       prisma.link.update({
         where: {
           id: link.id,
         },
         data: {
           leads: {
             increment: 1,
           },
           lastLeadAt: updateLinkStatsForImporter({
             currentTimestamp: link.lastLeadAt,
             newTimestamp: latestLeadAt,
           }),
         },
       }),
 
       // partner links should always have a partnerId and programId, but we're doing this to make TS happy
       ...(link.partnerId && link.programId
         ? [
             syncPartnerLinksStats({
               partnerId: link.partnerId,
               programId: link.programId,
               eventType: "lead",
             }),
           ]
         : []),
-    ]);
+    ]);
♻️ Duplicate comments (13)
apps/web/scripts/partners/aggregate-stats.ts (2)

116-124: Replace .map() with .forEach().

Line 116 uses .map() but the return value is discarded. Since you're mutating programEnrollmentsToUpdate for side effects, .forEach() is more appropriate and clearly expresses intent.

Apply this diff:

-    partnerLinkStats.map((p) => {
+    partnerLinkStats.forEach((p) => {
       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
         totalClicks: p._sum.clicks ?? undefined,
         totalLeads: p._sum.leads ?? undefined,
         totalConversions: p._sum.conversions ?? undefined,
         totalSales: p._sum.sales ?? undefined,
         totalSaleAmount: p._sum.saleAmount ?? undefined,
       };
     });

150-155: Replace .map() with .forEach().

Line 150 uses .map() but the return value is discarded. Since you're mutating programEnrollmentsToUpdate for side effects, .forEach() is more appropriate.

Apply this diff:

-    partnerCommissionStats.map((p) => {
+    partnerCommissionStats.forEach((p) => {
       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
         ...programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`],
         totalCommissions: p._sum.earnings ?? undefined,
       };
     });
apps/web/scripts/partners/aggregate-stats-seeding.ts (2)

47-64: Event type mismatch persists.

The code still publishes only "click" events despite aggregating multiple activity types (clicks, leads, conversions, sales). This was flagged in a previous review and remains unaddressed.


67-67: Missing error handling and cleanup.

The script still lacks error handling and Prisma connection cleanup, as flagged in a previous review.

apps/web/lib/api/partners/get-partners.ts (1)

48-62: Case-insensitive search still missing.

The partner search and email filters still lack mode: "insensitive", making them case-sensitive on databases without case-insensitive collations. This was flagged in a previous review and remains unaddressed.

apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)

17-35: Caller's nested include args still being overwritten.

The code still overwrites any nested include, select, or orderBy that callers supply for links and discountCodes. When a caller passes include.links = { include: { tags: true }, orderBy: {...} }, those nested fields are lost. This was flagged in a previous review with a detailed fix suggestion and remains unaddressed.

apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)

40-40: Empty include object.

Same as in message-program.ts, the empty include: {} might be unnecessary if the function has defaults. Consider omitting if not needed.

apps/web/scripts/cal/backfill-referral-links.ts (1)

91-93: Known issue: API response missing total-prefixed metrics.

This mapping expects totalClicks, totalLeads, and totalSales from createAndEnrollPartner, but the function's return type doesn't include these fields. This issue was previously identified and remains unresolved.

apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1)

140-149: Deduplicate partner/program pairs and add error handling.

This issue was previously flagged and remains unresolved. The loop calls syncTotalCommissions for every commission without deduplicating partnerId/programId pairs, leading to:

  1. Redundant syncs: Multiple calls for the same partner/program combination
  2. No error isolation: If one sync fails, the entire cron fails
  3. Misleading log: Reports commission count instead of unique partner count

Recommended solution (from previous review):

-    for (const { partnerId, programId } of commissionsToCreate) {
-      await syncTotalCommissions({
-        partnerId,
-        programId,
-      });
-    }
-    console.log(
-      `Synced total commissions count for ${commissionsToCreate.length} partners`,
-    );
+    // Deduplicate partner/program pairs
+    const uniquePairs = Array.from(
+      new Map(
+        commissionsToCreate.map(({ partnerId, programId }) => [
+          `${partnerId}-${programId}`,
+          { partnerId, programId },
+        ]),
+      ).values(),
+    );
+
+    // Sync in parallel with error handling
+    const results = await Promise.allSettled(
+      uniquePairs.map(({ partnerId, programId }) =>
+        syncTotalCommissions({ partnerId, programId }),
+      ),
+    );
+
+    const failures = results.filter((r) => r.status === "rejected");
+    if (failures.length > 0) {
+      await log({
+        message: `Failed to sync commissions for ${failures.length}/${uniquePairs.length} partners`,
+        type: "errors",
+        mention: true,
+      });
+    }
+
+    console.log(
+      `Synced total commissions count for ${uniquePairs.length} partners`,
+    );
apps/web/lib/integrations/shopify/create-sale.ts (1)

157-174: Consider logging failures from settled promises.

Same concern as in track-sale.ts: Promise.allSettled results are not inspected, so failures in either operation will be silent. Consider adding error logging as recommended in the lead/sale tracking paths.

apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (1)

235-252: Consider logging failures from settled promises.

Same concern as in other files: Promise.allSettled results are not inspected. Consider adding error logging for failed operations.

apps/web/app/(ee)/api/commissions/[commissionId]/route.ts (1)

88-95: CRITICAL: Include program relation to prevent runtime errors.

The past review comment is still valid. getProgramEnrollmentOrThrow requires the program relation to be included, otherwise it will throw an error. Without program: true, this endpoint will return 500 errors even for valid requests.

       include: {
+        program: true,
         links: true,
         saleReward: true,
       },
apps/web/ui/partners/partner-info-stats.tsx (1)

38-43: Coalesce totalSaleAmount before dividing

Line 41 still divides partner.totalSaleAmount directly. When that field is null/undefined, Number.isNaN(partner.totalSaleAmount) returns false, so we pass NaN into currencyFormatter and render “NaN”. Align with the other components and coalesce to 0 before dividing.

-          : currencyFormatter(partner.totalSaleAmount / 100, {
+          : currencyFormatter((partner.totalSaleAmount ?? 0) / 100, {
             trailingZeroDisplay: "stripIfInteger",
           }),
🧹 Nitpick comments (20)
apps/web/scripts/migrations/backfill-click-commissions.ts (1)

15-18: Consider using the included links data.

The change now includes links: true in the programEnrollment fetch, but the separate link query on lines 26-31 still executes. Since programEnrollment is already filtered by programId and partnerId, you could refactor to use programEnrollment.links?.[0]?.id at line 64 instead of the separate query, eliminating the redundancy.

Apply this diff to use the included links:

   const programEnrollment = await getProgramEnrollmentOrThrow({
     partnerId,
     programId,
     include: {
       links: true,
       clickReward: true,
     },
   });

   const reward = determinePartnerReward({
     event: "click",
     programEnrollment,
   });

-  const link = await prisma.link.findFirst({
-    where: {
-      programId,
-      partnerId,
-    },
-  });
+  const link = programEnrollment.links?.[0];

   if (!reward) {

Alternatively, if the included links are not needed elsewhere, you could remove links: true from the include object and keep the separate query.

apps/web/scripts/partners/aggregate-stats.ts (5)

66-76: Consider more specific typing.

The type annotation Record<string, string[]> at line 75 could be more precise since the keys are always "linkStats" and "commissionStats":

const programEnrollmentActivity = entries.reduce(
  (acc, entry) => {
    const { programId, partnerId, eventType } = entry.data;
    const key = eventType === "commission" ? "commissionStats" : "linkStats";
    const eventTypesSet = new Set(acc[key]);
    eventTypesSet.add(`${programId}:${partnerId}`);
    acc[key] = Array.from(eventTypesSet);
    return acc;
  },
  { linkStats: [], commissionStats: [] } as {
    linkStats: string[];
    commissionStats: string[];
  },
);

91-96: Reduce redundant string splitting.

Lines 91-96 split each composite key twice (once for programIds, once for partnerIds). Consider splitting once:

-    const programIds = programEnrollmentActivity.linkStats.map(
-      (p) => p.split(":")[0],
-    );
-    const partnerIds = programEnrollmentActivity.linkStats.map(
-      (p) => p.split(":")[1],
-    );
+    const pairs = programEnrollmentActivity.linkStats.map((p) => p.split(":"));
+    const programIds = pairs.map(([programId]) => programId);
+    const partnerIds = pairs.map(([, partnerId]) => partnerId);

118-122: Remove redundant ?? undefined operators.

Using ?? undefined is redundant since nullish coalescing already handles null/undefined by returning the right-hand operand. Simply omit these properties when the value is null/undefined, or use value ?? 0 if you want explicit zero defaults.

       programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-        totalClicks: p._sum.clicks ?? undefined,
-        totalLeads: p._sum.leads ?? undefined,
-        totalConversions: p._sum.conversions ?? undefined,
-        totalSales: p._sum.sales ?? undefined,
-        totalSaleAmount: p._sum.saleAmount ?? undefined,
+        ...(p._sum.clicks !== null && { totalClicks: p._sum.clicks }),
+        ...(p._sum.leads !== null && { totalLeads: p._sum.leads }),
+        ...(p._sum.conversions !== null && { totalConversions: p._sum.conversions }),
+        ...(p._sum.sales !== null && { totalSales: p._sum.sales }),
+        ...(p._sum.saleAmount !== null && { totalSaleAmount: p._sum.saleAmount }),
       };

128-133: Extract shared logic for splitting composite keys.

Lines 128-133 duplicate the pattern from lines 91-96. Consider extracting a helper function:

Add this helper at the top of the main function:

function extractProgramAndPartnerIds(compositeKeys: string[]) {
  const pairs = compositeKeys.map((key) => key.split(":"));
  return {
    programIds: pairs.map(([programId]) => programId),
    partnerIds: pairs.map(([, partnerId]) => partnerId),
  };
}

Then use it:

if (programEnrollmentActivity.linkStats.length > 0) {
  const { programIds, partnerIds } = extractProgramAndPartnerIds(
    programEnrollmentActivity.linkStats,
  );
  // ... rest of linkStats processing
}

if (programEnrollmentActivity.commissionStats.length > 0) {
  const { programIds, partnerIds } = extractProgramAndPartnerIds(
    programEnrollmentActivity.commissionStats,
  );
  // ... rest of commissionStats processing
}

7-64: timestamp format: timestamp is typed as string, so "2021-01-01" is valid; for production, use full ISO 8601 strings (e.g., "2021-01-01T00:00:00Z").

apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (3)

79-83: Remove redundant nullish coalescing to undefined.

The ?? undefined pattern is redundant—null or undefined both remain undefined when coalescing to undefined. You can simplify or remove this if the goal is to exclude null values (in which case, keep ?? undefined but add a comment explaining why).

Apply this diff if you want to simplify:

           programEnrollmentsToUpdate[`${p.programId}:${p.partnerId}`] = {
-            totalClicks: p._sum.clicks ?? undefined,
-            totalLeads: p._sum.leads ?? undefined,
-            totalConversions: p._sum.conversions ?? undefined,
-            totalSales: p._sum.sales ?? undefined,
-            totalSaleAmount: p._sum.saleAmount ?? undefined,
+            totalClicks: p._sum.clicks || undefined,
+            totalLeads: p._sum.leads || undefined,
+            totalConversions: p._sum.conversions || undefined,
+            totalSales: p._sum.sales || undefined,
+            totalSaleAmount: p._sum.saleAmount || undefined,
           };

Or keep ?? undefined and add a comment if you specifically need to convert null to undefined for downstream processing.


52-57: Extract duplicate ID extraction logic.

Lines 52-57 and 89-94 contain identical logic for extracting programIds and partnerIds from activity keys. Consider extracting this into a helper function to reduce duplication.

Add a helper function:

function extractIds(keys: string[]): { programIds: string[]; partnerIds: string[] } {
  return {
    programIds: keys.map((k) => k.split(":")[0]),
    partnerIds: keys.map((k) => k.split(":")[1]),
  };
}

Then use it:

       if (programEnrollmentActivity.linkStats.length > 0) {
-        const programIds = programEnrollmentActivity.linkStats.map(
-          (p) => p.split(":")[0],
-        );
-        const partnerIds = programEnrollmentActivity.linkStats.map(
-          (p) => p.split(":")[1],
-        );
+        const { programIds, partnerIds } = extractIds(
+          programEnrollmentActivity.linkStats,
+        );
         // ... rest of the code
       }

       if (programEnrollmentActivity.commissionStats.length > 0) {
-        const programIds = programEnrollmentActivity.commissionStats.map(
-          (p) => p.split(":")[0],
-        );
-        const partnerIds = programEnrollmentActivity.commissionStats.map(
-          (p) => p.split(":")[1],
-        );
+        const { programIds, partnerIds } = extractIds(
+          programEnrollmentActivity.commissionStats,
+        );
         // ... rest of the code
       }

Also applies to: 89-94


127-127: Remove or conditionally disable console.table for production.

Line 127's console.table(programEnrollmentsToUpdateArray) can be expensive and verbose when processing large batches (up to 10,000 entries). Consider replacing it with a simple count log or wrapping it in a debug flag.

Apply this diff:

-      console.table(programEnrollmentsToUpdateArray);
+      console.log(
+        `Prepared ${programEnrollmentsToUpdateArray.length} program enrollments for update`,
+      );

Or conditionally enable for debugging:

-      console.table(programEnrollmentsToUpdateArray);
+      if (process.env.NODE_ENV === "development") {
+        console.table(programEnrollmentsToUpdateArray);
+      }
apps/web/lib/actions/partners/mark-program-messages-read.ts (1)

19-23: Consider removing unnecessary empty include object.

The include: {} parameter doesn't request any relations and may be unnecessary. If no additional data is needed beyond enrollment validation, you can omit the include parameter entirely to simplify the code.

Apply this diff if the include is not required:

 const { partnerId, programId } = await getProgramEnrollmentOrThrow({
   programId: programSlug,
   partnerId: partner.id,
-  include: {},
 });
apps/web/lib/actions/partners/message-program.ts (1)

25-25: Empty include object.

The include: {} parameter appears to be passed without any properties. If getProgramEnrollmentOrThrow has sensible defaults for empty includes, this is fine. Otherwise, consider omitting the parameter or clarifying the intent.

apps/web/app/(ee)/api/groups/route.ts (1)

27-32: Performance instrumentation added.

The console.time/timeEnd instrumentation will run in production. This appears to be an intentional pattern for monitoring performance (as seen in similar routes). Ensure this aligns with your logging and observability strategy.

apps/web/lib/rewardful/import-commissions.ts (1)

373-377: Consider resilient execution for stats synchronization.

The syncPartnerLinksStats call is included in Promise.all alongside critical operations (commission creation, sale recording). If stats synchronization fails, it will fail the entire commission creation flow.

If stats synchronization is a non-critical side-effect, consider using Promise.allSettled for it or moving it to a waitUntil block to prevent stats sync failures from blocking commission creation.

Example with Promise.allSettled:

-    await Promise.all([
+    const results = await Promise.allSettled([
       prisma.commission.create({...}),
       recordSaleWithTimestamp({...}),
       prisma.link.update({...}),
-      syncPartnerLinksStats({
-        partnerId: customerFound.link.partnerId,
-        programId: program.id,
-        eventType: "sale",
-      }),
       prisma.customer.update({...}),
     ]);
+
+    await Promise.all(
+      results.map(async (result) => {
+        if (result.status === 'rejected') {
+          throw result.reason;
+        }
+        return result.value;
+      })
+    );
+
+    // Run stats sync separately to avoid blocking
+    waitUntil(
+      syncPartnerLinksStats({
+        partnerId: customerFound.link.partnerId,
+        programId: program.id,
+        eventType: "sale",
+      })
+    );

Or move stats sync outside the critical path entirely using waitUntil.

apps/web/lib/tolt/import-commissions.ts (1)

316-401: Consider using Promise.allSettled for better fault tolerance.

The current Promise.all will fail the entire commission creation if any side-effect (including the new syncPartnerLinksStats call) throws an error. Since stats synchronization is non-critical, consider switching to Promise.allSettled to prevent stats sync failures from blocking commission creation.

Apply this diff:

-  await Promise.all([
+  await Promise.allSettled([
     prisma.commission.create({
       // ...
     }),
     // ... other operations
     syncPartnerLinksStats({
       partnerId: customerFound.link.partnerId,
       programId: program.id,
       eventType: "sale",
     }),
     // ... customer update
   ]);
apps/web/lib/partnerstack/import-commissions.ts (1)

305-386: Consider using Promise.allSettled for better fault tolerance.

Similar to other import flows, the Promise.all block will fail the entire commission creation if syncPartnerLinksStats throws an error. Since stats synchronization is a non-critical side-effect, consider switching to Promise.allSettled to isolate failures.

Apply this diff:

-  await Promise.all([
+  await Promise.allSettled([
     prisma.commission.create({
       // ...
     }),
     // ... other operations
     syncPartnerLinksStats({
       partnerId: customer.link.partnerId,
       programId: program.id,
       eventType: "sale",
     }),
     // ... customer update
   ]);
apps/web/lib/api/conversions/track-sale.ts (1)

371-387: Consider logging failures from settled promises.

Promise.allSettled allows both operations to complete independently, but the results are not inspected. If either executeWorkflows or syncPartnerLinksStats fails, the error will be silently swallowed. This could lead to data inconsistencies where workflows execute but stats don't sync, or vice versa.

Consider adding basic error logging to detect failures:

-        await Promise.allSettled([
+        const results = await Promise.allSettled([
           executeWorkflows({
             trigger: WorkflowTrigger.leadRecorded,
             context: {
               programId: link.programId,
               partnerId: link.partnerId,
               current: {
                 leads: 1,
               },
             },
           }),
           syncPartnerLinksStats({
             partnerId: link.partnerId,
             programId: link.programId,
             eventType: "lead",
           }),
         ]);
+        
+        results.forEach((result, index) => {
+          if (result.status === "rejected") {
+            const operation = index === 0 ? "executeWorkflows" : "syncPartnerLinksStats";
+            console.error(`[trackLead] ${operation} failed:`, result.reason);
+          }
+        });

This pattern applies to the sale path at lines 551-568 as well.

apps/web/lib/firstpromoter/import-commissions.ts (1)

373-377: Good addition to existing pattern.

The syncPartnerLinksStats call is appropriately added to the existing Promise.allSettled block, maintaining consistency with the parallel execution pattern. The same error logging recommendation from other files applies here as well.

apps/web/app/(ee)/api/partners/route.ts (2)

21-26: Remove console timing or use structured logging.

console.time and console.timeEnd will clutter production logs and aren't structured. Consider using a proper logging framework or removing this instrumentation after debugging.


28-50: Simplify backward compatibility mapping.

The Zod schema definition with .default(0) followed by .parse() is overly complex for a simple field mapping. Consider a more direct approach:

-    // polyfill deprecated fields for backward compatibility
-    return NextResponse.json(
-      z
-        .array(
-          EnrolledPartnerSchema.extend({
-            clicks: z.number().default(0),
-            leads: z.number().default(0),
-            conversions: z.number().default(0),
-            sales: z.number().default(0),
-            saleAmount: z.number().default(0),
-          }),
-        )
-        .parse(
-          partners.map((partner) => ({
-            ...partner,
-            clicks: partner.totalClicks,
-            leads: partner.totalLeads,
-            conversions: partner.totalConversions,
-            sales: partner.totalSales,
-            saleAmount: partner.totalSaleAmount,
-          })),
-        ),
-    );
+    // polyfill deprecated fields for backward compatibility
+    return NextResponse.json(
+      partners.map((partner) => ({
+        ...partner,
+        clicks: partner.totalClicks ?? 0,
+        leads: partner.totalLeads ?? 0,
+        conversions: partner.totalConversions ?? 0,
+        sales: partner.totalSales ?? 0,
+        saleAmount: partner.totalSaleAmount ?? 0,
+      })),
+    );

This achieves the same result with less overhead and is more readable.

apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (1)

139-157: Good use of conditional spread for parallel execution.

The conditional spread pattern cleanly includes both executeWorkflows and syncPartnerLinksStats only when programId and partnerId are present. This maintains the parallel execution pattern while keeping the code readable.

Same error logging recommendation applies here as in other files using Promise.allSettled.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eaec736 and c713d73.

📒 Files selected for processing (78)
  • apps/web/app/(ee)/api/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/commissions/[commissionId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (1 hunks)
  • apps/web/app/(ee)/api/discount-codes/route.ts (2 hunks)
  • apps/web/app/(ee)/api/groups/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (2 hunks)
  • apps/web/app/(ee)/api/partners/export/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts (2 hunks)
  • apps/web/app/(ee)/api/track/click/route.ts (1 hunks)
  • apps/web/app/(ee)/api/track/open/route.ts (1 hunks)
  • apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1 hunks)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (4 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (6 hunks)
  • apps/web/lib/actions/partners/archive-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/create-clawback.ts (1 hunks)
  • apps/web/lib/actions/partners/create-manual-commission.ts (3 hunks)
  • apps/web/lib/actions/partners/create-partner-comment.ts (1 hunks)
  • apps/web/lib/actions/partners/deactivate-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/mark-program-messages-read.ts (1 hunks)
  • apps/web/lib/actions/partners/message-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/message-program.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-enrollment.ts (1 hunks)
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1 hunks)
  • apps/web/lib/api/conversions/track-lead.ts (2 hunks)
  • apps/web/lib/api/conversions/track-sale.ts (3 hunks)
  • apps/web/lib/api/groups/get-groups.ts (1 hunks)
  • apps/web/lib/api/partners/backfill-link-commissions.ts (1 hunks)
  • apps/web/lib/api/partners/get-partner-for-program.ts (1 hunks)
  • apps/web/lib/api/partners/get-partners.ts (2 hunks)
  • apps/web/lib/api/partners/sync-partner-links-stats.ts (1 hunks)
  • apps/web/lib/api/partners/sync-total-commissions.ts (2 hunks)
  • apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (3 hunks)
  • apps/web/lib/firstpromoter/import-commissions.ts (2 hunks)
  • apps/web/lib/firstpromoter/import-customers.ts (4 hunks)
  • apps/web/lib/integrations/shopify/create-sale.ts (2 hunks)
  • apps/web/lib/middleware/link.ts (7 hunks)
  • apps/web/lib/partners/create-partner-commission.ts (3 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (1 hunks)
  • apps/web/lib/partnerstack/import-commissions.ts (2 hunks)
  • apps/web/lib/partnerstack/import-customers.ts (4 hunks)
  • apps/web/lib/rewardful/import-commissions.ts (2 hunks)
  • apps/web/lib/rewardful/import-customers.ts (3 hunks)
  • apps/web/lib/tinybird/record-click.ts (5 hunks)
  • apps/web/lib/tolt/import-commissions.ts (2 hunks)
  • apps/web/lib/tolt/import-customers.ts (5 hunks)
  • apps/web/lib/upstash/redis-streams.ts (1 hunks)
  • apps/web/lib/zod/schemas/groups.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (2 hunks)
  • apps/web/scripts/cal/backfill-referral-links.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-click-commissions.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats-seeding.ts (1 hunks)
  • apps/web/scripts/partners/aggregate-stats.ts (1 hunks)
  • apps/web/scripts/tella/update-commissions.ts (1 hunks)
  • apps/web/tests/partners/resource.ts (1 hunks)
  • apps/web/ui/links/link-builder/link-partner-details.tsx (1 hunks)
  • apps/web/ui/modals/delete-group-modal.tsx (4 hunks)
  • apps/web/ui/partners/groups/group-selector.tsx (2 hunks)
  • apps/web/ui/partners/partner-info-stats.tsx (1 hunks)
  • apps/web/vercel.json (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx
  • apps/web/ui/partners/partner-info-stats.tsx
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/rewardful/import-customers.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/message-program.ts
📚 Learning: 2025-08-14T04:46:08.721Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/(ee)/api/groups/count/route.ts:17-31
Timestamp: 2025-08-14T04:46:08.721Z
Learning: MySQL string comparisons using LIKE (which Prisma's `contains` maps to) are case-insensitive by default unless the column has a case-sensitive collation. Adding `mode: "insensitive"` to Prisma queries is unnecessary for MySQL databases with standard collations like utf8_general_ci or utf8mb4_unicode_ci.

Applied to files:

  • apps/web/lib/api/partners/get-partners.ts
🔇 Additional comments (63)
apps/web/tests/partners/resource.ts (1)

30-30: LGTM! Consistent with existing pattern.

The addition of bannedAt as a nullish date field is consistent with other optional date fields in the schema (e.g., payoutsEnabledAt, websiteVerifiedAt). The field placement and naming convention align with the existing pattern.

apps/web/lib/actions/partners/deactivate-partner.ts (2)

26-30: LGTM: Cleaner include pattern.

The refactoring to use a standard Prisma include object consolidates related data fetching and eliminates the need for a separate query later. This improves both performance and maintainability.


62-67: LGTM: Efficient data reuse.

Destructuring the included data avoids redundant queries. Using pre-transaction data for cleanup operations (cache expiration, deletion queueing, audit logging) is appropriate since the link/discount code identifiers remain unchanged.

apps/web/app/(ee)/api/discount-codes/route.ts (2)

28-30: Clean refactor to generic include pattern.

The change from a boolean flag to the generic include object is a good improvement that aligns with the broader refactoring in this PR.


71-80: Improved data retrieval pattern.

The consolidated include pattern with explicit destructuring is cleaner and more efficient than separate queries. The link lookup logic remains correct with proper null checks downstream.

apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts (4)

5-5: LGTM: Import supports type assertion below.

The import is used for the type assertion on line 100.


22-31: LGTM: Cleaner include pattern.

The refactor from multiple boolean flags to a single nested include object is a cleaner, more idiomatic Prisma pattern. All included relations are properly utilized later in the function.


60-60: LGTM: Improved variable naming.

Renaming to group improves clarity for the returned object structure.


100-100: Confirm inclusion of all PartnerGroupProps fields
PartnerGroupProps is defined as PartnerGroup & { additionalLinks: PartnerGroupAdditionalLink[] }. The object literal only has id, maxPartnerLinks, linkStructure, and additionalLinks—verify that PartnerGroup doesn’t require any other properties. Consider replacing as PartnerGroupProps with a satisfies PartnerGroupProps assertion to catch missing fields.

apps/web/vercel.json (2)

16-16: LGTM: Path migration to stream-based processing.

The path change from /api/cron/usage/update-workspace-clicks to /api/cron/streams/update-workspace-clicks aligns with the PR's architectural shift toward stream-based partner activity processing.


19-22: Verify cron overlap safeguards.

The 5-minute schedule for /api/cron/streams/update-partner-stats could overlap if a run exceeds 5 minutes; ensure Vercel prevents parallel executions or the handler implements locking/idempotency.

apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts (6)

1-13: LGTM: Imports and configuration.

The imports are appropriate, and the force-dynamic export ensures the route isn't statically optimized. The batch size of 10,000 is reasonable for high-frequency updates.


18-24: LGTM: Early return for empty batches.

Correctly handles the case when no entries are available to process.


26-37: LGTM: Activity aggregation logic.

The reduce operation correctly separates commission events from link-related events. The logic is sound.


165-167: Consider filtering both undefined and null values.

Line 166 only filters undefined values, but Prisma aggregations can return null for empty sums. If null should also be excluded from updates, add it to the filter condition.

             const finalStatsToUpdate = Object.entries(stats).filter(
-              ([_, value]) => value !== undefined,
+              ([_, value]) => value !== undefined && value !== null,
             );

Verify if null values from Prisma's _sum should be excluded from updates or if they should set columns to NULL.


228-261: LGTM: GET handler orchestration.

The handler correctly:

  • Verifies the Vercel cron signature for security
  • Processes the batch and handles empty results
  • Includes stream info for monitoring
  • Returns appropriate error responses

172-181: Handle missing ProgramEnrollment records in SQL UPDATE
The raw UPDATE (lines 172–181) doesn’t inspect affectedRows, so if no row exists for a given programId/partnerId, it silently does nothing. You could:

  • Check result.affectedRows and log a warning when it’s 0
  • Use an upsert (INSERT … ON DUPLICATE KEY UPDATE) or Prisma’s upsert
  • Leave as-is if missing enrollments are expected and safe to ignore
apps/web/lib/actions/partners/create-clawback.ts (1)

20-20: LGTM!

The migration from boolean flags to a structured include object aligns with the broader refactor pattern in this PR.

apps/web/app/(ee)/api/bounties/route.ts (1)

41-43: LGTM!

The refactor to use include: { program: true } is consistent with Prisma's standard include syntax and aligns with the broader refactor in this PR.

apps/web/lib/partners/determine-partner-reward.ts (1)

17-17: LGTM!

Making links required (while keeping it nullable) ensures callers explicitly provide link data, which aligns with the PR's partner-link stats synchronization requirements.

apps/web/lib/partners/create-partner-commission.ts (2)

82-88: LGTM!

The refactor to use conditional reward includes based on event type is a clean optimization that avoids fetching unnecessary reward data.


265-312: Eventual consistency approach for totalCommissions.

The webhook payload uses an optimistic totalCommissions calculation (line 267-268), while syncTotalCommissions reconciles the actual value asynchronously (lines 309-312). This means webhook payloads may reflect temporary values during concurrent commission creation. Verify that this eventual consistency trade-off is acceptable for your use case.

apps/web/lib/zod/schemas/partners.ts (1)

369-410: Breaking change: EnrolledPartnerSchema fields renamed.

The schema fields have been consistently renamed from clicks, leads, conversions, sales, saleAmount to totalClicks, totalLeads, totalConversions, totalSales, totalSaleAmount. This is a breaking change for API responses.

The renaming is consistent and well-documented in the field descriptions. Verify that:

  1. All frontend consumers of this API have been updated
  2. Any third-party integrations are notified of this change
  3. The totalCommissions field (line 399) follows the same naming convention
apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts (1)

11-18: LGTM! Consistent refactor to Prisma-style includes.

The change from boolean flags to a structured include object is a clean refactor that aligns with Prisma's native patterns. The code now explicitly requests program and links data, making the data requirements more transparent.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts (1)

13-19: LGTM! Proper use of include for downstream data.

The include: { program: true } correctly fetches the program relation, which is subsequently used on line 42 (programId: program.id).

apps/web/lib/actions/partners/update-partner-enrollment.ts (1)

28-34: LGTM! Partner data properly included for audit log.

The include: { partner: true } correctly fetches the partner relation, which is used in the audit log metadata on line 76.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts (1)

11-13: LGTM!

The include: { program: true } correctly fetches the program data needed to access program.resources at line 17.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts (1)

24-37: LGTM!

The migration from boolean flags to the include object pattern is clean. The destructuring correctly maps partnerGroup to group, maintaining consistency with the rest of the file where group is used throughout.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts (1)

19-21: LGTM!

The include: { program: true } correctly fetches program data used in the earnings query at line 50.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1)

17-20: LGTM!

The include: { program: true, links: true } correctly fetches both program data (used at lines 40, 59) and links data (used at lines 109-112 for timeseries grouping).

apps/web/lib/actions/partners/archive-partner.ts (1)

23-23: LGTM! API signature alignment.

The addition of the empty include object aligns with the refactored getProgramEnrollmentOrThrow API without changing behavior.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts (1)

18-21: LGTM! Efficient data fetching.

The eager loading of program and links via the include object is appropriate since both are used downstream (lines 30, 39, 40, 51).

apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts (1)

26-28: LGTM! Appropriate eager loading.

The include object fetches program data that is subsequently used on lines 54 and 56.

apps/web/lib/api/partners/backfill-link-commissions.ts (1)

29-33: LGTM! Comprehensive data fetching for commission calculation.

The include object appropriately fetches program, links, and saleReward data required for downstream commission calculation and reward determination.

apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

60-64: LGTM! Efficient workflow data preparation.

The include object fetches all related data (program, partner, links) needed throughout the workflow steps, reducing database round-trips.

apps/web/lib/api/conversions/track-lead.ts (2)

22-22: LGTM! Import for parallel stats synchronization.

The import enables parallel execution of workflow triggers and partner link stats synchronization.


319-335: LGTM! Appropriate parallelization pattern.

Using Promise.allSettled to run workflow execution and stats synchronization in parallel is appropriate. Both operations are side effects that can proceed independently, and using allSettled ensures one failure doesn't block the other.

The eventType: "lead" is correct for this context (previously flagged issue was addressed in commit c713d73).

apps/web/app/(ee)/api/track/open/route.ts (1)

117-118: LGTM! Enhanced click tracking with partner context.

Adding programId and partnerId to the click record enriches tracking data for partner-attributed links, supporting the partner stats synchronization functionality introduced in this PR.

apps/web/app/(ee)/api/track/click/route.ts (1)

132-145: LGTM!

The addition of programId and partnerId parameters to the recordClick call is consistent with the broader pattern of enriching click events with partner/program context.

apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)

443-477: LGTM!

Good use of Promise.allSettled within waitUntil for the new syncPartnerLinksStats call. This pattern ensures that stats synchronization failures won't block the main checkout flow or affect workflow execution.

apps/web/lib/middleware/link.ts (1)

274-288: LGTM!

The propagation of programId and partnerId through all recordClick call sites is consistent and complete. All code paths (root domain, custom URI, cloaking, iOS/Android redirects, geo-targeting, and default redirect) now include these fields.

Also applies to: 327-341, 367-381, 409-423, 480-494, 516-530, 552-566

apps/web/lib/actions/partners/create-bounty-submission.ts (1)

44-49: LGTM!

The migration to the generic include parameter with an empty object is correct. The code only accesses direct fields (status, groupId) from the enrollment, not relations, so no includes are needed.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (1)

103-106: LGTM!

The rename from partners to totalPartners is consistently applied across the column definition, accessor function, and sortable columns configuration, aligning with the broader schema migration.

Note: The netRevenue column is still displayed but commented out from sortableColumns. Consider removing it entirely or documenting when sorting will be re-enabled.

Also applies to: 164-170

apps/web/ui/modals/delete-group-modal.tsx (1)

13-13: LGTM! Consistent field rename.

The rename from partners to totalPartners is consistent with the broader schema changes and properly applied throughout the component.

Also applies to: 63-64, 76-76, 149-149

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (3)

72-89: LGTM! Consistent migration to total-prefixed metrics.

The column definitions correctly reference the new total* prefixed fields throughout both all and defaultVisible arrays. The changes align with the broader migration to total-prefixed metrics across the system.


146-146: Column visibility key change will reset user preferences.

Changing the key from "partners-table-columns" to "partners-table-columns-v2" will reset all users' column visibility preferences. This is appropriate given the metric name changes, as the old preferences would reference non-existent column IDs.


308-314: Verify when netRevenue sorting can be re-enabled.

The netRevenue column is visible but its sorting is commented out with a TODO. Since netRevenue is a computed field (totalSaleAmount - totalCommissions), sorting may require a database-level computed column or a different approach.

Please clarify:

  1. What blocker prevents netRevenue from being sortable currently?
  2. Is there a plan/timeline to re-enable this sorting capability?

If this requires backend support (e.g., adding a persisted computed column or materialized view), consider opening a tracking issue.

apps/web/lib/upstash/redis-streams.ts (2)

141-164: LGTM! Workspace usage stream refactored correctly.

The WORKSPACE_USAGE_UPDATES_STREAM_KEY is now properly encapsulated as non-exported, while the ClickEvent interface remains exported. The publishClickEvent function correctly publishes structured payloads with proper error handling.


166-193: LGTM! Partner activity stream implemented correctly.

The new partner activity stream is well-structured:

  • Clear interface definition with appropriate event types
  • Consistent error handling matching publishClickEvent
  • Proper stream key encapsulation
packages/prisma/schema/program.prisma (1)

99-106: LGTM! Total statistics fields properly defined.

The new total* fields are correctly defined:

  • Appropriate Int type for both counts and monetary values (in cents)
  • Safe default values of 0 for all fields
  • Clear inline comments documenting each field's purpose

No indexes are needed as these are aggregate fields typically updated in batch operations rather than queried individually.

apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (2)

13-21: LGTM! Include structure migrated to object-based pattern.

The migration from separate boolean flags to a structured include object improves type safety and aligns with the refactored getProgramEnrollmentOrThrow API.


36-36: LGTM! Group field properly exposed.

The group field is correctly mapped from programEnrollment.partnerGroup in the response payload, making group information available to partner profile consumers.

apps/web/lib/api/partners/sync-partner-links-stats.ts (2)

14-25: LGTM! Stream-first approach with proper fallback.

The function correctly attempts to publish to the partner activity stream first, then falls back to a direct database update on failure. The error logging provides clear context for debugging.


42-48: Verify Prisma's handling of undefined in update data.

The mapping uses ?? undefined for null values from aggregation. Prisma's behavior with undefined in update data is to skip those fields entirely (not set them to NULL). If the intent is to preserve existing values when there are no links, this is correct. However, if you want to explicitly set fields to 0 when no links exist, use:

 const partnerLinkStats = {
-  totalClicks: res._sum.clicks ?? undefined,
-  totalLeads: res._sum.leads ?? undefined,
-  totalConversions: res._sum.conversions ?? undefined,
-  totalSales: res._sum.sales ?? undefined,
-  totalSaleAmount: res._sum.saleAmount ?? undefined,
+  totalClicks: res._sum.clicks ?? 0,
+  totalLeads: res._sum.leads ?? 0,
+  totalConversions: res._sum.conversions ?? 0,
+  totalSales: res._sum.sales ?? 0,
+  totalSaleAmount: res._sum.saleAmount ?? 0,
 };

Please confirm the intended behavior when no links exist for a partner. Should the fields remain unchanged (current behavior with undefined) or be reset to 0?

apps/web/lib/actions/partners/ban-partner.ts (2)

32-37: LGTM! Include structure updated consistently.

The migration to the object-based include structure is consistent with other files in this PR and improves the function's efficiency by fetching all required data in a single query.


115-118: LGTM! Links correctly destructured from programEnrollment.

The links are now obtained directly from the included relations, eliminating the need for a separate database query. This improves performance while maintaining the same functionality.

apps/web/lib/api/partners/get-partner-for-program.ts (3)

10-27: LGTM! Migration to Prisma ORM improves maintainability.

The replacement of raw SQL with Prisma's type-safe query builder improves code maintainability and reduces the risk of SQL injection. The nested includes properly fetch all required relations in a single query.


38-39: LGTM! netRevenue calculation is correct for Int fields.

The arithmetic is safe because totalSaleAmount and totalCommissions are defined as Int in the Prisma schema (not Decimal). JavaScript number arithmetic works correctly here.

Note: A previous review comment flagged this as a Decimal arithmetic issue, but that was based on an incorrect assumption about the field types.


43-53: LGTM! Timestamp derivation logic is sound.

The lastLeadAt and lastConversionAt calculations correctly find the most recent timestamp across all links using a reduce operation with proper null handling and date comparison.

apps/web/lib/tinybird/record-click.ts (4)

22-25: LGTM! Partner activity event integration added.

The import of publishPartnerActivityEvent alongside publishClickEvent sets up the partner activity tracking capability.


40-41: LGTM! Function signature extended to support partner tracking.

The optional programId and partnerId parameters enable partner activity tracking when these values are available, without breaking existing call sites that don't provide them.

Also applies to: 56-57


217-230: LGTM! Partner activity event publishing follows established patterns.

The partner activity event publishing correctly:

  • Guards with programId && partnerId to only track when both are present
  • Publishes to the stream first, with SQL fallback on error
  • Updates totalClicks in ProgramEnrollment using the same compound key pattern
  • Maintains consistency with the workspace usage tracking approach

255-255: LGTM! Error tracking updated with new operation.

The error operation label array correctly includes "Program enrollment totalClicks increment" to track failures in the new partner activity path.

@steven-tey steven-tey merged commit 5086080 into main Oct 12, 2025
8 checks passed
@steven-tey steven-tey deleted the programenrollment-stats branch October 12, 2025 03:12
This was referenced Nov 3, 2025
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.

2 participants