+
Skip to content

Conversation

steven-tey
Copy link
Collaborator

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

Summary by CodeRabbit

  • New Features

    • Auto-sync of partner link stats on clicks, leads, and sales across integrations, imports, and webhooks.
    • Partner activity events and a partner-activity stream for near-real-time stats updates.
    • Scheduled stream-backed job to process and apply partner stats updates.
  • Refactor

    • UI and APIs standardized to total* metrics (totalClicks, totalLeads, totalConversions, totalSales, totalSaleAmount) with backward-compatible polyfills.
  • Chores

    • Parallelized background syncs for quicker workflow processing and improved resilience.

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 1:03am

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, PartnerActivityEvent type, partnerActivityStream, and publishPartnerActivityEvent; changed publishClickEvent payload shape; removed exported WORKSPACE_USAGE_UPDATES_STREAM_KEY.
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 prefers publishing PartnerActivity events 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
recordClick accepts optional programId/partnerId; when provided publishes partner activity "click" (with DB fallback). Routes/middleware thread programId/partnerId into recordClick.
Parallel workflows + stats sync
apps/web/lib/api/conversions/*, apps/web/lib/actions/partners/create-manual-commission.ts, apps/web/lib/integrations/shopify/create-sale.ts, apps/web/app/(ee)/api/stripe/integration/webhook/...
Replaced sequential executeWorkflows with Promise.allSettled to run executeWorkflows in parallel with syncPartnerLinksStats (eventType "lead"/"sale") across handlers.
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.allPromise.allSettled.
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; partners route timing; renamed public metric fields to total* and updated sort enums/defaults; response now includes total-prefixed fields.
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, and batch-update ProgramEnrollment; cron added at */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, and column-set 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
Compute local totalCommissions from enrollment + commission and move syncTotalCommissions into async/background (Promise.allSettled) alongside webhooks/notifications; removed timing instrumentation and adjusted logging.
getProgramEnrollmentOrThrow refactor
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
Replaced multiple boolean include flags with generic include: T extends Prisma.ProgramEnrollmentInclude; Prisma calls use finalInclude; return type adjusted to Prisma.ProgramEnrollmentGetPayload<{ include: T }>; simplified not-found handling.
Widespread callsite include updates
Multiple under apps/web/app/(ee)/api/..., apps/web/lib/actions/partners/*, scripts
Many call sites replaced boolean include flags with include: { ... } or added include: {} to align with 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 remove deprecated per-field sort enums; added bannedAt in test resource; CSV export now only ISO-converts when value is a Date.

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 0.00% 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 accurately and concisely summarizes the primary change of synchronizing partner link statistics into the ProgramEnrollment table, making it immediately clear what the pull request implements.
✨ 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

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

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

📒 Files selected for processing (8)
  • apps/web/app/(ee)/api/groups/route.ts (1 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/partners-table.tsx (6 hunks)
  • apps/web/lib/api/groups/get-groups.ts (1 hunks)
  • apps/web/lib/api/partners/get-partner-for-program.ts (1 hunks)
  • apps/web/lib/zod/schemas/groups.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (2 hunks)
  • apps/web/ui/modals/delete-group-modal.tsx (4 hunks)
✅ Files skipped from review due to trivial changes (1)
  • apps/web/app/(ee)/api/groups/route.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/zod/schemas/partners.ts
  • apps/web/lib/api/partners/get-partner-for-program.ts
🧰 Additional context used
🧬 Code graph analysis (2)
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/ui/modals/delete-group-modal.tsx (1)
apps/web/lib/types.ts (1)
  • GroupExtendedProps (590-590)
⏰ 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 (8)
apps/web/lib/api/groups/get-groups.ts (3)

56-74: LGTM! In-memory join is acceptable given the constraints.

The groups.find() approach (line 59) is O(n*m), but given the GROUPS_MAX_PAGE_SIZE of 100, this is acceptable for the expected data volumes. The null check (line 60) correctly handles edge cases where enrollments might reference deleted groups.


9-77: Well-structured refactoring to Prisma.

The shift from raw SQL to Prisma-based queries improves maintainability and type safety. The parallel query execution with Promise.all is efficient.


13-54: Confirm handling of groups with zero enrollments: the current programEnrollment.groupBy excludes groups lacking enrollments—verify this matches requirements.
Add error handling around Promise.all to report failures with context.

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

13-149: LGTM! Consistent field rename throughout.

All references to the partners field have been correctly updated to totalPartners, maintaining consistency with the broader schema changes across the PR.

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

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

Changing the storage key from "partners-table-columns" to "partners-table-columns-v2" will reset all users' column visibility preferences. This is appropriate given the column ID changes from non-prefixed to total* prefixed names, but be aware that users will need to reconfigure their table views.


228-314: LGTM! Consistent field updates throughout table columns.

All column definitions correctly reference the new total* prefixed fields. The decision to make netRevenue visible but non-sortable (line 314) is documented with a TODO comment, indicating this is a temporary limitation.

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

64-137: LGTM! Schema updates align with UI and data model changes.

The field rename from partners to totalPartners (line 64) and the removal of netRevenue from the sortBy enum (line 137) are consistent with the corresponding UI changes in the groups table and the broader shift to total* prefixed fields.

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

103-170: LGTM! Consistent field updates in groups table.

The column rename to totalPartners (lines 103-105) and the updated sortableColumns configuration (lines 164-170) maintain consistency with the partners table and schema changes. The temporary removal of netRevenue from sortable columns is appropriately documented with a TODO.


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

Comment on lines 227 to 240
// Get stream info for monitoring
const streamInfo = await workspaceUsageStream.getStreamInfo();
const response = {
success: true,
processed: totalProcessed,
errors: errors?.length || 0,
streamInfo,
message: `Successfully processed ${totalProcessed} workspace usage updates`,
};
console.log(response);

return NextResponse.json(response);
} catch (error) {
console.error("Failed to process workspace usage updates:", error);
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

Return partner stream info instead of workspace usage

This route is processing partner activity, but we fetch workspaceUsageStream.getStreamInfo() and log “workspace usage updates”. That reports the wrong stream and makes the response misleading. Please swap in partnerActivityStream.getStreamInfo() and align the log/message text so the telemetry reflects partner stats.

🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/usage/update-partner-stats/route.ts around lines
227 to 240, the response and logs incorrectly reference workspace usage and call
workspaceUsageStream.getStreamInfo(); replace that call with
partnerActivityStream.getStreamInfo() and update the response.message and
console.log/console.error text to refer to partner activity/partner stats so
telemetry and logs accurately reflect partner stream processing.

Comment on lines +319 to +335
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: "sale",
}),
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix incorrect eventType in syncPartnerLinksStats call.

Line 333 specifies eventType: "sale" in the track-lead flow, which is incorrect and will cause wrong statistics to be recorded. The eventType should be "lead" to match the context.

Apply this diff to fix the eventType:

             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: "sale",
+                eventType: "lead",
               }),
             ]);
📝 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
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: "sale",
}),
]);
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",
}),
]);
🤖 Prompt for AI Agents
In apps/web/lib/api/conversions/track-lead.ts around lines 319 to 335, the call
to syncPartnerLinksStats incorrectly uses eventType: "sale" in the track-lead
flow; change that property to eventType: "lead" so the partner link statistics
are recorded as a lead event instead of a sale. Update the object passed to
syncPartnerLinksStats accordingly and keep the surrounding Promise.allSettled
structure intact.

Comment on lines +263 to 265
totalCommissions:
programEnrollment.totalCommissions + commission.earnings,
});
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

Potential race condition with concurrent commission creation.

Computing totalCommissions locally without synchronization could lead to incorrect values when multiple commissions are created concurrently for the same partner. For example:

  1. Thread A reads programEnrollment.totalCommissions = 100, computes 100 + 50 = 150
  2. Thread B reads programEnrollment.totalCommissions = 100, computes 100 + 30 = 130
  3. Both threads emit webhooks with different totals, and eventual sync may produce yet another value

While the async syncTotalCommissions will eventually reconcile the value, the webhook payload reflects an optimistic calculation that might not match the final database state.

Consider one of these approaches:

  1. Accept eventual consistency: Document that webhook payloads reflect optimistic totals that will be reconciled asynchronously
  2. Add locking: Use database-level optimistic locking (version field) or distributed locks to serialize updates
  3. Defer webhook emission: Wait for syncTotalCommissions to complete before sending the webhook (trades latency for consistency)
🤖 Prompt for AI Agents
In apps/web/lib/partners/create-partner-commission.ts around lines 263-265, the
code computes totalCommissions locally then emits a webhook, causing a race when
multiple commissions are created concurrently; instead perform an atomic DB
update that increments totalCommissions and returns the new value (e.g., UPDATE
... SET totalCommissions = totalCommissions + :amount RETURNING
totalCommissions) and use that returned value in the webhook payload so the
webhook reflects the actual persisted total; if atomic DB increments are not
possible in your DB layer, implement optimistic locking (version check) or defer
webhook emission until syncTotalCommissions completes—pick one approach and wire
the webhook to the authoritative post-update value.

Comment on lines +178 to +188
export const publishPartnerActivityEvent = async (
event: PartnerActivityEvent,
) => {
const { programId, partnerId, timestamp, eventType } = event;
return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, "*", {
programId,
partnerId,
timestamp,
eventType,
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Add error handling to publishPartnerActivityEvent.

The publishPartnerActivityEvent function lacks error handling (try-catch block), while publishClickEvent (lines 152-164) includes it. This inconsistency means partner activity events will throw unhandled exceptions on Redis failures, potentially breaking caller flows.

Apply this diff to add error handling:

 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;
+  }
 };
📝 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
export const publishPartnerActivityEvent = async (
event: PartnerActivityEvent,
) => {
const { programId, partnerId, timestamp, eventType } = event;
return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, "*", {
programId,
partnerId,
timestamp,
eventType,
});
};
export const publishPartnerActivityEvent = async (
event: PartnerActivityEvent,
) => {
const { programId, partnerId, timestamp, eventType } = event;
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;
}
};
🤖 Prompt for AI Agents
In apps/web/lib/upstash/redis-streams.ts around lines 178 to 188, wrap the
publishPartnerActivityEvent body in a try-catch to mirror publishClickEvent:
call redis.xadd inside try and return its result, and in catch log the error
with context (include event/programId/partnerId) using the same logger used
elsewhere (or console.error if none) and return null so callers don't get an
unhandled exception; keep the function async and preserve the return shape
(e.g., Promise<string | null>).

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.

Comment on lines 88 to 95
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
includeSaleReward: true,
include: {
links: true,
saleReward: true,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Include program when fetching enrollment

getProgramEnrollmentOrThrow now throws unless the program relation is included (see apps/web/lib/api/programs/get-program-enrollment-or-throw.ts where it checks (programEnrollment as any).program). Without program: true, this path will now 500 even for valid enrollments. Please include the program relation:

       include: {
+        program: true,
         links: true,
         saleReward: true,
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
includeSaleReward: true,
include: {
links: true,
saleReward: true,
},
});
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
include: {
program: true,
links: true,
saleReward: true,
},
});
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/commissions/[commissionId]/route.ts around lines 88 to
95, getProgramEnrollmentOrThrow is being called without including the program
relation which causes it to throw when it expects program to be present; update
the include object to add program: true (alongside links: true and saleReward:
true) so the returned enrollment contains the program relation required by
downstream checks.

Comment on lines +19 to 23
include: {
links: true,
discountCodes: true,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Add program to the include list

getProgramEnrollmentOrThrow expects the program relation to be loaded; otherwise it throws a not_found error even when the enrollment exists. Please include program: true here:

     include: {
+      program: true,
       links: true,
       discountCodes: true,
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
include: {
links: true,
discountCodes: true,
},
});
include: {
program: true,
links: true,
discountCodes: true,
},
});
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts
around lines 19 to 23, the Prisma include lacks the program relation which
getProgramEnrollmentOrThrow requires; update the include object to add program:
true so the enrollment's program is eagerly loaded (i.e., include { links: true,
discountCodes: true, program: true }) ensuring getProgramEnrollmentOrThrow won't
throw a not_found due to a missing relation.

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

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.

Comment on lines +291 to +300
// 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",
}),
]
: []),
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

Surface failures from the new partner-stats sync.

The new syncPartnerLinksStats promise is wrapped in the existing Promise.allSettled, which already swallows rejections from the lead recorder and link update. Now a stats-sync failure will also disappear silently, leaving enrollments stale. Please either revert this block to Promise.all or capture the Promise.allSettled results and log/throw on any rejection so we notice when the additional side effect breaks.

🤖 Prompt for AI Agents
In apps/web/lib/firstpromoter/import-customers.ts around lines 291 to 300, the
added call to syncPartnerLinksStats is being included in an existing
Promise.allSettled group so any rejection is silently ignored; change this so
failures are surfaced: either (A) replace Promise.allSettled with Promise.all
for the whole batch so any rejection fails fast, or (B) keep Promise.allSettled
but capture its results and if any entry has status "rejected" log the error(s)
with contextual info (partnerId/programId) and rethrow or return a rejected
promise so callers see the failure; implement the chosen option consistently and
add tests or at least a log line to ensure stats-sync errors are observable.

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.

1 participant

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载