From 5d4c9006cccd4f12aec12f1c9863e7ce6ad4d340 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 1 Sep 2025 12:17:19 -0700 Subject: [PATCH 1/2] Add Export Applications feature --- .../web/app/(ee)/api/partners/export/route.ts | 2 +- .../[programId]/applications/export/route.ts | 101 ++++++++++ .../applications/applications-menu.tsx | 97 ++++++---- apps/web/lib/zod/schemas/partners.ts | 28 +++ .../ui/modals/export-applications-modal.tsx | 182 ++++++++++++++++++ apps/web/ui/modals/export-partners-modal.tsx | 121 ++++++------ 6 files changed, 442 insertions(+), 89 deletions(-) create mode 100644 apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts create mode 100644 apps/web/ui/modals/export-applications-modal.tsx diff --git a/apps/web/app/(ee)/api/partners/export/route.ts b/apps/web/app/(ee)/api/partners/export/route.ts index 33f6853f958..73319a7131f 100644 --- a/apps/web/app/(ee)/api/partners/export/route.ts +++ b/apps/web/app/(ee)/api/partners/export/route.ts @@ -79,7 +79,7 @@ export const GET = withWorkspace( return new Response(convertToCSV(formattedPartners), { headers: { "Content-Type": "text/csv", - "Content-Disposition": `attachment`, + "Content-Disposition": "attachment", }, }); }, diff --git a/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts b/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts new file mode 100644 index 00000000000..ee960d5c242 --- /dev/null +++ b/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts @@ -0,0 +1,101 @@ +import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; +import { withWorkspace } from "@/lib/auth"; +import { + exportApplicationColumns, + exportApplicationsColumnsDefault, +} from "@/lib/zod/schemas/partners"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; + +const columnIdToLabel = exportApplicationColumns.reduce((acc, column) => { + acc[column.id] = column.label; + return acc; +}, {}); + +const applicationsExportQuerySchema = z.object({ + columns: z + .string() + .optional() + .transform((v) => v?.split(",") || exportApplicationsColumnsDefault) + .default(exportApplicationsColumnsDefault.join(",")), +}); + +// GET /api/programs/[programId]/applications/export – export applications to CSV +export const GET = withWorkspace( + async ({ searchParams, params }) => { + const { programId } = params; + let { columns } = applicationsExportQuerySchema.parse(searchParams); + + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + status: "pending", + }, + include: { + partner: true, + application: true, + }, + }); + + const applications = programEnrollments.map( + ({ partner, application, ...programEnrollment }) => { + return { + ...partner, + createdAt: application?.createdAt || programEnrollment.createdAt, + proposal: application?.proposal || "", + comments: application?.comments || "", + }; + }, + ); + + const columnOrderMap = exportApplicationColumns.reduce( + (acc, column, index) => { + acc[column.id] = index + 1; + return acc; + }, + {}, + ); + + columns = columns.sort( + (a, b) => (columnOrderMap[a] || 999) - (columnOrderMap[b] || 999), + ); + + const schemaFields = {}; + columns.forEach((column) => { + schemaFields[columnIdToLabel[column]] = z.string().optional().default(""); + }); + + const formattedApplications = applications.map((application) => { + const result = {}; + + columns.forEach((column) => { + if (column === "createdAt") { + result[columnIdToLabel[column]] = application[column] + ? new Date(application[column]).toISOString() + : ""; + } else { + result[columnIdToLabel[column]] = application[column] || ""; + } + }); + + return z.object(schemaFields).parse(result); + }); + + return new Response(convertToCSV(formattedApplications), { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": "attachment", + }, + }); + }, + { + requiredPlan: [ + "business", + "business extra", + "business max", + "business plus", + "advanced", + "enterprise", + ], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx index e4f604afa73..68883d7fc69 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx @@ -5,16 +5,10 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { useConfirmModal } from "@/ui/modals/confirm-modal"; +import { useExportApplicationsModal } from "@/ui/modals/export-applications-modal"; import { ThreeDots } from "@/ui/shared/icons"; -import { - Button, - LoadingSpinner, - MenuItem, - Popover, - UserCheck, - UserXmark, -} from "@dub/ui"; -import { Command } from "cmdk"; +import { Button, LoadingSpinner, Popover, UserCheck, UserXmark } from "@dub/ui"; +import { Download } from "@dub/ui/icons"; import { useAction } from "next-safe-action/hooks"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -70,55 +64,92 @@ export function ApplicationsMenu() { }, }); + const { setShowExportApplicationsModal, ExportApplicationsModal } = + useExportApplicationsModal(); + return ( <> {confirmEnableAutoApproveModal} {confirmDisableAutoApproveModal} + - +
+
+

+ Application Settings +

{program?.autoApprovePartnersEnabledAt ? ( - } - onSelect={() => { + ) : ( - - } - onSelect={() => { + )} - { + +
+ +
+ +
+

+ Export Applications +

+ +
+
} align="end" > diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index df0edf038bd..f31f1aef38d 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -73,6 +73,34 @@ export const exportPartnersColumnsDefault = exportPartnerColumns .filter((column) => column.default) .map((column) => column.id); +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 exportApplicationsColumnsDefault = [ + "id", + "name", + "email", + "country", + "createdAt", + "website", + "youtube", + "linkedin", +]; + export const getPartnersQuerySchema = z .object({ status: z diff --git a/apps/web/ui/modals/export-applications-modal.tsx b/apps/web/ui/modals/export-applications-modal.tsx new file mode 100644 index 00000000000..423c0835498 --- /dev/null +++ b/apps/web/ui/modals/export-applications-modal.tsx @@ -0,0 +1,182 @@ +import useProgram from "@/lib/swr/use-program"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { + exportApplicationColumns, + exportApplicationsColumnsDefault, +} from "@/lib/zod/schemas/partners"; +import { Button, Checkbox, Modal, useRouterStuff } from "@dub/ui"; +import { + Dispatch, + SetStateAction, + useCallback, + useId, + useMemo, + useState, +} from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; + +interface FormData { + columns: string[]; +} + +function ExportApplicationsModal({ + showExportApplicationsModal, + setShowExportApplicationsModal, +}: { + showExportApplicationsModal: boolean; + setShowExportApplicationsModal: Dispatch>; +}) { + const columnCheckboxId = useId(); + const { program } = useProgram(); + const { id: workspaceId } = useWorkspace(); + const { getQueryString } = useRouterStuff(); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + columns: exportApplicationsColumnsDefault, + }, + }); + + const onSubmit = handleSubmit(async (data) => { + if (!workspaceId || !program?.id) { + return; + } + + const lid = toast.loading("Exporting applications..."); + + try { + 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", + }, + }, + ); + + if (!response.ok) { + const { error } = await response.json(); + throw new Error(error.message); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + + a.href = url; + a.download = `Dub Applications Export - ${new Date().toISOString()}.csv`; + a.click(); + + toast.success("Exported successfully"); + setShowExportApplicationsModal(false); + } catch (error) { + toast.error(error); + } finally { + toast.dismiss(lid); + } + }); + + return ( + +
+

+ Export applications +

+
+ +
+
+
+
+

+ Columns +

+ ( +
+ {exportApplicationColumns.map(({ id, label }) => ( +
+ { + field.onChange( + checked + ? [...field.value, id] + : field.value.filter((value) => value !== id), + ); + }} + /> + +
+ ))} +
+ )} + /> +
+
+
+ +
+
+
+
+ ); +} + +export function useExportApplicationsModal() { + const [showExportApplicationsModal, setShowExportApplicationsModal] = + useState(false); + + const ExportApplicationsModalCallback = useCallback(() => { + return ( + + ); + }, [showExportApplicationsModal, setShowExportApplicationsModal]); + + return useMemo( + () => ({ + setShowExportApplicationsModal, + ExportApplicationsModal: ExportApplicationsModalCallback, + }), + [setShowExportApplicationsModal, ExportApplicationsModalCallback], + ); +} diff --git a/apps/web/ui/modals/export-partners-modal.tsx b/apps/web/ui/modals/export-partners-modal.tsx index 81894da3a83..5f8329d0b94 100644 --- a/apps/web/ui/modals/export-partners-modal.tsx +++ b/apps/web/ui/modals/export-partners-modal.tsx @@ -8,7 +8,6 @@ import { Button, Checkbox, InfoTooltip, - Logo, Modal, Switch, useRouterStuff, @@ -106,70 +105,82 @@ function ExportPartnersModal({ showModal={showExportPartnersModal} setShowModal={setShowExportPartnersModal} > -
- -
-

Export partners

-

- Export this program's partners to a CSV file -

-
+
+

Export partners

-
-
-

Columns

+ +
+
+
+

+ Columns +

+ ( +
+ {exportPartnerColumns.map(({ id, label }) => ( +
+ { + field.onChange( + checked + ? [...field.value, id] + : field.value.filter((value) => value !== id), + ); + }} + /> + +
+ ))} +
+ )} + /> +
+
+
+ +
( -
- {exportPartnerColumns.map(({ id, label }) => ( -
- { - field.onChange( - checked - ? [...field.value, id] - : field.value.filter((value) => value !== id), - ); - }} - /> - -
- ))} +
+ + Apply current filters + + +
)} />
-
- - ( -
- - Apply current filters - - - -
- )} - /> -
); From fd9c30bf1b212142172bcbb0434b7f3f7a64c1c8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 1 Sep 2025 13:08:15 -0700 Subject: [PATCH 2/2] account for null sale.revenue in tolt importer --- .../[programId]/applications/export/route.ts | 6 +- apps/web/lib/tolt/import-commissions.ts | 78 ++++++++++--------- apps/web/lib/tolt/schemas.ts | 9 ++- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts b/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts index ee960d5c242..e3c13cad9d0 100644 --- a/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts +++ b/apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts @@ -1,4 +1,5 @@ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { exportApplicationColumns, @@ -22,8 +23,9 @@ const applicationsExportQuerySchema = z.object({ // GET /api/programs/[programId]/applications/export – export applications to CSV export const GET = withWorkspace( - async ({ searchParams, params }) => { - const { programId } = params; + async ({ searchParams, workspace }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + let { columns } = applicationsExportQuerySchema.parse(searchParams); const programEnrollments = await prisma.programEnrollment.findMany({ diff --git a/apps/web/lib/tolt/import-commissions.ts b/apps/web/lib/tolt/import-commissions.ts index 14d72830ff8..520f7f5c531 100644 --- a/apps/web/lib/tolt/import-commissions.ts +++ b/apps/web/lib/tolt/import-commissions.ts @@ -210,17 +210,17 @@ async function createCommission({ return; } - // Sale amount - let amount = Number(sale.amount); + // Sale amount (can potentially be null) + let saleAmount = Number(sale.revenue ?? 0); if (programCurrency.toUpperCase() !== "USD" && fxRates) { const { amount: convertedAmount } = convertCurrencyWithFxRates({ currency: programCurrency, - amount, + amount: saleAmount, fxRates, }); - amount = convertedAmount; + saleAmount = convertedAmount; } // Earnings @@ -250,7 +250,7 @@ async function createCommission({ }, customerId: customerFound.id, type: "sale", - amount, + amount: saleAmount, }, }); @@ -321,7 +321,7 @@ async function createCommission({ partnerId: customerFound.link.partnerId, linkId: customerFound.linkId, customerId: customerFound.id, - amount, + amount: saleAmount, earnings, // TODO: allow custom "defaultCurrency" on workspace table in the future currency: "usd", @@ -332,20 +332,21 @@ async function createCommission({ }, }), - recordSaleWithTimestamp({ - ...clickData, - event_id: eventId, - event_name: "Invoice paid", - amount, - customer_id: customerFound.id, - payment_processor: "stripe", - // TODO: allow custom "defaultCurrency" on workspace table in the future - currency: "usd", - metadata: JSON.stringify(commission), - timestamp: new Date(sale.created_at).toISOString(), - }), + saleAmount > 0 && + recordSaleWithTimestamp({ + ...clickData, + event_id: eventId, + event_name: "Invoice paid", + amount: saleAmount, + customer_id: customerFound.id, + payment_processor: "stripe", + // TODO: allow custom "defaultCurrency" on workspace table in the future + currency: "usd", + metadata: JSON.stringify(commission), + timestamp: new Date(sale.created_at).toISOString(), + }), - // update link stats + // update link stats (if sale amount is greater than 0) prisma.link.update({ where: { id: customerFound.linkId, @@ -359,29 +360,32 @@ async function createCommission({ increment: 1, }, }), - sales: { - increment: 1, - }, - saleAmount: { - increment: amount, - }, + ...(saleAmount > 0 && { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + }), }, }), - // update customer stats - prisma.customer.update({ - where: { - id: customerFound.id, - }, - data: { - sales: { - increment: 1, + // update customer stats (if sale amount is greater than 0) + saleAmount > 0 && + prisma.customer.update({ + where: { + id: customerFound.id, }, - saleAmount: { - increment: amount, + data: { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, }, - }, - }), + }), ]); await syncTotalCommissions({ diff --git a/apps/web/lib/tolt/schemas.ts b/apps/web/lib/tolt/schemas.ts index fc1a6a0be03..6cf3b1129c1 100644 --- a/apps/web/lib/tolt/schemas.ts +++ b/apps/web/lib/tolt/schemas.ts @@ -62,9 +62,12 @@ export const ToltCustomerSchema = z.object({ export const ToltCommissionSchema = z.object({ id: z.string(), amount: z.string().describe("Amount of the commission in cents."), - revenue: z.string().describe("Revenue of the commission in cents."), - transaction_id: z.string().nullable(), // this can be null - charge_id: z.string(), + revenue: z + .string() + .nullable() + .describe("Revenue of the transaction in cents."), + transaction_id: z.string().nullable(), + charge_id: z.string().nullable(), status: z.string(), created_at: z.string(), updated_at: z.string(),