Skip to content

Partner Analytics/Events export#3351

Merged
steven-tey merged 3 commits intomainfrom
partner-analytics-export
Jan 13, 2026
Merged

Partner Analytics/Events export#3351
steven-tey merged 3 commits intomainfrom
partner-analytics-export

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Jan 13, 2026

Summary by CodeRabbit

  • New Features

    • Export partner program analytics as a ZIP of CSV reports.
    • Export partner program events as CSV files.
  • Improvements

    • Export buttons now select the correct export endpoint based on context.
    • Events options always shown on partner dashboard.
    • Events view now uses a provider context for consistent behavior.
    • Columns selection parsed into arrays; empty-export scenarios handled gracefully.

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link
Contributor

vercel bot commented Jan 13, 2026

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

Project Deployment Review Updated (UTC)
dub Ready Ready Preview Jan 13, 2026 7:20pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 13, 2026

📝 Walkthrough

Walkthrough

Adds partner-profile analytics and events export GET routes, updates root events export schema to parse comma-separated columns into arrays, switches UI export buttons to use AnalyticsContext paths, and wraps the Events page with an EventsProvider.

Changes

Cohort / File(s) Summary
Partner Profile Export Routes
apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts, apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
New GET routes that fetch program enrollment (with links), enforce large-program access checks, resolve link context (linkId or domain/key), fetch/transform analytics/events data, and return ZIP (analytics) or CSV (events) file downloads.
Root Events Export Schema
apps/web/app/(ee)/api/events/export/route.ts
Schema change: replaced .and(z.object({ columns: ... })) with .extend({ columns: ... }) and parse comma-separated columns into an array of strings.
UI: Export Endpoints & Context
apps/web/ui/analytics/analytics-export-button.tsx, apps/web/ui/analytics/events/events-export-button.tsx
Export buttons now derive base paths from AnalyticsContext (baseApiPath / eventsApiPath) and construct dynamic /export URLs instead of hardcoded endpoints.
Events Page Provider
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx, apps/web/ui/analytics/events/events-provider*
Wraps the Events component with EventsProvider (provider import/usage added).
UI: Options Rendering
apps/web/ui/analytics/toggle.tsx
AnalyticsOptions is now rendered unconditionally (removed partnerPage gating).

Sequence Diagram(s)

sequenceDiagram
    participant Client as Partner Client
    participant UI as Export UI
    participant API as Partner Export API
    participant DB as Data/Services
    participant ZIP as Archive Generator

    Client->>UI: Click "Export Analytics"
    UI->>UI: Resolve base path from AnalyticsContext
    UI->>API: GET /partner-profile/programs/{id}/analytics/export?...
    API->>DB: getProgramEnrollmentOrThrow (program, links) & access check
    alt Access Denied
        API-->>UI: 403 Forbidden
    else Access Granted
        API->>DB: Concurrent analytics fetches per endpoint
        DB-->>API: analytics responses
        API->>ZIP: Generate CSVs and add to ZIP
        ZIP-->>API: ZIP buffer
        API-->>UI: return ZIP file response
        UI-->>Client: download analytics.zip
    end
Loading
sequenceDiagram
    participant Client as Partner Client
    participant UI as Export UI
    participant API as Partner Export API
    participant DB as Data/Services
    participant CSV as CSV Generator

    Client->>UI: Click "Export Events"
    UI->>UI: Resolve eventsApiPath from AnalyticsContext
    UI->>API: GET /partner-profile/programs/{id}/events/export?columns=...
    API->>DB: getProgramEnrollmentOrThrow & access check
    alt Access Denied
        API-->>UI: 403 Forbidden
    else Access Granted
        API->>DB: Fetch events (filtered by linkId/partner/workspace)
        DB-->>API: event records
        API->>API: sanitize/mask customer data, transform rows
        API->>CSV: build CSV content
        CSV-->>API: CSV content
        API-->>UI: return CSV file response
        UI-->>Client: download events.csv
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

🐇 I hopped through code with ears held high,

Columns split neatly beneath a CSV sky,
Links found their homes and rows got trimmed,
Zipped and shipped—my little paws brimmed! 🥕📁

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding export functionality for partner analytics and events across multiple new API routes and UI components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
apps/web/ui/analytics/analytics-export-button.tsx (1)

41-43: Fix the error re-throwing to preserve the original message.

throw new Error(error) will stringify the error object, resulting in "[object Object]" or "Error: ..." as the message. This loses the original error context.

🐛 Proposed fix
    } catch (error) {
-     throw new Error(error);
+     throw error instanceof Error ? error : new Error(String(error));
    }

Or simply re-throw directly:

    } catch (error) {
-     throw new Error(error);
+     throw error;
    }
🤖 Fix all issues with AI agents
In @apps/web/app/(ee)/api/events/export/route.ts:
- Line 59: Remove the leftover debug logging statement
console.log("parsedParams", parsedParams) from the export route handler in
route.ts; locate where parsedParams is computed and delete that console.log call
(or replace it with a proper logger.debug/trace call if structured logging is
needed) so no console output remains in production code.

In
@apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts:
- Around line 167-177: The current object passed to .parse assigns customer.name
|| generateRandomName() into the email field when customer.email is falsy,
producing malformed "name <email>" output; change that assignment so email is an
empty string (or other valid email placeholder) when customer.email is falsy
instead of the name. Concretely, in the object built for .parse (the block
referencing customer, customer.email, customerDataSharingEnabledAt, and
generateRandomName()), ensure the email property resolves to customer.email when
present and to '' (not customer.name or generateRandomName()) when absent; keep
the existing logic that populates the name property via the conditional
...(customerDataSharingEnabledAt && { name: customer.name ||
generateRandomName() }) so names remain correct in the customer column accessor
that formats as name <email>.
- Line 83: Remove the stray debug console.log in route.ts: delete the line
console.log("parsedParams", parsedParams); from the export request handler so no
debug output is left in production; if you need retained diagnostics, replace it
with a proper logger call (e.g., processLogger.debug or requestLogger.debug)
referencing parsedParams instead of console.log.

In @apps/web/ui/analytics/events/events-export-button.tsx:
- Line 19: The debug console.log in EventsExportButton prints exportQueryString
and should be removed; locate the console.log({ exportQueryString }) statement
inside the events-export-button.tsx (within the EventsExportButton component or
related handler) and delete that line so no debug output is emitted in
production builds.
- Around line 22-24: The fetch call in EventsExportButton uses eventsApiPath
which is optional in AnalyticsContext and can be undefined in public dashboards;
add a defensive guard in the EventsExportButton component so you never call
fetch with an undefined base path: either return early / disable the export
button when eventsApiPath is falsy, or validate and throw/log a clear error
before building the URL. Locate the fetch usage around
`${eventsApiPath}/export?${exportQueryString}` and update the component (e.g.,
EventsExportButton or its click handler) to check eventsApiPath is defined and
bail out or hide/disable the UI if not.
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts (2)

22-48: Consider extracting shared column definitions.

columnNames and columnAccessors are duplicated from apps/web/app/(ee)/api/events/export/route.ts. Consider extracting these to a shared utility to reduce maintenance overhead.


128-135: Empty response lacks CSV headers.

When links.length === 0, the response returns an empty body. Consider returning at least the column headers for consistency with the non-empty case, so consumers can still parse the structure.

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

92-99: Consider handling the empty ZIP edge case.

If all analytics endpoints return empty data, an empty ZIP file will be returned. While technically valid, this may confuse users expecting data. Consider returning an appropriate error or message.

Additionally, the zipData as unknown as BodyInit cast suggests a type mismatch between JSZip's nodebuffer output and the Response constructor. This works at runtime but could be cleaner.

💡 Optional: Add empty ZIP handling
    const zipData = await zip.generateAsync({ type: "nodebuffer" });

