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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 27, 2025

Summary by CodeRabbit

  • New Features

    • Background processing for large commission exports—users receive an email notification when their export is ready, improving performance and user experience.
  • Improvements

    • Enhanced export workflow with consistent, timestamped filenames for both commission and partner exports.
    • Optimized data retrieval and export formatting for better reliability and scalability.

@vercel
Copy link
Contributor

vercel bot commented Oct 27, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 27, 2025 4:55pm

@devkiran devkiran changed the base branch from main to fix-csv-export October 27, 2025 13:44
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

This PR refactors the commissions data retrieval and export infrastructure by extracting shared utility functions (getCommissions, getCommissionsCount, formatCommissionsForExport) and implementing background processing via QStash for large exports. It simplifies existing API routes by delegating to these utilities, introduces batch processing for exports, and updates the export email template to be generic across export types.

Changes

Cohort / File(s) Change Summary
Commissions API Routes
apps/web/app/(ee)/api/commissions/count/route.ts, apps/web/app/(ee)/api/commissions/export/route.ts, apps/web/app/(ee)/api/commissions/route.ts
Refactored to delegate data retrieval and aggregation to new utility functions. The count endpoint now uses getCommissionsCount, the list endpoint uses getCommissions, and the export endpoint implements threshold-based QStash background processing for large exports (202 Accepted response) or direct CSV return for small exports.
Commissions Utilities
apps/web/lib/api/commissions/get-commissions.ts, apps/web/lib/api/commissions/get-commissions-count.ts, apps/web/lib/api/commissions/format-commissions-for-export.ts
New utility modules: getCommissions performs flexible Prisma queries with filtering, pagination, and sorting; getCommissionsCount provides aggregated status counts and earnings totals; formatCommissionsForExport enriches commissions with flattened fields, sorts columns, and validates against zod schemas.
Commissions Export Cron Jobs
apps/web/app/(ee)/api/cron/commissions/export/fetch-commissions-batch.ts, apps/web/app/(ee)/api/cron/commissions/export/route.ts
New files implementing QStash-triggered background export workflow: fetchCommissionsBatch streams commission batches, the route verifies QStash signature, fetches user/program, batches commissions, formats and converts to CSV, uploads to R2 storage, and emails download link.
Partners Export Cron Jobs
apps/web/app/(ee)/api/cron/partners/export/fetch-partners-batch.ts, apps/web/app/(ee)/api/cron/partners/export/route.ts
Renamed parameter batchSize to pageSize in fetchPartnersBatch for consistency; updated route to use generateExportFilename, simplified batch-fetch invocation, and integrated with the refactored ExportReady email component.
Export Utilities
apps/web/lib/api/utils/generate-export-filename.ts
New utility generating consistent export filenames with sanitized ISO timestamps and capitalized export type labels (e.g., "Dub Commissions Export - 2025-10-15...csv").
Export UI & Email
apps/web/ui/modals/export-commissions-modal.tsx, apps/web/ui/modals/export-partners-modal.tsx, packages/email/src/templates/partner-export-ready.tsx
Export modals updated to handle 202 Accepted responses for background jobs and use generateExportFilename; PartnerExportReady component renamed to ExportReady with a new generic exportType prop ("partners" | "commissions") for reuse across export types.

Sequence Diagram

sequenceDiagram
    participant User
    participant Modal as Export Modal
    participant API as /api/commissions/export
    participant QStash
    participant Cron as /api/cron/commissions/export
    participant Storage as R2 Storage
    participant Email as Email Service

    User->>Modal: Click Export
    Modal->>API: POST /api/commissions/export
    
    rect rgb(200, 220, 255)
    Note over API: Check export size
    API->>API: count = getCommissionsCount()
    end
    
    alt Large Export (> threshold)
        rect rgb(255, 200, 200)
        Note over API: Queue background job
        API->>QStash: Queue export task
        API-->>Modal: 202 Accepted
        Modal->>User: Show toast (will email results)
        end
        
        QStash->>Cron: Trigger export job
        rect rgb(200, 255, 200)
        Note over Cron: Background processing
        Cron->>Cron: fetchCommissionsBatch() → stream
        Cron->>Cron: formatCommissionsForExport()
        Cron->>Cron: Convert to CSV
        end
        Cron->>Storage: Upload CSV to R2
        Cron->>Email: Send download link to user
        Email-->>User: Email with ready-export link
    else Small Export (≤ threshold)
        rect rgb(200, 255, 200)
        Note over API: Inline processing
        API->>API: getCommissions() + formatCommissionsForExport()
        API->>API: Convert to CSV
        end
        API-->>Modal: 200 OK + CSV
        Modal->>User: Download starts immediately
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • QStash integration & background job flow: Verify signature validation, payload parsing/schema validation, error handling, and email notification logic in the new cron route
  • Batch streaming logic: Review fetchCommissionsBatch generator for correctness of pagination termination condition and off-by-one errors
  • Data enrichment & formatting: Validate the flattening of customer/partner fields and zod schema-based type coercion in formatCommissionsForExport
  • Threshold-based dispatch: Ensure the 202 Accepted response path is correctly wired and doesn't break existing export workflows
  • API route refactoring: Verify that delegating to new utilities maintains filtering, sorting, and pagination semantics across commissions/count/export routes
  • Email template generalization: Confirm the renamed ExportReady component and exportType prop are correctly passed in both partners and commissions export flows

Possibly related PRs

  • Export commissions as CSV #2755: Modifies the commissions CSV export flow by refactoring the inline Prisma/Zod logic into getCommissions and getCommissionsCount helpers, with the main PR further adding background QStash processing.
  • Add tenantId filter to GET /commissions #2905: Updates the commissions GET handler and tenantId → partnerId resolution logic, overlapping with this PR's refactoring of the same route to use getCommissions.
  • FEAT: Partner Groups #2735: Modifies commissions count/listing routes and introduces partner group (groupId) filtering through shared helpers like getCommissionsCount and getCommissions, aligning with this PR's extraction of utilities.

Suggested reviewers

  • steven-tey

Poem

🐰 Exports now flow through the misty clouds,
QStash queues them in background crowds,
No more freezing the browser's thread—
Commissions fly swift, emails are spread!
From partners to exports, one template to rule,

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Async commissions export" is directly related to the main change in the changeset. The primary objective of this PR is to implement asynchronous processing for commissions exports, where large exports are queued as background jobs via QStash instead of being processed synchronously. The changes support this objective by introducing new cron routes, batch data retrieval functions, CSV formatting utilities, and email notifications. The title is concise, clear, and specific enough that a teammate scanning the PR history would understand that this PR adds async commissions export functionality.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@devkiran devkiran marked this pull request as ready for review October 27, 2025 15:30
@devkiran
Copy link
Collaborator Author

@coderabbitai review please

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 27, 2025

@devkiran: I'll review the changes in this PR for you.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (9)
packages/email/src/templates/partner-export-ready.tsx (1)

17-31: LGTM!

The generalization from PartnerExportReady to ExportReady with the exportType prop enables reuse across multiple export types while maintaining backward compatibility with the default value.

Optional: Consider pluralizing the export type label for better grammar.

The phrasing "Your export of partners from..." reads naturally, but you could enhance it slightly:

const exportTypeLabel = exportType === "commissions" ? "commissions" : "partners";

Then use it in the text. However, this is a minor stylistic point and the current implementation is acceptable.

Also applies to: 35-48

apps/web/app/(ee)/api/cron/commissions/export/route.ts (2)

68-84: Avoid loading entire export into memory; stream CSV to storage.

Building an in-memory array and one big Blob risks high memory usage and long GC pauses for large exports. Prefer line-by-line/row-chunk streaming to R2 (multipart or streaming upload) while iterating the async generator.

Example adjustment outline:

  • Convert batches to CSV rows incrementally.
  • Initiate multipart upload; upload parts per N rows.
  • Finalize upload and then email the link.

If streaming is not available in your storage SDK, buffer modest chunks (e.g., 5–10 MB) before flushing to a part. This keeps memory bounded.

Also applies to: 85-96


101-112: Defensive email payload: handle nullable program name.

If program.name can be null, the template might render poorly. Fallback to a safe string.

-        program: {
-          name: program.name,
-        },
+        program: {
+          name: program.name ?? "Program",
+        },
apps/web/lib/api/commissions/get-commissions-count.ts (1)

49-58: Nested partner.programs.some filter may be expensive; consider pre-resolving partnerIds or adding an index.

Filtering via a nested relation in a groupBy can be slow at scale. If feasible, prefetch the partnerIds for (programId, groupId) and use partnerId: { in: [...] }. At minimum, ensure an index covers (programId, groupId) on the join table.

apps/web/lib/api/commissions/format-commissions-for-export.ts (3)

49-53: Don’t mutate the input columns array when sorting.

Sorting in place can produce side effects for callers.

-  const sortedColumns = columns.sort(
+  const sortedColumns = [...columns].sort(
     (a, b) =>
       (COLUMN_LOOKUP.get(a)?.order || 999) -
       (COLUMN_LOOKUP.get(b)?.order || 999),
   );

38-46: Select partnerTenantId for the current program rather than the first enrollment.

Using programs[0] is brittle for partners in multiple programs. Prefer the enrollment that matches the commission’s program.

If commission carries programId or partnerEnrollment, use that; otherwise, fallback:

-    partnerTenantId: commission.partner?.programs[0]?.tenantId || "",
+    partnerTenantId:
+      commission.partnerEnrollment?.tenantId ??
+      commission.partner?.programs?.find?.((p: any) => p.programId === commission.programId)?.tenantId ??
+      "",

If programId isn’t available here, consider passing it as a third parameter to formatCommissionsForExport.


55-66: Column schema construction is good; pair with ordered headers for CSV.

Schema-driven shaping ensures consistent row objects. To guarantee header order for downstream CSV, return both data and the ordered column list from this function.

-export function formatCommissionsForExport(
+export function formatCommissionsForExport(
   commissions: any[],
   columns: string[],
-): Record<string, any>[] {
+): { rows: Record<string, any>[]; columns: string[] } {
   ...
-  return z.array(z.object(columnSchemas)).parse(formattedCommissions);
+  const rows = z.array(z.object(columnSchemas)).parse(formattedCommissions);
+  return { rows, columns: sortedColumns };
}

Then, update callers to use convertToCSV(rows, { columns: sortedColumns }).

Also applies to: 68-69

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

40-40: Provide tracking information in the 202 response.

The 202 Accepted response returns an empty object. Consider including information to help users track their export, such as a message indicating the export is being processed and will be emailed, or an estimated completion time.

-      return NextResponse.json({}, { status: 202 });
+      return NextResponse.json(
+        { 
+          message: "Export is being processed. You will receive an email when it's ready.",
+          estimatedTime: "5-10 minutes"
+        }, 
+        { status: 202 }
+      );

57-62: Add filename to Content-Disposition header.

The Content-Disposition header is set to attachment but doesn't include a filename. Browsers will use a generic name. Consider adding a descriptive filename with timestamp:

       headers: {
         "Content-Type": "text/csv",
-        "Content-Disposition": "attachment",
+        "Content-Disposition": `attachment; filename="commissions-export-${new Date().toISOString().split('T')[0]}.csv"`,
       },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e0a97b1 and 1739b0e.

📒 Files selected for processing (14)
  • apps/web/app/(ee)/api/commissions/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/commissions/export/route.ts (1 hunks)
  • apps/web/app/(ee)/api/commissions/route.ts (3 hunks)
  • apps/web/app/(ee)/api/cron/commissions/export/fetch-commissions-batch.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/commissions/export/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/partners/export/fetch-partners-batch.ts (2 hunks)
  • apps/web/app/(ee)/api/cron/partners/export/route.ts (4 hunks)
  • apps/web/lib/api/commissions/format-commissions-for-export.ts (1 hunks)
  • apps/web/lib/api/commissions/get-commissions-count.ts (1 hunks)
  • apps/web/lib/api/commissions/get-commissions.ts (1 hunks)
  • apps/web/lib/api/utils/generate-export-filename.ts (1 hunks)
  • apps/web/ui/modals/export-commissions-modal.tsx (4 hunks)
  • apps/web/ui/modals/export-partners-modal.tsx (2 hunks)
  • packages/email/src/templates/partner-export-ready.tsx (1 hunks)
🔇 Additional comments (14)
apps/web/app/(ee)/api/cron/partners/export/fetch-partners-batch.ts (1)

12-34: LGTM!

The parameter rename from batchSize to pageSize improves clarity and accurately reflects its usage. All internal references are updated consistently.

apps/web/ui/modals/export-partners-modal.tsx (1)

1-1: LGTM!

The adoption of the generateExportFilename utility provides consistent, timestamp-based filename generation across export flows.

Also applies to: 103-103

apps/web/ui/modals/export-commissions-modal.tsx (1)

1-1: LGTM!

The adoption of generateExportFilename and useSession enables consistent filename generation and personalized user feedback for async export flows.

Also applies to: 102-102

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

1-19: LGTM!

The refactoring to delegate count logic to the getCommissionsCount utility reduces duplication and centralizes data aggregation logic.

apps/web/app/(ee)/api/cron/commissions/export/fetch-commissions-batch.ts (1)

12-35: LGTM!

The async generator pattern for batch processing is well-suited for large exports. The implementation is consistent with the partners batch fetcher and correctly handles pagination.

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

11-90: LGTM!

The getCommissions utility provides flexible filtering with proper pagination, conditional includes, and date-range handling. The dual where-clause logic (invoiceId vs. earnings-based) and conditional program enrollment inclusion are correctly implemented.

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

4-4: Filename generation and unified ExportReady template look good.

Using generateExportFilename and passing exportType: "partners" standardizes UX and download behavior.

Also applies to: 10-10, 91-91, 102-106


75-75: No action required—default batch size confirms no throughput regression.

The fetchPartnersBatch function in fetch-partners-batch.ts (line 14) has a default pageSize: number = 1000. Removing the explicit parameter at the call site has no effect; the function will still use 1000 as the batch size. There is no throughput regression or runtime cost increase.

Likely an incorrect or invalid review comment.

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

18-20: Verification found inconsistency: many API routes still use direct Prisma queries instead of delegating to getCommissions.

The refactored commissions/route.ts correctly uses getCommissions, but verification revealed that several other API routes bypass it entirely:

  • apps/web/app/(ee)/api/partners/analytics/route.ts
  • apps/web/app/(ee)/api/partner-profile/.../earnings/route.ts
  • apps/web/app/(ee)/api/embed/referrals/earnings/route.ts
  • apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts
  • apps/web/app/(ee)/api/cron/partner-program-summary/route.ts

Confirm whether these should also delegate to getCommissions or if their operations (groupBy, aggregate) require different query patterns.

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

67-85: No issues found with status handling — code is correct.

The CommissionStatus enum contains only active statuses (pending, processed, paid, refunded, duplicate, fraud, canceled) with no deprecated or legacy values. All seven enum values are:

  • Mapped in CommissionStatusBadges with complete coverage
  • Properly handled by the forEach() loop at line 87 (filling missing statuses)
  • Safely used by downstream consumers
  • Type-checked via the Record<CommissionStatus | "all", ...> annotation

The verification confirms the original concern about deprecated statuses is not applicable.

apps/web/app/(ee)/api/cron/commissions/export/route.ts (1)

83-84: The review comment's concern is valid, but the suggested fix code is incorrect and incompatible with the current API.

Here's why:

  1. convertToCSV only accepts a single data parameter—it does not accept a second options parameter. The suggested call convertToCSV(allCommissions, { columns: ..., headers: ... }) will not work.

  2. json2csv (the underlying library) does support column ordering via options.keys, but convertToCSV doesn't expose that parameter to its callers.

  3. formatCommissionsForExport sorts the columns array and builds a Zod schema in that order, but there's no guarantee that the resulting parsed objects will preserve key order when passed to convertToCSV, since json2csv will auto-detect header order from the first object's key iteration order.

The legitimate fix requires modifying convertToCSV itself to accept and pass through json2csv options (specifically the keys parameter), or modifying formatCommissionsForExport to return both sorted data and an explicit ordered keys array for use with convertToCSV.

Likely an incorrect or invalid review comment.

apps/web/app/(ee)/api/commissions/export/route.ts (3)

23-26: No action required—code is type-safe.

The function getCommissionsCount explicitly constructs and returns an object where counts.all is always present with a guaranteed structure { count: number; amount: number; earnings: number } (lines 91–97 of get-commissions-count.ts). The usage of counts.all.count in the export route is type-safe and requires no validation or type guards.

Likely an incorrect or invalid review comment.


16-16: Session type is already guaranteed as non-null by WithWorkspaceHandler interface.

The session parameter in the WithWorkspaceHandler interface is typed as required (session: Session) rather than optional. The withWorkspace function validates this guarantee through a guard clause that throws if session or session.user.id is falsy, ensuring handlers always receive a valid session object. No additional null checks are needed.

Likely an incorrect or invalid review comment.


9-9: Incorrect review comment – no production safety issue.

The APP_DOMAIN_WITH_NGROK constant correctly resolves to https://app.${NEXT_PUBLIC_APP_DOMAIN} in production environments (when NEXT_PUBLIC_VERCEL_ENV === "production") and to preview or local development URLs otherwise. The constant name is appropriate—it reflects that ngrok can optionally be used in local development via the NEXT_PUBLIC_NGROK_URL environment variable, but production deployments always use the correct production domain. The QStash callback URL at line 31 is safe and follows the established pattern used throughout the codebase.

Likely an incorrect or invalid review comment.

url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/export`,
body: {
...parsedParams,
columns: columns.join(","),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle edge case where column names contain commas.

Converting the columns array to a CSV string with columns.join(",") could cause parsing issues if any column name contains a comma. Consider using JSON serialization instead, or verify that column names are sanitized.

-          columns: columns.join(","),
+          columns: JSON.stringify(columns),

Then ensure the cron job handler parses this with JSON.parse(columns).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/commissions/export/route.ts around line 34, the code
uses columns.join(",") which breaks when column names contain commas; replace
columns.join(",") with JSON.stringify(columns) when serializing the columns into
the request and update the cron job handler to parse the incoming value with
JSON.parse(columns) (or validate/sanitize after parse) so arrays with commas in
names are preserved correctly.

Comment on lines +44 to +50
const commissions = await getCommissions({
...filters,
programId,
page: 1,
pageSize: MAX_COMMISSIONS_TO_EXPORT,
includeProgramEnrollment: true,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider race condition between count check and data fetch.

There's a potential race condition: if commissions are created between the getCommissionsCount call (line 23) and the getCommissions call (line 44), the synchronous path might miss records or fetch more than expected. For critical exports, consider using database transactions or document this behavior.

Comment on lines +27 to +32
const { startDate, endDate } = getStartEndDates({
interval,
start,
end,
});

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid toISOString on Date filters to prevent TZ drift; pass Date objects.

Converting to ISO strings can shift the window in non‑UTC timezones. Prisma accepts Date instances directly.

-      createdAt: {
-        gte: startDate.toISOString(),
-        lte: endDate.toISOString(),
-      },
+      createdAt: {
+        gte: startDate,
+        lte: endDate,
+      },

If the intent is end‑exclusive, use lt and normalize endDate to the next tick/day.

Also applies to: 45-49

🤖 Prompt for AI Agents
In apps/web/lib/api/commissions/get-commissions-count.ts around lines 27-32 (and
similarly lines 45-49), the code converts startDate/endDate to ISO strings which
causes timezone drift; instead keep and pass Date objects directly into Prisma
filters, and if the intent is an end-exclusive window use the appropriate lt
operator and normalize endDate to the next tick/day (e.g., add one day or set
time to 23:59:59.999 then use lt) so the filter is accurate across timezones.

@panda-sandeep
Copy link

/bug0 run

@steven-tey steven-tey merged commit cc54008 into fix-csv-export Oct 28, 2025
7 checks passed
@steven-tey steven-tey deleted the csv-export-commissions-large branch October 28, 2025 05:01
@coderabbitai coderabbitai bot mentioned this pull request Dec 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants