-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Sync partner link stats to ProgramEnrollment table #2956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds 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
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
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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (8)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (2)
🧰 Additional context used🧬 Code graph analysis (2)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)
apps/web/ui/modals/delete-group-modal.tsx (1)
⏰ 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)
🔇 Additional comments (8)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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
: UsePromise.allSettled
for consistency and fault tolerance.This file uses
Promise.all
(line 271) while similar import files (firstpromoter
,tolt
,rewardful
) usePromise.allSettled
. UsingPromise.all
means that ifsyncPartnerLinksStats
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.programIdapps/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.programIdapps/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.programIdapps/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
📒 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
andcommissionStats
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 usingPromise.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 ofPromise.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
orsyncPartnerLinksStats
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
tototalClicks/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:
- Is there a tracking issue for re-enabling
netRevenue
sorting?- 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:
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.
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.
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 ofundefined
instead ofnull
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 expressionprogramEnrollment.totalSaleAmount - programEnrollment.totalCommissions
can yield negative values (e.g., refunds or clawbacks). Clarify in the API docs whether negativenetRevenue
is expected or should be clamped to zero.apps/web/lib/zod/schemas/partners.ts (1)
47-58
: Do not rename column IDs:exportPartnerColumns
usesclicks
,leads
,conversions
,sales
, andsaleAmount
to match the response object properties returned by getPartners (which maps the DB aliasestotalClicks
,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 updatingpublishClickEvent
to use the object payload pattern withredis.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 andpartnerActivityStream
exportPartnerActivityEvent
interface is properly typed with appropriate event types- Consistent with the existing workspace usage stream pattern
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`, | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deduplicate partner/program pairs and add error handling.
The current implementation has several issues:
- Duplicate syncs: If a partner has multiple links with commissions,
syncTotalCommissions
is called multiple times for the samepartnerId
/programId
pair, which is wasteful and may cause race conditions. - Misleading log: The log message reports
commissionsToCreate.length
as "partners" but this is the count of commissions, not unique partners. - 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.
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).
apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts
Outdated
Show resolved
Hide resolved
// 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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", | ||
}), | ||
]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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.
totalCommissions: | ||
programEnrollment.totalCommissions + commission.earnings, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
- Thread A reads
programEnrollment.totalCommissions = 100
, computes 100 + 50 = 150 - Thread B reads
programEnrollment.totalCommissions = 100
, computes 100 + 30 = 130 - 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:
- Accept eventual consistency: Document that webhook payloads reflect optimistic totals that will be reconciled asynchronously
- Add locking: Use database-level optimistic locking (version field) or distributed locks to serialize updates
- 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.
export const publishPartnerActivityEvent = async ( | ||
event: PartnerActivityEvent, | ||
) => { | ||
const { programId, partnerId, timestamp, eventType } = event; | ||
return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, "*", { | ||
programId, | ||
partnerId, | ||
timestamp, | ||
eventType, | ||
}); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add 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.
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>).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (3)
apps/web/lib/middleware/link.ts (1)
282-284
: DRY the partner/program args across branchesThe 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 logsAdd 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
📒 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 goodPassing 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 recordingMatches the new recordClick signature and integrates with existing dedupe/caching.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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, writesundefined
, 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
andcommissionStatsArray
.
91-96
: Optimize the extraction of programIds and partnerIds.The code splits each composite key twice—once for
programIds
and again forpartnerIds
. 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
isnull
, it will be assigned asnull
, 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
📒 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 ofmain()
is the correct pattern for a standalone script.
137-137
: Earnings field non-nullable with default 0
The Prisma schema definesearnings
as a non-nullable Int with@default(0)
, so null values aren’t possible andearnings: { not: 0 }
correctly filters out zero earnings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (8)
apps/web/app/(ee)/api/partners/export/route.ts (1)
67-78
: Simplify date handling by converting all Date valuesRather than maintaining a hard-coded list of fields, drop the
column === …
check and simply convert anyDate
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 alongsideBATCH_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
: UseforEach
instead ofmap
when not returning a value.The
.map()
call doesn't return a new array—it's used only for side effects (populatingprogramEnrollmentsToUpdate
). 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 isnull
orundefined
. Explicitly returningundefined
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
beforeWHERE
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
📒 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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
, andlinkStructure
, but theas PartnerGroupProps
assertion claims it contains all properties from the fullPartnerGroup
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 entiregroup
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 forsyncTotalCommissions
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 butprogramEnrollment.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 separateprisma.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
📒 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 nestedinclude
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 thegetProgramEnrollmentOrThrow
API. The empty include is appropriate here since the code only needs the enrollment'sprogramId
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 onlypartnerId
andprogramId
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
toinclude: { program: true }
correctly transitions to the standardized include object pattern. The includedprogram
data is necessary for accessingprogramEnrollment.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 thestatus
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 includedpartner
andlinks
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 runexecuteWorkflows
andsyncPartnerLinksStats
in parallel is a good practice that improves performance. The type assertioncommissionType 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
andlinks
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
andlinks
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
toinclude: { program: true, links: true, partnerGroup: true }
improves clarity, and the destructuring aliaspartnerGroup: 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
andlinks
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
ThebannedAt
field is defined inprogram.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. ThenetRevenue
field appropriately remains without thetotal
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 temporarynetRevenue
limitation noted.The sortable columns have been correctly updated to use the
total*
prefixed fields. The comment on line 314 clearly indicates thatnetRevenue
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
: VerifygetProgramEnrollmentOrThrow
selectslinks
Confirm that thegetProgramEnrollmentOrThrow
query includes thelinks
relation so everyprogramEnrollment
passed intodeterminePartnerReward
satisfies the now-requiredlinks
field.
const programEnrollment = await getProgramEnrollmentOrThrow({ | ||
partnerId: partner.id, | ||
programId, | ||
includeSaleReward: true, | ||
include: { | ||
links: true, | ||
saleReward: true, | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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.
include: { | ||
links: true, | ||
discountCodes: true, | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ 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 nestedinclude
,select
, ororderBy
that the caller supplies forlinks
anddiscountCodes
. Wheninclude.links
is truthy (eithertrue
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 likelinks: { include: { tags: true } }
ordiscountCodes: { 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:
- Data loss: Leads, conversions, and sales metrics are not published as events
- Misleading timestamps: Historical activity is stamped with the current time (
new Date().toISOString()
)- 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
📒 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 theprogram
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.
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
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
toPromise.allSettled
means any rejection from the lead recorder, link update, or the newsyncPartnerLinksStats
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 withPromise.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 newtotal*
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
, ororderBy
properties that callers supply forlinks
anddiscountCodes
. For example, if a caller passesinclude: { links: { include: { tags: true } } }
, the current implementation replaces it with{ orderBy: { createdAt: "asc" } }
, losing thetags
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 derivingnetRevenue
.This issue was already flagged in a previous review.
ProgramEnrollment.totalSaleAmount
and.totalCommissions
are stored as PrismaDecimal
s. 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 beeventType: "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 theinclude
object, which will causegetProgramEnrollmentOrThrow
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 acatch
/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 alwaysawait 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 awaitedsyncTotalCommissions
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
withoutmode: "insensitive"
. A previous review flagged this as a regression fromILIKE
, 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, makingmode: "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: Addprogram
relation to the include object.
getProgramEnrollmentOrThrow
requires theprogram
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 structuredinclude
object is more scalable and aligns with the broader PR refactor- Renaming
partnerGroup
togroup
in destructuring is consistent with its usageAlso 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 theinclude
parameter is optional ingetProgramEnrollmentOrThrow
, 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 theinclude
parameter is optional ingetProgramEnrollmentOrThrow
, 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. IfsyncPartnerLinksStats
orexecuteWorkflows
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 bothexecuteWorkflows
andsyncPartnerLinksStats
. 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 mutatingprogramEnrollmentsToUpdate
,.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
andconsole.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 bygetPartners
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
📒 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 ofgetProgramEnrollmentOrThrow
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
tototalSaleAmount
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 correspondingbannedAt
column.apps/web/app/(ee)/api/discount-codes/route.ts (2)
28-30
: LGTM! Include pattern standardization.The refactor from
includeDiscountCodes: true
toinclude: { 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
, anddiscountCodes
- 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 unsafeas PartnerGroupProps
cast. It bypasses TS checks—ensure you map all fields fromPartnerGroup & { additionalLinks: … }
or usesatisfies PartnerGroupProps
so missing or mismatched props are caught.apps/web/lib/middleware/link.ts (1)
282-283
: LGTM!The addition of
programId
andpartnerId
to allrecordClick
calls is consistent across all redirect paths and correctly referencescachedLink
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 onlyprogramId
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 onlypartnerId
andprogramId
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
toinclude: { links: true, clickReward: true }
correctly adapts to the new function signature. The included relations are appropriately used in the script (line 21 forreward
determination and line 26 forlink
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 structuredinclude
object improves clarity and aligns with the standardized pattern used across the codebase. The destructuring ofpartnerGroup
togroup
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
andpartnerId
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 newtotal*
prefixed fields, while adding support fortotalCommissions
andnetRevenue
. The formatting logic and NaN handling remain unchanged.apps/web/lib/partners/determine-partner-reward.ts (1)
16-22
: Verify callers include requiredlinks
fieldThe
links
property onProgramEnrollmentWithReward
is now required (links: Link[] | null
). Ensure every call todeterminePartnerReward
(e.g. inapps/web/scripts/*
,create-partner-commission.ts
, API routes underapps/web/lib/api
, and app routes underapps/web/app
) explicitly passes alinks
field—even ifnull
.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
beforeWHERE
, 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
toinclude: { 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 forpartnerId
andprogramId
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 inapps/web/lib/zod/schemas/partners.ts
includes alltotal*
fields to prevent runtime errors.
306-315
: Confirm backend support fornetRevenue
sorting
NonetRevenue
field exists in the Prisma schema—ensure the API computes and exposes sortablenetRevenue
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, theupdate
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. Theid
andcreatedAt
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 alltotal*
fields with appropriate defaults and descriptions. This resolves the concern raised in a previous review about missing type definitions for the partners table.
// 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", | ||
}), | ||
] | ||
: []), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Summary by CodeRabbit
New Features
Refactor
Chores