+   // Check if any files were added to the ZIP
+   if (Object.keys(zip.files).length === 0) {
+     throw new DubApiError({
+       code: "not_found",
+       message: "No analytics data available for the selected period.",
+     });
+   }

    return new Response(zipData as unknown as BodyInit, {
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7259062 and e934425.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/api/events/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx
  • apps/web/ui/analytics/analytics-export-button.tsx
  • apps/web/ui/analytics/events/events-export-button.tsx
  • apps/web/ui/analytics/toggle.tsx
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2026-01-13T12:06:42.476Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
  • apps/web/app/(ee)/api/events/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
📚 Learning: 2025-06-16T19:21:23.506Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2519
File: apps/web/ui/analytics/utils.ts:35-37
Timestamp: 2025-06-16T19:21:23.506Z
Learning: In the `useAnalyticsFilterOption` function in `apps/web/ui/analytics/utils.ts`, the pattern `options?.context ?? useContext(AnalyticsContext)` is intentionally designed as a complete replacement strategy, not a merge. When `options.context` is provided, it should contain all required fields (`baseApiPath`, `queryString`, `selectedTab`, `requiresUpgrade`) and completely replace the React context, not be merged with it. This is used for dependency injection or testing scenarios.

Applied to files:

  • apps/web/ui/analytics/toggle.tsx
  • apps/web/ui/analytics/events/events-export-button.tsx
  • apps/web/ui/analytics/analytics-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically sets type="button" when an onClick prop is passed and defaults to type="submit" otherwise, using the logic: type={props.onClick ? "button" : "submit"}. This prevents accidental form submissions when buttons are used for modal triggers or other non-form actions.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
🧬 Code graph analysis (5)
apps/web/ui/analytics/toggle.tsx (1)
apps/web/ui/analytics/analytics-options.tsx (1)
  • AnalyticsOptions (8-39)
apps/web/ui/analytics/events/events-export-button.tsx (1)
apps/web/ui/analytics/analytics-provider.tsx (1)
  • AnalyticsContext (56-99)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx (1)
apps/web/ui/layout/page-content/index.tsx (1)
  • PageContent (10-39)
apps/web/ui/analytics/analytics-export-button.tsx (1)
apps/web/ui/analytics/analytics-provider.tsx (1)
  • AnalyticsContext (56-99)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts (4)
apps/web/lib/types.ts (2)
  • LeadEvent (545-545)
  • SaleEvent (543-543)
apps/web/lib/constants/partner-profile.ts (3)
  • LARGE_PROGRAM_IDS (7-7)
  • LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS (8-8)
  • MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING (5-5)
apps/web/lib/api/errors.ts (1)
  • DubApiError (43-60)
apps/web/lib/zod/schemas/partner-profile.ts (2)
  • partnerProfileEventsQuerySchema (115-123)
  • PartnerProfileLinkSchema (79-94)
🔇 Additional comments (7)
apps/web/ui/analytics/toggle.tsx (1)

271-271: LGTM!

Rendering AnalyticsOptions unconditionally within this block enables export functionality for partner pages, which aligns with the new partner export routes introduced in this PR.

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx (1)

8-10: LGTM!

Wrapping Events with EventsProvider is necessary for the export button to access exportQueryString via EventsContext.

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

51-56: LGTM on schema update.

Using .extend() instead of .and() is the correct approach in Zod 4 for adding fields to an existing schema.

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

28-36: LGTM!

The access control logic correctly gates this feature for large programs based on total commissions, preventing abuse while allowing qualified partners access.


42-61: LGTM!

The link validation logic properly handles both linkId and domain/key combinations, ensuring the partner can only access their own links. The early return pattern with DubApiError provides clear error messages.


65-90: LGTM!

The concurrent analytics fetching with Promise.all is efficient. The conditional skipping of top_links when a single link is specified and count endpoint is a good optimization. Empty response handling prevents empty CSVs.

apps/web/ui/analytics/analytics-export-button.tsx (1)

18-23: Consider using baseApiPath consistently for both paths, or verify that the hardcoded endpoint works for all contexts.

The current logic uses baseApiPath for partner pages but hardcodes /api/analytics/export for non-partner pages. Given that baseApiPath varies across contexts (admin, workspace, dashboard), this inconsistency may be unintentional. If baseApiPath is meant to provide the correct API path in all contexts, consider using it consistently:

const exportPath = partnerPage
  ? `${baseApiPath}/export`
- : "/api/analytics/export";
+ : `${baseApiPath}/export`;

However, if the hardcoded path is intentionally a universal endpoint for non-partner analytics (while different baseApiPath values are used elsewhere), this is acceptable. Verify that admin and dashboard analytics contexts (where baseApiPath differs) either do not use this export button, or that the hardcoded /api/analytics/export endpoint is intentionally the correct path for them.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 13, 2026

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
@apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts:
- Around line 165-176: The current assignment sets email to customer.name ||
generateRandomName() when customer.email is falsy, which puts a name into the
email field; change this to produce an anonymized placeholder email instead
(e.g. use generateRandomName() combined with a fixed domain like "@anon.invalid"
or "@no-reply.example" so the email field remains syntactically an email).
Update the .parse({... customer, email: ...}) block that references
customer.email/customer.name/generateRandomName() to return that placeholder
when customer.email is missing, or alternatively adjust the consumer (the column
accessor that renders "Name <Email>") to detect missing emails and render only
the name when no valid email exists.
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts (1)

22-48: Consider extracting shared column definitions to reduce duplication.

columnNames and columnAccessors are duplicated from apps/web/app/(ee)/api/events/export/route.ts. Extract these to a shared module (e.g., @/lib/analytics/export-columns) to maintain consistency and reduce maintenance burden.

// Example: @/lib/analytics/export-columns.ts
import { ClickEvent, LeadEvent, SaleEvent } from "@/lib/types";
import { COUNTRIES, capitalize } from "@dub/utils";

export type EventRow = ClickEvent | LeadEvent | SaleEvent;

export const columnNames: Record<string, string> = {
  trigger: "Event",
  url: "Destination URL",
  // ... rest of mappings
};

export const columnAccessors: Record<string, (r: EventRow) => unknown> = {
  // ... accessors
};
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e934425 and e105f2d.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/events/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
  • apps/web/ui/analytics/events/events-export-button.tsx
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-11-17T05:19:11.972Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3113
File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:65-75
Timestamp: 2025-11-17T05:19:11.972Z
Learning: In the Dub codebase, `sendBatchEmail` (implemented in packages/email/src/send-via-resend.ts) handles filtering of emails with invalid `to` addresses internally. Call sites can safely use non-null assertions on email addresses because the email sending layer will filter out any entries with null/undefined `to` values before sending. This centralized validation pattern is intentional and removes the need for filtering at individual call sites.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
📚 Learning: 2026-01-13T12:06:42.476Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
  • apps/web/app/(ee)/api/events/export/route.ts
📚 Learning: 2025-06-16T19:21:23.506Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2519
File: apps/web/ui/analytics/utils.ts:35-37
Timestamp: 2025-06-16T19:21:23.506Z
Learning: In the `useAnalyticsFilterOption` function in `apps/web/ui/analytics/utils.ts`, the pattern `options?.context ?? useContext(AnalyticsContext)` is intentionally designed as a complete replacement strategy, not a merge. When `options.context` is provided, it should contain all required fields (`baseApiPath`, `queryString`, `selectedTab`, `requiresUpgrade`) and completely replace the React context, not be merged with it. This is used for dependency injection or testing scenarios.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically sets type="button" when an onClick prop is passed and defaults to type="submit" otherwise, using the logic: type={props.onClick ? "button" : "submit"}. This prevents accidental form submissions when buttons are used for modal triggers or other non-form actions.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts (5)
apps/web/lib/auth/partner.ts (1)
  • withPartnerProfile (48-179)
apps/web/lib/constants/partner-profile.ts (3)
  • LARGE_PROGRAM_IDS (7-7)
  • LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS (8-8)
  • MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING (5-5)
apps/web/lib/api/errors.ts (1)
  • DubApiError (43-60)
apps/web/lib/zod/schemas/partner-profile.ts (2)
  • partnerProfileEventsQuerySchema (115-123)
  • PartnerProfileLinkSchema (79-94)
apps/web/lib/analytics/get-events.ts (1)
  • getEvents (30-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 (6)
apps/web/app/(ee)/api/events/export/route.ts (1)

50-57: LGTM! Clean refactor from .and() to .extend().

The schema change is cleaner. Note that columns is required here (no .optional()), while the new partner-profile export route makes it optional with sensible defaults. Consider aligning the behavior if this endpoint should also support default columns when none are specified.

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

63-71: Good access control for large programs.

The restriction based on LARGE_PROGRAM_IDS and minimum commission threshold is a sensible gate for resource-intensive exports.


105-133: Good security validation for link ownership.

Properly validates that the requested linkId or domain/key belongs to the partner's enrolled links before fetching events. The empty links edge case is handled cleanly with an empty CSV response.


147-157: Good privacy hygiene stripping IP addresses.

The IP address removal for partner-facing exports is appropriate. The @ts-ignore comment for the deprecated ip field is acceptable—consider adding a brief note explaining why it's needed.


135-145: Smart filtering optimization based on link count.

The conditional filtering logic is well-designed—using partnerId for partners with many links avoids bloated linkIds arrays. The event parameter is correctly passed to getEvents via the ...rest spread operator, and the getEvents function expects it (renamed as eventType internally), so this is working as intended.

apps/web/ui/analytics/events/events-export-button.tsx (1)

13-28: The code is safe—EventsExportButton can only be rendered in contexts where AnalyticsProvider supplies eventsApiPath. It's structurally impossible for it to receive an undefined value.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
apps/web/ui/analytics/analytics-export-button.tsx (1)

15-44: Loading state not reset on error path after line 32.

If an error occurs during blob processing (lines 35-40), setLoading(false) on line 44 is never reached because line 42 throws. This leaves the button in a loading state.

🐛 Proposed fix using finally block
   async function exportData() {
     setLoading(true);
     try {
       // Use partner profile export endpoint if on partner page, otherwise use regular export endpoint
       const exportPath = partnerPage
         ? `${baseApiPath}/export`
         : "/api/analytics/export";

       const response = await fetch(`${exportPath}?${queryString}`, {
         method: "GET",
         headers: {
           "Content-Type": "application/json",
         },
       });

       if (!response.ok) {
-        setLoading(false);
         throw new Error(response.statusText);
       }

       const blob = await response.blob();
       const url = window.URL.createObjectURL(blob);
       const a = document.createElement("a");
       a.href = url;
       a.download = `Dub Analytics Export - ${new Date().toISOString()}.zip`;
       a.click();
     } catch (error) {
-      throw new Error(error);
+      throw error instanceof Error ? error : new Error(String(error));
+    } finally {
+      setLoading(false);
     }
-    setLoading(false);
   }
🤖 Fix all issues with AI agents
In
@apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts:
- Around line 63-90: The loop should early-return when there are no links so we
don't call getAnalytics with linkIds: []; add a check before the Promise.all
(using links.length === 0 and linkId absence) to either return an empty CSV/zip
response or skip generating analytics for link-based endpoints; ensure the check
references VALID_ANALYTICS_ENDPOINTS, linkId, links, and
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING so the downstream logic that builds the
getAnalytics args (including partnerId vs linkIds) is not invoked with an empty
links array.

In
@apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts:
- Around line 158-176: The customer transform omits name when
customerDataSharingEnabledAt is falsy and wrongly assigns customer.name to the
email fallback, causing "undefined <email>" in CSV; update the customer z.object
and parsed payload so the parsed customer always includes a name (e.g., make
name present or optional in the schema and set name: customer.name ||
generateRandomName()), and change the email fallback to a proper generated email
placeholder (use generateRandomEmail() or construct a placeholder like
`${generateRandomName()}@example.com`) instead of customer.name; ensure this
fixes the accessor that reads r.customer.name.
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/events/export/route.ts (1)

50-56: Consider handling empty columns string edge case.

If columns is an empty string, "".split(",") returns [""] (an array containing one empty string), not an empty array. This could cause unexpected behavior in the data mapping at lines 105-111, where an empty string key would not match any columnAccessors entry.

♻️ Suggested fix to handle empty string
       columns: z
         .string()
-        .transform((c) => c.split(","))
+        .transform((c) => (c ? c.split(",") : []))
         .pipe(z.string().array()),

Alternatively, add .min(1) validation before the transform if empty columns should be rejected.

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

34-48: Type-safe column accessors may fail at runtime for mismatched event types.

Several accessors are typed for specific event subtypes (e.g., url for ClickEvent, customer for LeadEvent | SaleEvent), but Row is a union of all three. If a user passes custom columns that don't match the event type, the accessor could access non-existent properties.

The fallback ?? row?.[c] at line 184 mitigates runtime errors, but the result would be undefined. Consider adding runtime guards or restricting column options per event type in the schema.


191-196: Consider using text/csv MIME type.

The standard MIME type for CSV per RFC 4180 is text/csv. While application/csv works in most browsers, text/csv is more widely recognized.

Suggested change
     return new Response(csvData, {
       headers: {
-        "Content-Type": "application/csv",
+        "Content-Type": "text/csv",
         "Content-Disposition": `attachment; filename=${event}_export.csv`,
       },
     });
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7259062 and e105f2d.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/api/events/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx
  • apps/web/ui/analytics/analytics-export-button.tsx
  • apps/web/ui/analytics/events/events-export-button.tsx
  • apps/web/ui/analytics/toggle.tsx
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-11-17T05:19:11.972Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3113
File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:65-75
Timestamp: 2025-11-17T05:19:11.972Z
Learning: In the Dub codebase, `sendBatchEmail` (implemented in packages/email/src/send-via-resend.ts) handles filtering of emails with invalid `to` addresses internally. Call sites can safely use non-null assertions on email addresses because the email sending layer will filter out any entries with null/undefined `to` values before sending. This centralized validation pattern is intentional and removes the need for filtering at individual call sites.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
📚 Learning: 2026-01-13T12:06:42.476Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts
  • apps/web/app/(ee)/api/events/export/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
📚 Learning: 2025-06-16T19:21:23.506Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2519
File: apps/web/ui/analytics/utils.ts:35-37
Timestamp: 2025-06-16T19:21:23.506Z
Learning: In the `useAnalyticsFilterOption` function in `apps/web/ui/analytics/utils.ts`, the pattern `options?.context ?? useContext(AnalyticsContext)` is intentionally designed as a complete replacement strategy, not a merge. When `options.context` is provided, it should contain all required fields (`baseApiPath`, `queryString`, `selectedTab`, `requiresUpgrade`) and completely replace the React context, not be merged with it. This is used for dependency injection or testing scenarios.

Applied to files:

  • apps/web/ui/analytics/analytics-export-button.tsx
  • apps/web/ui/analytics/events/events-export-button.tsx
  • apps/web/ui/analytics/toggle.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically sets type="button" when an onClick prop is passed and defaults to type="submit" otherwise, using the logic: type={props.onClick ? "button" : "submit"}. This prevents accidental form submissions when buttons are used for modal triggers or other non-form actions.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/analytics/events/events-export-button.tsx
🧬 Code graph analysis (5)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts (6)
apps/web/lib/auth/partner.ts (1)
  • withPartnerProfile (48-179)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-67)
apps/web/lib/constants/partner-profile.ts (3)
  • LARGE_PROGRAM_IDS (7-7)
  • LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS (8-8)
  • MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING (5-5)
apps/web/lib/api/errors.ts (1)
  • DubApiError (43-60)
apps/web/lib/zod/schemas/partner-profile.ts (2)
  • partnerProfileEventsQuerySchema (115-123)
  • PartnerProfileLinkSchema (79-94)
apps/web/lib/analytics/get-events.ts (1)
  • getEvents (30-176)
apps/web/ui/analytics/analytics-export-button.tsx (1)
apps/web/ui/analytics/analytics-provider.tsx (1)
  • AnalyticsContext (56-99)
apps/web/ui/analytics/events/events-export-button.tsx (1)
apps/web/ui/analytics/analytics-provider.tsx (1)
  • AnalyticsContext (56-99)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx (1)
apps/web/ui/layout/page-content/index.tsx (1)
  • PageContent (10-39)
apps/web/ui/analytics/toggle.tsx (1)
apps/web/ui/analytics/analytics-options.tsx (1)
  • AnalyticsOptions (8-39)
🔇 Additional comments (8)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx (1)

2-10: LGTM!

The EventsProvider wrapper is correctly added to provide the necessary context (exportQueryString) for the EventsExportButton component used within the Events page. This aligns with the broader PR changes enabling partner analytics/events export.

apps/web/ui/analytics/toggle.tsx (1)

271-271: LGTM!

Removing the conditional rendering allows AnalyticsOptions (containing export buttons) to render on partner pages. This aligns with the PR's goal of enabling partner analytics/events export, and the export button components have been updated to handle partner pages via context-based API paths.

apps/web/ui/analytics/events/events-export-button.tsx (1)

14-28: No action needed. eventsApiPath is always defined when EventsExportButton is rendered. The component is only used in three routes — workspace analytics (/[slug]/events), admin (/admin/events), and partner program events — all of which initialize eventsApiPath before rendering. There is no path through the codebase where EventsExportButton can render with an undefined or empty eventsApiPath.

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

50-71: LGTM!

Authentication via withPartnerProfile, enrollment validation, and the large program access check are correctly implemented and consistent with the analytics export route.


73-103: LGTM!

The query parameter parsing with the columns transform and default column mappings per event type are well structured.


105-133: LGTM!

Link validation logic correctly checks ownership before allowing export, and the empty links case returns a well-formed empty CSV response.

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

16-61: LGTM!

The enrollment fetch, large program access check, and link validation are correctly implemented and consistent with the events export route.


92-100: LGTM!

The ZIP response is correctly constructed. The as unknown as BodyInit cast is a common pattern to bridge Node.js Buffer types with web API BodyInit.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

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

77-102: Consider using Promise.allSettled for resilience.

If getAnalytics throws for any single endpoint, the entire export fails. Using Promise.allSettled would allow partial exports when some endpoints succeed while others fail, providing a better user experience.

♻️ Optional: Use Promise.allSettled for partial export resilience
-    await Promise.all(
+    const results = await Promise.allSettled(
       VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => {
         // no need to fetch top links data if there's a link specified
         // since this is just a single link
         if (endpoint === "top_links" && linkId) return;
         // skip clicks count
         if (endpoint === "count") return;

         const response = await getAnalytics({
           ...rest,
           workspaceId: program.workspaceId,
           ...(linkId
             ? { linkId }
             : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING
               ? { partnerId: partner.id }
               : { linkIds: links.map((link) => link.id) }),
           dataAvailableFrom: program.startedAt ?? program.createdAt,
           groupBy: endpoint,
         });

         if (!response || response.length === 0) return;

-        const csvData = convertToCSV(response);
-        zip.file(`${endpoint}.csv`, csvData);
+        return { endpoint, response };
       }),
     );
+
+    for (const result of results) {
+      if (result.status === "fulfilled" && result.value) {
+        const { endpoint, response } = result.value;
+        const csvData = convertToCSV(response);
+        zip.file(`${endpoint}.csv`, csvData);
+      }
+    }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e105f2d and 40c993d.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
📚 Learning: 2026-01-13T12:06:42.476Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.

Applied to files:

  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts (4)

16-36: LGTM!

The access control logic properly validates program enrollment and enforces commission-based restrictions for large programs before allowing export access.


38-61: LGTM!

The link resolution logic correctly validates that the requested link belongs to the partner's enrolled links, preventing unauthorized access to other partners' analytics data.


63-73: The early return logic is correct.

The && !linkId condition is technically redundant (if links.length === 0 and a linkId was requested, a not_found error would have already been thrown), but it makes the intent clearer and serves as a defensive check.


104-112: LGTM!

The response correctly sets the Content-Type and Content-Disposition headers for a ZIP file download.

@steven-tey steven-tey merged commit 6f0db3f into main Jan 13, 2026
7 checks passed
@steven-tey steven-tey deleted the partner-analytics-export branch January 13, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant