-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add Export Applications feature #2800
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a new CSV export flow for program applications (server route, modal, and menu integration), introduces export column schema constants, tweaks partners export route header string, and updates export-related modals and commission/schema handling elsewhere. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant Menu as Applications Menu (UI)
participant Modal as ExportApplicationsModal (UI)
participant API as Server: /api/programs/:programId/applications/export
participant DB as Prisma
participant CSV as CSV generator
participant Browser as Browser Download
User->>Menu: open menu
Menu->>Modal: open export modal
User->>Modal: select columns, click Export
Modal->>API: GET /api/programs/:id/applications/export?columns=...
Note over API: auth + plan check
API->>DB: query pending enrollments + includes
DB-->>API: enrollment + application data
API->>CSV: map rows, validate (dynamic Zod), convert to CSV
CSV-->>API: CSV text
API-->>Modal: 200 OK (text/csv)
Modal->>Browser: create blob & trigger download
Browser-->>User: save "applications-<timestamp>.csv"
Modal->>Modal: close modal
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
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
🧹 Nitpick comments (17)
apps/web/app/(ee)/api/partners/export/route.ts (1)
79-84: Include filename and charset in CSV response headersImprove download UX and cross-browser compatibility by adding a filename and explicit charset.
return new Response(convertToCSV(formattedPartners), { headers: { - "Content-Type": "text/csv", - "Content-Disposition": "attachment", + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="partners.csv"; filename*=UTF-8''partners.csv`, }, });apps/web/lib/zod/schemas/partners.ts (2)
76-91: Co-locate “default” flags with column metadata and tighten typingThis keeps defaults DRY and enables safer reuse by routes and UI.
-export const exportApplicationColumns = [ - { id: "id", label: "ID" }, - { id: "name", label: "Name" }, - { id: "email", label: "Email" }, - { id: "country", label: "Country" }, - { id: "createdAt", label: "Applied at" }, - { id: "description", label: "Description" }, - { id: "website", label: "Website" }, - { id: "youtube", label: "YouTube" }, - { id: "twitter", label: "Twitter" }, - { id: "linkedin", label: "LinkedIn" }, - { id: "instagram", label: "Instagram" }, - { id: "tiktok", label: "TikTok" }, - { id: "proposal", label: "Proposal" }, - { id: "comments", label: "Comments" }, -]; +export const exportApplicationColumns = [ + { id: "id", label: "ID", default: true }, + { id: "name", label: "Name", default: true }, + { id: "email", label: "Email", default: true }, + { id: "country", label: "Country", default: true }, + { id: "createdAt", label: "Applied at", default: true }, + { id: "description", label: "Description" }, + { id: "website", label: "Website", default: true }, + { id: "youtube", label: "YouTube", default: true }, + { id: "twitter", label: "Twitter" }, + { id: "linkedin", label: "LinkedIn", default: true }, + { id: "instagram", label: "Instagram" }, + { id: "tiktok", label: "TikTok" }, + { id: "proposal", label: "Proposal" }, + { id: "comments", label: "Comments" }, +] as const;Optionally also export:
export type ApplicationExportColumnId = (typeof exportApplicationColumns)[number]["id"];
93-102: Derive defaults from the source listAvoids drift between the columns list and defaults.
-export const exportApplicationsColumnsDefault = [ - "id", - "name", - "email", - "country", - "createdAt", - "website", - "youtube", - "linkedin", -]; +export const exportApplicationsColumnsDefault = exportApplicationColumns + .filter((c) => c.default) + .map((c) => c.id);apps/web/ui/modals/export-partners-modal.tsx (4)
112-151: Columns section UX: prevent exporting with zero columnsIf users unselect all, the API falls back to defaults, which can surprise. Either disable Export when none selected or show a helper/error.
74-79: Prefer Accept header over Content-Type on GETAdvertise expected response type.
- const response = await fetch(`/api/partners/export${searchParams}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); + const response = await fetch(`/api/partners/export${searchParams}`, { + method: "GET", + headers: { Accept: "text/csv" }, + });
86-93: Revoke object URL after downloadAvoids small memory leaks in long-lived sessions.
const a = document.createElement("a"); a.href = url; a.download = `Dub Partners Export - ${new Date().toISOString()}.csv`; a.click(); + a.remove(); + window.URL.revokeObjectURL(url);
96-99: Surface error messages cleanly in toastEnsure string is shown, not an Error object.
- } catch (error) { - toast.error(error); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to export partners."; + toast.error(message);apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (2)
84-89: Add filename and charset to response headersAligns with partners export and improves UX.
- return new Response(convertToCSV(formattedApplications), { + return new Response(convertToCSV(formattedApplications), { headers: { - "Content-Type": "text/csv", - "Content-Disposition": "attachment", + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="applications.csv"; filename*=UTF-8''applications.csv`, }, });
29-38: Consider paging/upper bound for very large exportsUnbounded findMany on pending can be heavy. Either cap (e.g., 10k), stream, or batch.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx (3)
85-99: Use a “disable” icon for disabling auto-approve (UX clarity).Swap the red UserCheck for UserXmark to better match the action semantics.
- <UserCheck className="size-4 shrink-0 text-red-600" /> + <UserXmark className="size-4 shrink-0 text-red-600" />
115-123: Guard against missing workspaceSlug before navigation.Prevents pushing an /undefined/... route if slug hasn’t resolved yet.
- router.push( - `/${workspaceSlug}/program/partners/applications/rejected`, - ); + if (!workspaceSlug) { + toast.error("Workspace not loaded yet."); + return; + } + router.push(`/${workspaceSlug}/program/partners/applications/rejected`);
156-161: Disable menu trigger until workspace context is ready.Avoids confirm actions that rely on
workspaceId!when it’s not yet available.- disabled={isUpdatingAutoApprove || !program} + disabled={ + isUpdatingAutoApprove || !program || !workspaceId || !workspaceSlug + }apps/web/ui/modals/export-applications-modal.tsx (5)
7-7: Remove unused router helper.
useRouterStuff/getQueryStringis imported but unused; will trip lint.-import { Button, Checkbox, Modal, useRouterStuff } from "@dub/ui"; +import { Button, Checkbox, Modal } from "@dub/ui"; @@ - const { getQueryString } = useRouterStuff();Also applies to: 33-34
45-49: Surface missing context to the user.Early return is silent; show a toast so users know what happened.
- if (!workspaceId || !program?.id) { - return; - } + if (!workspaceId || !program?.id) { + toast.error("Workspace or program not loaded. Please try again."); + return; + }
53-66: Avoid setting Content-Type on GET; add small hardening.Unnecessary header can cause preflight; drop it.
- const response = await fetch( + const response = await fetch( `/api/programs/${program.id}/applications/export?${new URLSearchParams({ workspaceId: workspaceId, ...(data.columns.length ? { columns: data.columns.join(",") } : undefined), })}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }, + { method: "GET" }, );
113-126: Checkbox tri-state and dedup guard.Ensure boolean handling and prevent accidental duplicates.
- onCheckedChange={(checked) => { - field.onChange( - checked - ? [...field.value, id] - : field.value.filter((value) => value !== id), - ); - }} + onCheckedChange={(checked) => { + field.onChange( + checked === true + ? Array.from(new Set([...field.value, id])) + : field.value.filter((value) => value !== id), + ); + }}
151-155: Optionally disable Export when no columns selected.Prevents empty submissions and clarifies state.
- <Button + <Button type="submit" loading={isSubmitting} text="Export applications" className="h-8 w-fit px-3" + disabled={isSubmitting /* plus: watch length if desired */} />If you want full UX, I can wire up
useWatchto disable whencolumns.length === 0.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (6)
apps/web/app/(ee)/api/partners/export/route.ts(1 hunks)apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx(2 hunks)apps/web/lib/zod/schemas/partners.ts(1 hunks)apps/web/ui/modals/export-applications-modal.tsx(1 hunks)apps/web/ui/modals/export-partners-modal.tsx(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/ui/modals/export-applications-modal.tsx (2)
apps/web/lib/swr/use-workspace.ts (1)
useWorkspace(6-45)apps/web/lib/zod/schemas/partners.ts (2)
exportApplicationsColumnsDefault(93-102)exportApplicationColumns(76-91)
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (3)
apps/web/lib/zod/schemas/partners.ts (2)
exportApplicationColumns(76-91)exportApplicationsColumnsDefault(93-102)apps/web/app/(ee)/api/partners/export/route.ts (1)
GET(21-96)apps/web/lib/auth/workspace.ts (1)
withWorkspace(41-435)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx (1)
apps/web/ui/modals/export-applications-modal.tsx (1)
useExportApplicationsModal(162-182)
apps/web/ui/modals/export-partners-modal.tsx (2)
apps/web/lib/zod/schemas/partners.ts (1)
exportPartnerColumns(29-61)packages/ui/src/tooltip.tsx (1)
InfoTooltip(193-199)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (7)
apps/web/ui/modals/export-partners-modal.tsx (3)
108-110: Header polish LGTMClear, concise header structure.
153-167: “Apply current filters” control LGTMGood clarity and tooltip usage.
169-183: Actions area LGTMCancel button is a helpful addition; submit state is wired.
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (1)
68-79: InvestigateconvertToCSVfor built-in cell sanitizationPlease verify whether
convertToCSValready escapes leading=,+,-, or@. If it does not, prepend a single quote withinroute.tsas shown.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx (2)
67-75: Nice modal integration.Hook + once-mounted modal pattern looks clean and avoids remount glitches.
135-151: Export entrypoint looks good.Clear label and closes popover before opening modal. Solid UX.
apps/web/ui/modals/export-applications-modal.tsx (1)
90-159: Overall modal structure looks solid.Form wiring, defaults, and success path are clear.
| const { error } = await response.json(); | ||
| throw new Error(error.message); |
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.
Error handling assumes error response has a specific structure but doesn't handle cases where the response isn't valid JSON or lacks the expected error.message property.
View Details
📝 Patch Details
diff --git a/apps/web/ui/modals/export-applications-modal.tsx b/apps/web/ui/modals/export-applications-modal.tsx
index 423c08354..cd034466c 100644
--- a/apps/web/ui/modals/export-applications-modal.tsx
+++ b/apps/web/ui/modals/export-applications-modal.tsx
@@ -66,8 +66,14 @@ function ExportApplicationsModal({
);
if (!response.ok) {
- const { error } = await response.json();
- throw new Error(error.message);
+ let errorMessage = "Export failed";
+ try {
+ const errorData = await response.json();
+ errorMessage = errorData?.error?.message || errorData?.message || errorMessage;
+ } catch {
+ // If JSON parsing fails, use default message
+ }
+ throw new Error(errorMessage);
}
const blob = await response.blob();
Analysis
The error handling on lines 69-70 assumes that when response.ok is false, the response body will be valid JSON with an error object containing a message property. However, this code doesn't handle several potential failure scenarios:
- If the response body is not valid JSON,
response.json()will throw an error - If the JSON doesn't have an
errorproperty, the destructuring will fail - If
error.messageis undefined,new Error(undefined)creates an error with message "undefined"
This can lead to confusing error messages or unhandled promise rejections. The error handling should be more robust:
if (!response.ok) {
let errorMessage = 'Export failed';
try {
const errorData = await response.json();
errorMessage = errorData?.error?.message || errorData?.message || errorMessage;
} catch {
// If JSON parsing fails, use default message
}
throw new Error(errorMessage);
}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
♻️ Duplicate comments (2)
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (2)
16-22: Sanitize and allow‑list “columns” to avoid undefined CSV headersCurrent split can yield empty/unknown ids → “undefined” header cells. Trim, dedupe, and filter against known ids; require at least one valid column.
@@ -const applicationsExportQuerySchema = z.object({ - columns: z - .string() - .optional() - .transform((v) => v?.split(",") || exportApplicationsColumnsDefault) - .default(exportApplicationsColumnsDefault.join(",")), -}); +const allowedApplicationColumnIds = new Set( + exportApplicationColumns.map((c) => c.id), +); + +const applicationsExportQuerySchema = z.object({ + columns: z + .string() + .optional() + .default(exportApplicationsColumnsDefault.join(",")) + .transform((v) => + v.split(",").map((s) => s.trim()).filter(Boolean), + ) + .transform((arr) => + Array.from(new Set(arr.filter((id) => allowedApplicationColumnIds.has(id)))), + ) + .refine((arr) => arr.length > 0, { message: "No valid columns specified" }), +});
25-29: Honor [programId] path param and enforce workspace scopingRoute ignores the path param and always uses the default program. Validate the param belongs to the current workspace and query by it to avoid exporting the wrong program’s data.
-export const GET = withWorkspace( - async ({ searchParams, workspace }) => { - const programId = getDefaultProgramIdOrThrow(workspace); +export const GET = withWorkspace( + async ({ searchParams, params, workspace }) => { + const { programId } = params as { programId: string }; + const program = await prisma.program.findFirst({ + where: { id: programId, workspaceId: workspace.id }, + select: { id: true }, + }); + if (!program) { + return new Response("Program not found", { status: 404 }); + } @@ - const programEnrollments = await prisma.programEnrollment.findMany({ + const programEnrollments = await prisma.programEnrollment.findMany({ where: { - programId, + programId: program.id, status: "pending", }, include: { partner: true, application: true, }, + orderBy: { createdAt: "desc" }, + take: 5000, });Additional (outside this hunk): remove the now-unused import if present.
-import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";Also applies to: 31-35
🧹 Nitpick comments (6)
apps/web/lib/tolt/schemas.ts (1)
65-71: Clarify revenue unit and confirm null handling
- Update description in
apps/web/lib/tolt/schemas.ts:- .describe("Revenue of the transaction in cents."), + .describe("Revenue of the transaction in the smallest currency unit (e.g., cents for USD)."),
- Downstream usage:
sale.revenueis null-coalesced to 0 inapps/web/lib/tolt/import-commissions.ts:214; no other numeric conversions of.revenueand no external uses ofcharge_id.apps/web/lib/tolt/import-commissions.ts (3)
213-215: Harden parsing of nullable revenue stringsGuard against NaN/non-numeric values to avoid propagating NaN into writes and comparisons.
- // Sale amount (can potentially be null) - let saleAmount = Number(sale.revenue ?? 0); + // Sale amount (nullable string from Tolt) -> integer smallest unit + const rawRevenue = sale.revenue; + let saleAmount = + typeof rawRevenue === "string" ? Number.parseInt(rawRevenue, 10) : 0; + if (!Number.isFinite(saleAmount)) saleAmount = 0;
253-254: Avoid false-positive dedupe when saleAmount is 0Zero-amount imports within the ±1h window may collide. Consider applying the amount match only when
saleAmount > 0.- amount: saleAmount, + ...(saleAmount > 0 ? { amount: saleAmount } : {}),
324-324: Confirm business rule: create 0-amount “sale” commissions?You still insert a commission when
saleAmountis 0. If these shouldn’t be persisted, gate the create bysaleAmount > 0; otherwise, all good.apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (2)
65-69: Avoid rebuilding Zod schema per row; add typingHoist the row schema to reduce per-item allocations and add a concrete type for schemaFields.
- const schemaFields = {}; + const schemaFields: Record<string, z.ZodTypeAny> = {}; columns.forEach((column) => { schemaFields[columnIdToLabel[column]] = z.string().optional().default(""); }); - const formattedApplications = applications.map((application) => { + const rowSchema = z.object(schemaFields); + + const formattedApplications = applications.map((application) => { @@ - return z.object(schemaFields).parse(result); + return rowSchema.parse(result); });Also applies to: 83-84
86-91: Set a download filename in Content‑DispositionMinor UX win and aligns with common CSV routes.
return new Response(convertToCSV(formattedApplications), { headers: { "Content-Type": "text/csv", - "Content-Disposition": "attachment", + "Content-Disposition": 'attachment; filename="applications.csv"', }, });
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts(1 hunks)apps/web/lib/tolt/import-commissions.ts(5 hunks)apps/web/lib/tolt/schemas.ts(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-06-18T20:26:25.177Z
Learnt from: TWilson023
PR: dubinc/dub#2538
File: apps/web/ui/partners/overview/blocks/commissions-block.tsx:16-27
Timestamp: 2025-06-18T20:26:25.177Z
Learning: In the Dub codebase, components that use workspace data (workspaceId, defaultProgramId) are wrapped in `WorkspaceAuth` which ensures these values are always available, making non-null assertions safe. This is acknowledged as a common pattern in their codebase, though not ideal.
Applied to files:
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts (3)
apps/web/lib/zod/schemas/partners.ts (2)
exportApplicationColumns(76-91)exportApplicationsColumnsDefault(93-102)apps/web/app/(ee)/api/partners/export/route.ts (1)
GET(21-96)apps/web/lib/auth/workspace.ts (1)
withWorkspace(41-435)
apps/web/lib/tolt/import-commissions.ts (1)
apps/web/lib/analytics/convert-currency.ts (1)
convertCurrencyWithFxRates(57-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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (4)
apps/web/lib/tolt/import-commissions.ts (4)
219-224: LGTM: reuse converted saleAmountUsing
convertCurrencyWithFxRatesand reassigningsaleAmountis correct and consistent with earnings conversion.
335-347: LGTM: gate sale event emission on positive amountThis prevents noisy “Invoice paid” events for zero/negative amounts.
349-349: LGTM: conditional link stats updatesIncrementing sales/saleAmount only when
saleAmount > 0is appropriate. If refunds are expected to arrive here with negative amounts, confirm they’re handled elsewhere (e.g., separate refund flow) so aggregates reconcile.Also applies to: 363-370
374-389: LGTM: conditional customer stats updatesSame note as link stats regarding refunds; otherwise this aligns with the gating logic.
| const applications = programEnrollments.map( | ||
| ({ partner, application, ...programEnrollment }) => { | ||
| return { | ||
| ...partner, | ||
| createdAt: application?.createdAt || programEnrollment.createdAt, | ||
| proposal: application?.proposal || "", | ||
| comments: application?.comments || "", | ||
| }; | ||
| }, | ||
| ); |
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.
Populate “description” from application; currently always empty in CSV
You map proposal/comments but omit description, so selected “Description” column exports blanks.
return {
...partner,
createdAt: application?.createdAt || programEnrollment.createdAt,
+ description: application?.description || "",
proposal: application?.proposal || "",
comments: application?.comments || "",
};📝 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 applications = programEnrollments.map( | |
| ({ partner, application, ...programEnrollment }) => { | |
| return { | |
| ...partner, | |
| createdAt: application?.createdAt || programEnrollment.createdAt, | |
| proposal: application?.proposal || "", | |
| comments: application?.comments || "", | |
| }; | |
| }, | |
| ); | |
| const applications = programEnrollments.map( | |
| ({ partner, application, ...programEnrollment }) => { | |
| return { | |
| ...partner, | |
| createdAt: application?.createdAt || programEnrollment.createdAt, | |
| description: application?.description || "", | |
| proposal: application?.proposal || "", | |
| comments: application?.comments || "", | |
| }; | |
| }, | |
| ); |
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts
around lines 42 to 51, the mapped application objects include proposal and
comments but omit description so the exported CSV "Description" column is blank;
update the returned object to include description: set it to
application?.description || "" (similar to proposal/comments) so description is
populated from the application when present and falls back to an empty string.
Summary by CodeRabbit
New Features
Style
Bug Fixes
Chores