From c4109255d038a667d3a07b87502d5a6fe2c133ed Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Tue, 18 Aug 2026 17:24:42 -0700 Subject: [PATCH 01/28] Site wide modal fixes --- .../bounty-submission-details-sheet.tsx | 2 +- .../[campaignId]/send-email-preview-modal.tsx | 26 ++++++----- .../campaigns/delete-campaign-modal.tsx | 2 +- .../partners/update-partner-enrollment.ts | 34 +++++++++------ .../attribute-referring-partner-modal.tsx | 2 +- .../web/ui/modals/add-discount-code-modal.tsx | 2 +- apps/web/ui/modals/add-partner-link-modal.tsx | 7 ++- apps/web/ui/modals/archive-partner-modal.tsx | 2 +- apps/web/ui/modals/ban-partner-modal.tsx | 8 ++-- .../web/ui/modals/bulk-ban-partners-modal.tsx | 9 ++-- .../bulk-resolve-fraud-groups-modal.tsx | 9 ++-- .../ui/modals/deactivate-partner-modal.tsx | 2 +- apps/web/ui/modals/delete-domain-modal.tsx | 3 +- .../ui/modals/delete-email-domain-modal.tsx | 9 ++-- apps/web/ui/modals/delete-partner-modal.tsx | 2 +- .../ui/modals/domain-auto-renewal-modal.tsx | 1 - apps/web/ui/modals/edit-customer-modal.tsx | 13 ++---- .../ui/modals/edit-submitted-lead-modal.tsx | 17 +++----- apps/web/ui/modals/import-bitly-modal.tsx | 14 +++--- apps/web/ui/modals/import-csv-modal/index.tsx | 12 ++---- .../ui/modals/import-firstpromoter-modal.tsx | 16 ++----- .../ui/modals/import-partnerstack-modal.tsx | 23 ++-------- apps/web/ui/modals/import-rebrandly-modal.tsx | 13 +++--- apps/web/ui/modals/import-rewardful-modal.tsx | 15 ++----- apps/web/ui/modals/import-short-modal.tsx | 14 +++--- .../web/ui/modals/import-tapfiliate-modal.tsx | 13 ++---- apps/web/ui/modals/import-tolt-modal.tsx | 15 ++----- .../ui/modals/reactivate-partner-modal.tsx | 2 +- apps/web/ui/modals/register-domain-modal.tsx | 15 +++++-- .../ui/modals/remove-workspace-user-modal.tsx | 2 +- apps/web/ui/modals/share-dashboard-modal.tsx | 2 +- apps/web/ui/modals/unban-partner-modal.tsx | 2 +- .../ui/modals/update-workspace-user-role.tsx | 2 +- apps/web/ui/modals/use-import-modal-param.ts | 18 ++++++++ .../reject-bounty-submission-modal.tsx | 20 +++------ .../partner-advanced-settings-modal.tsx | 28 +++++++----- packages/ui/src/hooks/index.ts | 1 + packages/ui/src/hooks/use-latest-callback.ts | 24 +++++++++++ packages/ui/src/modal.tsx | 43 ++++++++++++++++++- 39 files changed, 246 insertions(+), 198 deletions(-) create mode 100644 apps/web/ui/modals/use-import-modal-param.ts create mode 100644 packages/ui/src/hooks/use-latest-callback.ts diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx index 86843091acb..0efe865149e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx @@ -601,7 +601,7 @@ function BountySubmissionDetailsSheetContent({ - + {RejectBountySubmissionModal} {ConfirmApproveBountySubmissionModal} ); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx index e4e7ca78030..536287234aa 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx @@ -3,7 +3,7 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useUser from "@/lib/swr/use-user"; import { Button, Modal, useEnterSubmit, useMediaQuery } from "@dub/ui"; -import { Dispatch, SetStateAction, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useState } from "react"; import { useWatch } from "react-hook-form"; import { toast } from "sonner"; import { useCampaignFormContext } from "./campaign-form-context"; @@ -24,7 +24,8 @@ function SendEmailPreviewModal({ const { handleKeyDown } = useEnterSubmit(); const { control } = useCampaignFormContext(); const { isSubmitting, makeRequest } = useApiMutation(); - const [emailAddresses, setEmailAddresses] = useState(user?.email ?? ""); + const [emailAddresses, setEmailAddresses] = useState(null); + const emailAddressesValue = emailAddresses ?? user?.email ?? ""; const [subject, preview, bodyJson, from] = useWatch({ control, @@ -34,7 +35,7 @@ function SendEmailPreviewModal({ const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!emailAddresses.trim()) { + if (!emailAddressesValue.trim()) { toast.error("Please enter at least one email address."); return; } @@ -46,7 +47,7 @@ function SendEmailPreviewModal({ return; } - const emails = emailAddresses + const emails = emailAddressesValue .split(",") .map((email) => email.trim()) .filter((email) => email.length > 0); @@ -95,7 +96,7 @@ function SendEmailPreviewModal({ placeholder="Separate multiple addresses with commas" autoFocus={!isMobile} required - value={emailAddresses} + value={emailAddressesValue} onChange={(e) => setEmailAddresses(e.target.value)} onKeyDown={handleKeyDown} rows={3} @@ -117,7 +118,7 @@ function SendEmailPreviewModal({ type="submit" text="Send preview" loading={isSubmitting} - disabled={!emailAddresses.trim()} + disabled={!emailAddressesValue.trim()} className="h-8 w-fit" /> @@ -135,15 +136,20 @@ export function useSendEmailPreviewModal({ const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] = useState(false); - return { - showSendEmailPreviewModal, - setShowSendEmailPreviewModal, - SendEmailPreviewModal: () => ( + const SendEmailPreviewModalCallback = useCallback( + () => ( ), + [showSendEmailPreviewModal, campaignId], + ); + + return { + showSendEmailPreviewModal, + setShowSendEmailPreviewModal, + SendEmailPreviewModal: SendEmailPreviewModalCallback, }; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx index 9b31a015984..b8bb4c59ef6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx @@ -142,7 +142,7 @@ export function useDeleteCampaignModal( campaign={campaign} /> ); - }, [showDeleteCampaignModal, setShowDeleteCampaignModal, campaign]); + }, [showDeleteCampaignModal, setShowDeleteCampaignModal]); return useMemo( () => ({ diff --git a/apps/web/lib/actions/partners/update-partner-enrollment.ts b/apps/web/lib/actions/partners/update-partner-enrollment.ts index 7e51d6ae9c4..accd7ee47ea 100644 --- a/apps/web/lib/actions/partners/update-partner-enrollment.ts +++ b/apps/web/lib/actions/partners/update-partner-enrollment.ts @@ -16,10 +16,10 @@ import { throwIfNoPermission } from "../throw-if-no-permission"; const updatePartnerEnrollmentSchema = z.object({ workspaceId: z.string(), partnerId: z.string(), - tenantId: z.string().nullable(), - customerDataSharingEnabledAt: z.coerce.date().nullable(), - groupMoveDisabledAt: z.coerce.date().nullable(), - riskMonitoringDisabledAt: z.coerce.date().nullable(), + tenantId: z.string().nullable().optional(), + customerDataSharingEnabledAt: z.coerce.date().nullable().optional(), + groupMoveDisabledAt: z.coerce.date().nullable().optional(), + riskMonitoringDisabledAt: z.coerce.date().nullable().optional(), }); // Update a partner's program enrollment data @@ -64,22 +64,28 @@ export const updatePartnerEnrollmentAction = authActionClient } const programEnrollment = await prisma.$transaction(async (tx) => { - await tx.link.updateMany({ - where, - data: { - tenantId, - }, - }); + if (tenantId !== undefined) { + await tx.link.updateMany({ + where, + data: { + tenantId, + }, + }); + } return await tx.programEnrollment.update({ where: { partnerId_programId: where, }, data: { - tenantId, - customerDataSharingEnabledAt, - groupMoveDisabledAt, - riskMonitoringDisabledAt, + ...(tenantId !== undefined && { tenantId }), + ...(customerDataSharingEnabledAt !== undefined && { + customerDataSharingEnabledAt, + }), + ...(groupMoveDisabledAt !== undefined && { groupMoveDisabledAt }), + ...(riskMonitoringDisabledAt !== undefined && { + riskMonitoringDisabledAt, + }), }, include: { links: { diff --git a/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx b/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx index 6d4354506cc..07733c34f2a 100644 --- a/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx +++ b/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx @@ -197,7 +197,7 @@ export function useAttributeReferringPartnerModal({ partner={partner} /> ); - }, [showModal, setShowModal, partner]); + }, [showModal, setShowModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/add-discount-code-modal.tsx b/apps/web/ui/modals/add-discount-code-modal.tsx index 7c7750be0e0..2877783d40a 100644 --- a/apps/web/ui/modals/add-discount-code-modal.tsx +++ b/apps/web/ui/modals/add-discount-code-modal.tsx @@ -250,7 +250,7 @@ export function useAddDiscountCodeModal({ partner={partner} /> ); - }, [showAddDiscountCodeModal, setShowAddDiscountCodeModal, partner]); + }, [showAddDiscountCodeModal, setShowAddDiscountCodeModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/add-partner-link-modal.tsx b/apps/web/ui/modals/add-partner-link-modal.tsx index bc29bf122a6..6ee30749395 100644 --- a/apps/web/ui/modals/add-partner-link-modal.tsx +++ b/apps/web/ui/modals/add-partner-link-modal.tsx @@ -10,6 +10,7 @@ import { InfoTooltip, Modal, useCopyToClipboard, + useLatestCallback, useMediaQuery, } from "@dub/ui"; import { constructURLFromUTMParams } from "@dub/utils"; @@ -222,16 +223,18 @@ export function useAddPartnerLinkModal({ }) { const [showAddPartnerLinkModal, setShowAddPartnerLinkModal] = useState(false); + const onSuccessCallback = useLatestCallback(onSuccess); + const AddPartnerLinkModalCallback = useCallback(() => { return ( ); - }, [showAddPartnerLinkModal, setShowAddPartnerLinkModal, partner]); + }, [showAddPartnerLinkModal, setShowAddPartnerLinkModal, onSuccessCallback]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/archive-partner-modal.tsx b/apps/web/ui/modals/archive-partner-modal.tsx index b6c152461a9..9018e70a68c 100644 --- a/apps/web/ui/modals/archive-partner-modal.tsx +++ b/apps/web/ui/modals/archive-partner-modal.tsx @@ -117,7 +117,7 @@ export function useArchivePartnerModal({ partner={partner} /> ); - }, [showArchivePartnerModal, setShowArchivePartnerModal, partner]); + }, [showArchivePartnerModal, setShowArchivePartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/ban-partner-modal.tsx b/apps/web/ui/modals/ban-partner-modal.tsx index 3256a50c45f..bf2664059d6 100644 --- a/apps/web/ui/modals/ban-partner-modal.tsx +++ b/apps/web/ui/modals/ban-partner-modal.tsx @@ -8,7 +8,7 @@ import { } from "@/lib/zod/schemas/partners"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; -import { Button, InfoTooltip, Modal, Switch } from "@dub/ui"; +import { Button, InfoTooltip, Modal, Switch, useLatestCallback } from "@dub/ui"; import { cn } from "@dub/utils"; import { motion } from "motion/react"; import { useAction } from "next-safe-action/hooks"; @@ -254,16 +254,18 @@ export function useBanPartnerModal({ }) { const [showBanPartnerModal, setShowBanPartnerModal] = useState(false); + const onConfirmCallback = useLatestCallback(onConfirm); + const BanPartnerModalCallback = useCallback(() => { return ( ); - }, [showBanPartnerModal, setShowBanPartnerModal, partner, onConfirm]); + }, [showBanPartnerModal, setShowBanPartnerModal, onConfirmCallback]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/bulk-ban-partners-modal.tsx b/apps/web/ui/modals/bulk-ban-partners-modal.tsx index b3b42de3f57..b1d3a0f1c0f 100644 --- a/apps/web/ui/modals/bulk-ban-partners-modal.tsx +++ b/apps/web/ui/modals/bulk-ban-partners-modal.tsx @@ -7,7 +7,7 @@ import { bulkBanPartnersSchema, } from "@/lib/zod/schemas/partners"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; -import { Button, Modal } from "@dub/ui"; +import { Button, Modal, useLatestCallback } from "@dub/ui"; import { cn, pluralize } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; import { @@ -287,20 +287,21 @@ export function useBulkBanPartnersModal({ const [showBulkBanPartnersModal, setShowBulkBanPartnersModal] = useState(false); + const onConfirmCallback = useLatestCallback(onConfirm); + const BulkBanPartnersModalCallback = useCallback(() => { return ( ); }, [ showBulkBanPartnersModal, setShowBulkBanPartnersModal, - partners, - onConfirm, + onConfirmCallback, ]); return useMemo( diff --git a/apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx b/apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx index 45e3997fb60..fd43d6be347 100644 --- a/apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx +++ b/apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx @@ -6,7 +6,7 @@ import { MAX_RESOLUTION_REASON_LENGTH, } from "@/lib/zod/schemas/fraud"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; -import { Button, Modal } from "@dub/ui"; +import { Button, Modal, useLatestCallback } from "@dub/ui"; import { cn, pluralize } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; import { @@ -202,20 +202,21 @@ export function useBulkResolveFraudGroupsModal({ const [showBulkResolveFraudGroupsModal, setShowBulkResolveFraudGroupsModal] = useState(false); + const onConfirmCallback = useLatestCallback(onConfirm); + const BulkResolveFraudGroupsModalCallback = useCallback(() => { return ( ); }, [ showBulkResolveFraudGroupsModal, setShowBulkResolveFraudGroupsModal, - fraudGroups, - onConfirm, + onConfirmCallback, ]); return useMemo( diff --git a/apps/web/ui/modals/deactivate-partner-modal.tsx b/apps/web/ui/modals/deactivate-partner-modal.tsx index 41010480336..56689216286 100644 --- a/apps/web/ui/modals/deactivate-partner-modal.tsx +++ b/apps/web/ui/modals/deactivate-partner-modal.tsx @@ -162,7 +162,7 @@ export function useDeactivatePartnerModal({ partner={partner} /> ); - }, [showDeactivatePartnerModal, setShowDeactivatePartnerModal, partner]); + }, [showDeactivatePartnerModal, setShowDeactivatePartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/delete-domain-modal.tsx b/apps/web/ui/modals/delete-domain-modal.tsx index e222c5057d1..3534b5720b4 100644 --- a/apps/web/ui/modals/delete-domain-modal.tsx +++ b/apps/web/ui/modals/delete-domain-modal.tsx @@ -119,6 +119,7 @@ function DeleteDomainModal({ export function useDeleteDomainModal({ props }: { props?: DomainProps }) { const [showDeleteDomainModal, setShowDeleteDomainModal] = useState(false); + const hasProps = Boolean(props); const DeleteDomainModalCallback = useCallback(() => { return props ? ( @@ -128,7 +129,7 @@ export function useDeleteDomainModal({ props }: { props?: DomainProps }) { props={props} /> ) : null; - }, [showDeleteDomainModal, props]); + }, [showDeleteDomainModal, hasProps]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/delete-email-domain-modal.tsx b/apps/web/ui/modals/delete-email-domain-modal.tsx index 5656b268a00..4b01d15cc6c 100644 --- a/apps/web/ui/modals/delete-email-domain-modal.tsx +++ b/apps/web/ui/modals/delete-email-domain-modal.tsx @@ -1,7 +1,7 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { EmailDomainProps } from "@/lib/types"; -import { Button, Modal, useMediaQuery } from "@dub/ui"; +import { Button, Modal, useLatestCallback, useMediaQuery } from "@dub/ui"; import { FormEvent, useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -125,20 +125,21 @@ export function useDeleteEmailDomainModal( const [showDeleteEmailDomainModal, setShowDeleteEmailDomainModal] = useState(false); + const onDeleteCallback = useLatestCallback(onDelete); + const DeleteEmailDomainModalCallback = useCallback(() => { return ( ); }, [ showDeleteEmailDomainModal, setShowDeleteEmailDomainModal, - onDelete, - emailDomain, + onDeleteCallback, ]); return useMemo( diff --git a/apps/web/ui/modals/delete-partner-modal.tsx b/apps/web/ui/modals/delete-partner-modal.tsx index 7e078fe234c..4a8603ee8b9 100644 --- a/apps/web/ui/modals/delete-partner-modal.tsx +++ b/apps/web/ui/modals/delete-partner-modal.tsx @@ -177,7 +177,7 @@ export function useDeletePartnerModal({ partner={partner} /> ); - }, [showDeletePartnerModal, setShowDeletePartnerModal, partner]); + }, [showDeletePartnerModal, setShowDeletePartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/domain-auto-renewal-modal.tsx b/apps/web/ui/modals/domain-auto-renewal-modal.tsx index 2699a330682..7303c11dc0c 100644 --- a/apps/web/ui/modals/domain-auto-renewal-modal.tsx +++ b/apps/web/ui/modals/domain-auto-renewal-modal.tsx @@ -152,7 +152,6 @@ export function useDomainAutoRenewalModal({ domain }: { domain: DomainProps }) { }, [ showDomainAutoRenewalModal, setShowDomainAutoRenewalModal, - domain, enableAutoRenewal, ]); diff --git a/apps/web/ui/modals/edit-customer-modal.tsx b/apps/web/ui/modals/edit-customer-modal.tsx index 7596b07b499..c82411bb1fc 100644 --- a/apps/web/ui/modals/edit-customer-modal.tsx +++ b/apps/web/ui/modals/edit-customer-modal.tsx @@ -3,7 +3,7 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { CustomerEnriched, CustomerProps } from "@/lib/types"; import { updateCustomerBodySchema } from "@/lib/zod/schemas/customers"; import { Button, Modal, useMediaQuery } from "@dub/ui"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -173,11 +173,7 @@ export function useEditCustomerModal() { setCustomer(customer); } - function closeEditCustomerModal() { - setCustomer(null); - } - - function EditCustomerModalWrapper() { + const EditCustomerModalWrapper = useCallback(() => { if (!customer) return null; return ( @@ -185,15 +181,14 @@ export function useEditCustomerModal() { customer={customer} showModal setShowModal={(show) => { - if (!show) closeEditCustomerModal(); + if (!show) setCustomer(null); }} /> ); - } + }, [customer]); return { openEditCustomerModal, - closeEditCustomerModal, EditCustomerModal: EditCustomerModalWrapper, isEditCustomerModalOpen: customer !== null, }; diff --git a/apps/web/ui/modals/edit-submitted-lead-modal.tsx b/apps/web/ui/modals/edit-submitted-lead-modal.tsx index 9ad03adaaea..ab9f24cd136 100644 --- a/apps/web/ui/modals/edit-submitted-lead-modal.tsx +++ b/apps/web/ui/modals/edit-submitted-lead-modal.tsx @@ -9,7 +9,7 @@ import { CountryCombobox } from "@/ui/partners/country-combobox"; import { Button, Modal, useMediaQuery } from "@dub/ui"; import { COUNTRIES } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -55,7 +55,7 @@ function EditSubmittedLeadModal({ const { isMobile } = useMediaQuery(); const { id: workspaceId, defaultProgramId } = useWorkspace(); - const customFormData = lead.formData ?? []; + const customFormData = lead.formData; const { register, @@ -186,7 +186,7 @@ function EditSubmittedLeadModal({ {/* Custom form data fields */} - {customFormData.map((field) => { + {customFormData?.map((field) => { const keyPath = `formData.${field.key}` as const; if (field.type === "textarea") { @@ -293,11 +293,7 @@ export function useEditSubmittedLeadModal() { setLead(lead); } - function closeEditSubmittedLeadModal() { - setLead(null); - } - - function EditSubmittedLeadModalWrapper() { + const EditSubmittedLeadModalWrapper = useCallback(() => { if (!lead) return null; return ( @@ -305,15 +301,14 @@ export function useEditSubmittedLeadModal() { lead={lead} showModal setShowModal={(show) => { - if (!show) closeEditSubmittedLeadModal(); + if (!show) setLead(null); }} /> ); - } + }, [lead]); return { openEditSubmittedLeadModal, - closeEditSubmittedLeadModal, EditSubmittedLeadModal: EditSubmittedLeadModalWrapper, isEditSubmittedLeadModalOpen: lead !== null, }; diff --git a/apps/web/ui/modals/import-bitly-modal.tsx b/apps/web/ui/modals/import-bitly-modal.tsx index e2c6506298d..221f85c6aa0 100644 --- a/apps/web/ui/modals/import-bitly-modal.tsx +++ b/apps/web/ui/modals/import-bitly-modal.tsx @@ -14,7 +14,7 @@ import { import { fetcher } from "@dub/utils"; import { ArrowRight, ServerOff } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -25,6 +25,7 @@ import { } from "react"; import { toast } from "sonner"; import useSWRImmutable from "swr/immutable"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportBitlyModal({ showImportBitlyModal, @@ -34,7 +35,6 @@ function ImportBitlyModal({ setShowImportBitlyModal: Dispatch>; }) { const router = useRouter(); - const searchParams = useSearchParams(); const { folderId } = useCurrentFolderId(); const { id: workspaceId } = useWorkspace(); const [importing, setImporting] = useState(false); @@ -81,13 +81,10 @@ function ImportBitlyModal({ const [selectedGroupTags, setSelectedGroupTags] = useState([]); useEffect(() => { - if (searchParams?.get("import") === "bitly") { + if (showImportBitlyModal && groups !== undefined) { mutate(); - setShowImportBitlyModal(true); - } else { - setShowImportBitlyModal(false); } - }, [searchParams]); + }, [showImportBitlyModal]); const isSelected = (domain: string) => { return selectedDomains.find((d) => d.domain === domain) ? true : false; @@ -291,7 +288,8 @@ function ImportBitlyModal({ } export function useImportBitlyModal() { - const [showImportBitlyModal, setShowImportBitlyModal] = useState(false); + const [showImportBitlyModal, setShowImportBitlyModal] = + useImportModalParam("bitly"); const ImportBitlyModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-csv-modal/index.tsx b/apps/web/ui/modals/import-csv-modal/index.tsx index 5024922a184..21d61614edd 100644 --- a/apps/web/ui/modals/import-csv-modal/index.tsx +++ b/apps/web/ui/modals/import-csv-modal/index.tsx @@ -12,7 +12,7 @@ import { } from "@dub/ui"; import { TableIcon } from "@dub/ui/icons"; import { ArrowRight } from "lucide-react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -31,6 +31,7 @@ import { } from "react-hook-form"; import { toast } from "sonner"; import { mutate } from "swr"; +import { useImportModalParam } from "../use-import-modal-param"; import { FieldMapping } from "./field-mapping"; import { SelectFile } from "./select-file"; @@ -97,16 +98,10 @@ function ImportCsvModal({ const router = useRouter(); const { slug } = useParams() as { slug?: string }; const { queryParams } = useRouterStuff(); - const searchParams = useSearchParams(); const { id: workspaceId } = useWorkspace(); const { folderId } = useCurrentFolderId(); - useEffect( - () => setShowImportCsvModal(searchParams?.get("import") === "csv"), - [searchParams], - ); - const { control, watch, @@ -270,7 +265,8 @@ function ImportCsvModal({ } export function useImportCsvModal() { - const [showImportCsvModal, setShowImportCsvModal] = useState(false); + const [showImportCsvModal, setShowImportCsvModal] = + useImportModalParam("csv"); const ImportCsvModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-firstpromoter-modal.tsx b/apps/web/ui/modals/import-firstpromoter-modal.tsx index b4e871668d0..f898198d80d 100644 --- a/apps/web/ui/modals/import-firstpromoter-modal.tsx +++ b/apps/web/ui/modals/import-firstpromoter-modal.tsx @@ -4,17 +4,17 @@ import { X } from "@/ui/shared/icons"; import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; import { ArrowRight } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useCallback, - useEffect, useMemo, useState, } from "react"; import { toast } from "sonner"; import { MarkdownDescription } from "../shared/markdown-description"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportFirstPromoterModal({ showImportFirstPromoterModal, @@ -180,17 +180,7 @@ function CredentialsForm({ onClose }: { onClose: () => void }) { export function useImportFirstPromoterModal() { const [showImportFirstPromoterModal, setShowImportFirstPromoterModal] = - useState(false); - const searchParams = useSearchParams(); - - // Sync the modal state with the `?import=` query param here in the hook - // rather than in the modal itself, which remounts on every open/close - // and would re-open from a stale param mid-navigation - useEffect(() => { - setShowImportFirstPromoterModal( - searchParams?.get("import") === "firstpromoter", - ); - }, [searchParams]); + useImportModalParam("firstpromoter"); const ImportFirstPromoterModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx index c25dd1118a8..295aea399fd 100644 --- a/apps/web/ui/modals/import-partnerstack-modal.tsx +++ b/apps/web/ui/modals/import-partnerstack-modal.tsx @@ -4,16 +4,11 @@ import { X } from "@/ui/shared/icons"; import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; import { ArrowRight } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { useRouter, useSearchParams } from "next/navigation"; -import { - Dispatch, - SetStateAction, - useCallback, - useEffect, - useState, -} from "react"; +import { useRouter } from "next/navigation"; +import { Dispatch, SetStateAction, useCallback, useState } from "react"; import { toast } from "sonner"; import { MarkdownDescription } from "../shared/markdown-description"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportPartnerStackModal({ showImportPartnerStackModal, @@ -173,17 +168,7 @@ function TokenForm({ onClose }: { onClose: () => void }) { export function useImportPartnerStackModal() { const [showImportPartnerStackModal, setShowImportPartnerStackModal] = - useState(false); - const searchParams = useSearchParams(); - - // Sync the modal state with the `?import=` query param here in the hook - // rather than in the modal itself, which remounts on every open/close - // and would re-open from a stale param mid-navigation - useEffect(() => { - setShowImportPartnerStackModal( - searchParams?.get("import") === "partnerstack", - ); - }, [searchParams]); + useImportModalParam("partnerstack"); const ImportPartnerStackModalCallback = useCallback( () => ( diff --git a/apps/web/ui/modals/import-rebrandly-modal.tsx b/apps/web/ui/modals/import-rebrandly-modal.tsx index 22b0e4888f9..0dc8503ae5b 100644 --- a/apps/web/ui/modals/import-rebrandly-modal.tsx +++ b/apps/web/ui/modals/import-rebrandly-modal.tsx @@ -16,7 +16,7 @@ import { import { fetcher, nFormatter } from "@dub/utils"; import { ArrowRight, ChevronDown } from "lucide-react"; import { motion } from "motion/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -27,6 +27,7 @@ import { } from "react"; import { toast } from "sonner"; import useSWRImmutable from "swr/immutable"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportRebrandlyModal({ showImportRebrandlyModal, @@ -37,7 +38,6 @@ function ImportRebrandlyModal({ }) { const router = useRouter(); const { id: workspaceId, slug } = useWorkspace(); - const searchParams = useSearchParams(); const { folderId } = useCurrentFolderId(); @@ -77,13 +77,10 @@ function ImportRebrandlyModal({ const [importing, setImporting] = useState(false); useEffect(() => { - if (searchParams?.get("import") === "rebrandly") { + if (showImportRebrandlyModal && domains !== null) { mutate(); - setShowImportRebrandlyModal(true); - } else { - setShowImportRebrandlyModal(false); } - }, [searchParams]); + }, [showImportRebrandlyModal]); const isSelected = (domain: string) => { return selectedDomains.find((d) => d.domain === domain) ? true : false; @@ -307,7 +304,7 @@ function ImportRebrandlyModal({ export function useImportRebrandlyModal() { const [showImportRebrandlyModal, setShowImportRebrandlyModal] = - useState(false); + useImportModalParam("rebrandly"); const ImportRebrandlyModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-rewardful-modal.tsx b/apps/web/ui/modals/import-rewardful-modal.tsx index ab2571e0a5f..7e62af24f0a 100644 --- a/apps/web/ui/modals/import-rewardful-modal.tsx +++ b/apps/web/ui/modals/import-rewardful-modal.tsx @@ -3,6 +3,7 @@ import { startRewardfulImportAction } from "@/lib/actions/partners/start-rewardf import { RewardfulCampaign } from "@/lib/rewardful/types"; import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; +import { X } from "@/ui/shared/icons"; import { AnimatedSizeContainer, Button, @@ -16,12 +17,11 @@ import { useMediaQuery, useRouterStuff, } from "@dub/ui"; -import { X } from "@/ui/shared/icons"; import { cn, currencyFormatter, fetcher } from "@dub/utils"; import { Command } from "cmdk"; import { ArrowRight, ServerOff, Users } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -34,6 +34,7 @@ import { toast } from "sonner"; import useSWRImmutable from "swr/immutable"; import { useDebounce } from "use-debounce"; import { MarkdownDescription } from "../shared/markdown-description"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportRewardfulModal({ showImportRewardfulModal, @@ -532,15 +533,7 @@ function CampaignsStep({ export function useImportRewardfulModal() { const [showImportRewardfulModal, setShowImportRewardfulModal] = - useState(false); - const searchParams = useSearchParams(); - - // Sync the modal state with the `?import=` query param here in the hook - // rather than in the modal itself, which remounts on every open/close - // and would re-open from a stale param mid-navigation - useEffect(() => { - setShowImportRewardfulModal(searchParams?.get("import") === "rewardful"); - }, [searchParams]); + useImportModalParam("rewardful"); const ImportRewardfulModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-short-modal.tsx b/apps/web/ui/modals/import-short-modal.tsx index a47986babc3..23b2e2633de 100644 --- a/apps/web/ui/modals/import-short-modal.tsx +++ b/apps/web/ui/modals/import-short-modal.tsx @@ -14,7 +14,7 @@ import { } from "@dub/ui"; import { cn, fetcher, nFormatter } from "@dub/utils"; import { ArrowRight, ServerOff } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -25,6 +25,7 @@ import { } from "react"; import { toast } from "sonner"; import useSWRImmutable from "swr/immutable"; +import { useImportModalParam } from "./use-import-modal-param"; function ImportShortModal({ showImportShortModal, @@ -35,7 +36,6 @@ function ImportShortModal({ }) { const router = useRouter(); const { id: workspaceId, slug } = useWorkspace(); - const searchParams = useSearchParams(); const { folderId } = useCurrentFolderId(); @@ -67,13 +67,10 @@ function ImportShortModal({ const [importing, setImporting] = useState(false); useEffect(() => { - if (searchParams?.get("import") === "short") { + if (showImportShortModal && domains !== undefined) { mutate(); - setShowImportShortModal(true); - } else { - setShowImportShortModal(false); } - }, [searchParams]); + }, [showImportShortModal]); const isSelected = (domain: string) => { return selectedDomains.find((d) => d.domain === domain) ? true : false; @@ -276,7 +273,8 @@ function ImportShortModal({ } export function useImportShortModal() { - const [showImportShortModal, setShowImportShortModal] = useState(false); + const [showImportShortModal, setShowImportShortModal] = + useImportModalParam("short"); const ImportShortModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-tapfiliate-modal.tsx b/apps/web/ui/modals/import-tapfiliate-modal.tsx index 48199c6f8ae..c95be440c11 100644 --- a/apps/web/ui/modals/import-tapfiliate-modal.tsx +++ b/apps/web/ui/modals/import-tapfiliate-modal.tsx @@ -16,7 +16,7 @@ import { cn } from "@dub/utils"; import { ArrowRight } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useAction } from "next-safe-action/hooks"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, @@ -27,6 +27,7 @@ import { } from "react"; import { toast } from "sonner"; import { MarkdownDescription } from "../shared/markdown-description"; +import { useImportModalParam } from "./use-import-modal-param"; type Step = "set-token" | "select-program"; @@ -305,15 +306,7 @@ function SelectProgram({ export function useImportTapfiliateModal() { const [showImportTapfiliateModal, setShowImportTapfiliateModal] = - useState(false); - const searchParams = useSearchParams(); - - // Sync the modal state with the `?import=` query param here in the hook - // rather than in the modal itself, which remounts on every open/close - // and would re-open from a stale param mid-navigation - useEffect(() => { - setShowImportTapfiliateModal(searchParams?.get("import") === "tapfiliate"); - }, [searchParams]); + useImportModalParam("tapfiliate"); const ImportTapfiliateModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/import-tolt-modal.tsx b/apps/web/ui/modals/import-tolt-modal.tsx index e8ae98ca4e4..b175c9b2195 100644 --- a/apps/web/ui/modals/import-tolt-modal.tsx +++ b/apps/web/ui/modals/import-tolt-modal.tsx @@ -7,17 +7,17 @@ import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; import { ArrowRight } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useAction } from "next-safe-action/hooks"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useCallback, - useEffect, useMemo, useState, } from "react"; import { toast } from "sonner"; import { MarkdownDescription } from "../shared/markdown-description"; +import { useImportModalParam } from "./use-import-modal-param"; type Step = "set-token" | "program-info"; @@ -294,15 +294,8 @@ function ProgramInfo({ } export function useImportToltModal() { - const [showImportToltModal, setShowImportToltModal] = useState(false); - const searchParams = useSearchParams(); - - // Sync the modal state with the `?import=` query param here in the hook - // rather than in the modal itself, which remounts on every open/close - // and would re-open from a stale param mid-navigation - useEffect(() => { - setShowImportToltModal(searchParams?.get("import") === "tolt"); - }, [searchParams]); + const [showImportToltModal, setShowImportToltModal] = + useImportModalParam("tolt"); const ImportToltModalCallback = useCallback(() => { return ( diff --git a/apps/web/ui/modals/reactivate-partner-modal.tsx b/apps/web/ui/modals/reactivate-partner-modal.tsx index 3b8595713a3..be3755b1812 100644 --- a/apps/web/ui/modals/reactivate-partner-modal.tsx +++ b/apps/web/ui/modals/reactivate-partner-modal.tsx @@ -113,7 +113,7 @@ export function useReactivatePartnerModal({ partner={partner} /> ); - }, [showReactivatePartnerModal, setShowReactivatePartnerModal, partner]); + }, [showReactivatePartnerModal, setShowReactivatePartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/register-domain-modal.tsx b/apps/web/ui/modals/register-domain-modal.tsx index d3bb13d675a..3a203a28092 100644 --- a/apps/web/ui/modals/register-domain-modal.tsx +++ b/apps/web/ui/modals/register-domain-modal.tsx @@ -1,4 +1,4 @@ -import { Modal, useRouterStuff } from "@dub/ui"; +import { Modal, useLatestCallback, useRouterStuff } from "@dub/ui"; import { useCallback, useMemo, useState } from "react"; import { RegisterDomainForm } from "../domains/register-domain-form"; @@ -46,17 +46,26 @@ const RegisterDomain = ({ export function useRegisterDomainModal( props: Omit = {}, ) { + const { onSuccess, setRegisteredParam } = props; const [showRegisterDomainModal, setShowRegisterDomainModal] = useState(false); + const onSuccessCallback = useLatestCallback(onSuccess); + const RegisterDomainModal = useCallback(() => { return ( ); - }, [showRegisterDomainModal, setShowRegisterDomainModal, props]); + }, [ + showRegisterDomainModal, + setShowRegisterDomainModal, + onSuccessCallback, + setRegisteredParam, + ]); return useMemo( () => ({ setShowRegisterDomainModal, RegisterDomainModal }), diff --git a/apps/web/ui/modals/remove-workspace-user-modal.tsx b/apps/web/ui/modals/remove-workspace-user-modal.tsx index a6960c25270..eeea081dfaa 100644 --- a/apps/web/ui/modals/remove-workspace-user-modal.tsx +++ b/apps/web/ui/modals/remove-workspace-user-modal.tsx @@ -239,7 +239,7 @@ export function useRemoveWorkspaceUserModal({ user }: { user: UserProps }) { user={user} /> ); - }, [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal, user]); + }, [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/share-dashboard-modal.tsx b/apps/web/ui/modals/share-dashboard-modal.tsx index 65db3bb119c..1bf63cd1def 100644 --- a/apps/web/ui/modals/share-dashboard-modal.tsx +++ b/apps/web/ui/modals/share-dashboard-modal.tsx @@ -500,7 +500,7 @@ export function useShareDashboardModal(props: ShareDashboardModalInnerProps) { {...props} /> ); - }, [showShareDashboardModal, setShowShareDashboardModal, props]); + }, [showShareDashboardModal, setShowShareDashboardModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/unban-partner-modal.tsx b/apps/web/ui/modals/unban-partner-modal.tsx index e57d59cbf55..1dcd1632211 100644 --- a/apps/web/ui/modals/unban-partner-modal.tsx +++ b/apps/web/ui/modals/unban-partner-modal.tsx @@ -156,7 +156,7 @@ export function useUnbanPartnerModal({ partner={partner} /> ); - }, [showUnbanPartnerModal, setShowUnbanPartnerModal, partner]); + }, [showUnbanPartnerModal, setShowUnbanPartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/update-workspace-user-role.tsx b/apps/web/ui/modals/update-workspace-user-role.tsx index e3e3dcc3c71..94686888dd5 100644 --- a/apps/web/ui/modals/update-workspace-user-role.tsx +++ b/apps/web/ui/modals/update-workspace-user-role.tsx @@ -136,7 +136,7 @@ export function useWorkspaceUserRoleModal({ role={role} /> ); - }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal, user, role]); + }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/use-import-modal-param.ts b/apps/web/ui/modals/use-import-modal-param.ts new file mode 100644 index 00000000000..8c11cbffc2a --- /dev/null +++ b/apps/web/ui/modals/use-import-modal-param.ts @@ -0,0 +1,18 @@ +import { useSearchParams } from "next/navigation"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +// Keeps an import modal's show state in sync with the `?import=` query param +// at the hook level rather than in the modal component itself, which remounts +// on every open/close and would re-open from a stale param mid-navigation +export function useImportModalParam( + provider: string, +): [boolean, Dispatch>] { + const [showModal, setShowModal] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + setShowModal(searchParams?.get("import") === provider); + }, [searchParams, provider]); + + return [showModal, setShowModal]; +} diff --git a/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx b/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx index e0b9fd58edc..258d9f3b22d 100644 --- a/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx +++ b/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx @@ -12,7 +12,7 @@ import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; import { Button, Modal, useKeyboardShortcut } from "@dub/ui"; import { cn } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -197,22 +197,16 @@ export function useRejectBountySubmissionModal( ) { const [showRejectModal, setShowRejectModal] = useState(false); - const RejectBountySubmissionModalCallback = useCallback(() => { - return ( + return { + setShowRejectModal, + RejectBountySubmissionModal: showRejectModal ? ( - ); - }, [showRejectModal, setShowRejectModal, onReject, submission]); - - return useMemo( - () => ({ - setShowRejectModal, - RejectBountySubmissionModal: RejectBountySubmissionModalCallback, - }), - [setShowRejectModal, RejectBountySubmissionModalCallback], - ); + ) : null, + }; } diff --git a/apps/web/ui/partners/partner-advanced-settings-modal.tsx b/apps/web/ui/partners/partner-advanced-settings-modal.tsx index 381c419f04d..8e5a23b03c5 100644 --- a/apps/web/ui/partners/partner-advanced-settings-modal.tsx +++ b/apps/web/ui/partners/partner-advanced-settings-modal.tsx @@ -73,7 +73,13 @@ function PartnerAdvancedSettingsModal({ setValue, setError, handleSubmit, - formState: { errors, isSubmitting, isSubmitSuccessful, isDirty }, + formState: { + errors, + isSubmitting, + isSubmitSuccessful, + isDirty, + dirtyFields, + }, } = useForm({ defaultValues: { tenantId: partner.tenantId, @@ -123,10 +129,16 @@ function PartnerAdvancedSettingsModal({ const result = await executeAsync({ workspaceId: workspaceId!, partnerId: partner.id, - tenantId: data.tenantId || null, - customerDataSharingEnabledAt: data.customerDataSharingEnabledAt, - groupMoveDisabledAt: data.groupMoveDisabledAt, - riskMonitoringDisabledAt: data.riskMonitoringDisabledAt, + ...(dirtyFields.tenantId && { tenantId: data.tenantId || null }), + ...(dirtyFields.customerDataSharingEnabledAt && { + customerDataSharingEnabledAt: data.customerDataSharingEnabledAt, + }), + ...(dirtyFields.groupMoveDisabledAt && { + groupMoveDisabledAt: data.groupMoveDisabledAt, + }), + ...(dirtyFields.riskMonitoringDisabledAt && { + riskMonitoringDisabledAt: data.riskMonitoringDisabledAt, + }), }); if (result?.serverError || result?.validationErrors) { @@ -282,11 +294,7 @@ export function usePartnerAdvancedSettingsModal({ partner={partner} /> ); - }, [ - showPartnerAdvancedSettingsModal, - setShowPartnerAdvancedSettingsModal, - partner, - ]); + }, [showPartnerAdvancedSettingsModal, setShowPartnerAdvancedSettingsModal]); return useMemo( () => ({ diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index a02d957afec..2e36db2cfa1 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -10,6 +10,7 @@ export * from "./use-in-viewport"; export * from "./use-input-focused"; export * from "./use-intersection-observer"; export * from "./use-keyboard-shortcut"; +export * from "./use-latest-callback"; export * from "./use-local-storage"; export * from "./use-media-query"; export * from "./use-optimistic-update"; diff --git a/packages/ui/src/hooks/use-latest-callback.ts b/packages/ui/src/hooks/use-latest-callback.ts new file mode 100644 index 00000000000..573864f6696 --- /dev/null +++ b/packages/ui/src/hooks/use-latest-callback.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * Returns a stable function that always invokes the latest `callback`, + * so it can be passed to memoized children (or used in effects) without + * their identity changing when the callback is recreated by the caller. + */ +export function useLatestCallback any>( + callback: T | undefined, +) { + const callbackRef = useRef(callback); + + useIsomorphicLayoutEffect(() => { + callbackRef.current = callback; + }); + + return useCallback( + (...args: Parameters) => callbackRef.current?.(...args) as ReturnType, + [], + ); +} diff --git a/packages/ui/src/modal.tsx b/packages/ui/src/modal.tsx index d80560391c3..f33e193bbb5 100644 --- a/packages/ui/src/modal.tsx +++ b/packages/ui/src/modal.tsx @@ -4,10 +4,49 @@ import { cn } from "@dub/utils"; import * as Dialog from "@radix-ui/react-dialog"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import { useRouter } from "next/navigation"; -import { ComponentProps, Dispatch, SetStateAction } from "react"; +import { + ComponentProps, + Dispatch, + SetStateAction, + useEffect, + useRef, +} from "react"; import { Drawer } from "vaul"; import { useMediaQuery } from "./hooks"; +// Dev-only detector for modal remount bugs: an open Modal unmounting while +// another mounts open in the same commit means its element type changed +// (unstable hook dep) and any user state in it was lost +let pendingOpenUnmount = false; + +function useWarnOnRemountWhileOpen(showModal?: boolean) { + const showModalRef = useRef(showModal); + + useEffect(() => { + showModalRef.current = showModal; + }); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + + if (showModalRef.current && pendingOpenUnmount) { + pendingOpenUnmount = false; + console.warn( + "[Modal] remounted while open — the modal's element type changed mid-session (unstable hook dep?) and any user state in it was lost.", + ); + } + + return () => { + if (showModalRef.current) { + pendingOpenUnmount = true; + queueMicrotask(() => { + pendingOpenUnmount = false; + }); + } + }; + }, []); +} + export function Modal({ children, className, @@ -29,6 +68,8 @@ export function Modal({ }) { const router = useRouter(); + useWarnOnRemountWhileOpen(showModal); + const closeModal = ({ dragged }: { dragged?: boolean } = {}) => { if (preventDefaultClose && !dragged) { return; From d6e4d2dc71e1309608eb7fdc5d21ee611fa45827 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 19 Aug 2026 11:11:25 -0300 Subject: [PATCH 02/28] partner.merged webhook for account merges --- .../api/cron/partners/merge-accounts/route.ts | 43 ++++++++++++ apps/web/lib/integrations/slack/transform.ts | 65 +++++++++++++++++++ .../lib/integrations/slack/ui/settings.tsx | 1 + .../lib/integrations/zapier/ui/settings.tsx | 1 + apps/web/lib/webhook/constants.ts | 2 + .../webhook/sample-events/partner-merged.json | 14 ++++ apps/web/lib/webhook/sample-events/payload.ts | 2 + apps/web/lib/webhook/schemas.ts | 15 +++++ apps/web/lib/webhook/types.ts | 10 ++- apps/web/lib/zod/schemas/partners.ts | 24 +++++++ apps/web/tests/webhooks/index.test.ts | 6 +- 11 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/webhook/sample-events/partner-merged.json diff --git a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts index f0d85d09160..b326730fdea 100644 --- a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts @@ -11,6 +11,8 @@ import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; +import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; +import { partnerMergedWebhookSchema } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import PartnerAccountMerged from "@dub/email/templates/partner-account-merged"; import { log, prettyPrint, R2_URL } from "@dub/utils"; @@ -69,6 +71,16 @@ export async function POST(req: Request) { programId: true, tenantId: true, status: true, + program: { + select: { + workspace: { + select: { + id: true, + webhookEnabled: true, + }, + }, + }, + }, }, }, users: { @@ -323,6 +335,37 @@ export async function POST(req: Request) { } } + if (sourcePartnerEnrollments.length > 0) { + const webhookResults = await Promise.allSettled( + sourcePartnerEnrollments.map((sourceEnrollment) => { + const targetEnrollment = targetPartnerEnrollments.find( + ({ programId }) => programId === sourceEnrollment.programId, + ); + + return sendWorkspaceWebhook({ + workspace: sourceEnrollment.program.workspace, + trigger: "partner.merged", + data: partnerMergedWebhookSchema.parse({ + programId: sourceEnrollment.programId, + targetAlreadyEnrolled: Boolean(targetEnrollment), + source: { + id: sourcePartnerId, + tenantId: sourceEnrollment.tenantId, + email: sourceAccount.email ?? null, + }, + target: { + id: targetPartnerId, + tenantId: + targetEnrollment?.tenantId ?? sourceEnrollment.tenantId, + email: targetAccount.email ?? null, + }, + }), + }); + }), + ); + console.log(prettyPrint(webhookResults)); + } + // Remove the user if there are no workspaces left // TODO: we need to handle deleting multiple users when we allow partners to invite their team members in the future const sourcePartnerUser = sourcePartnerUsers[0]; diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index f3288b5255e..9381a344373 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -13,6 +13,7 @@ import { LeadEventWebhookPayload, PartnerApplicationWebhookPayload, PartnerEventWebhookPayload, + PartnerMergedWebhookPayload, PayoutEventWebhookPayload, SaleEventWebhookPayload, } from "../../webhook/types"; @@ -535,6 +536,69 @@ const bountyTemplates = ({ }; }; +const partnerMergedTemplate = ({ + data, +}: { + data: PartnerMergedWebhookPayload; +}) => { + const { targetAlreadyEnrolled, source, target } = data; + const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${target.id}`; + const outcomeLabel = targetAlreadyEnrolled + ? "Target was already enrolled" + : "Target was not enrolled"; + + return { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Partner accounts merged* :twisted_rightwards_arrows:`, + }, + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Source*\n\`${source.id}\`${source.email ? ` (${source.email})` : ""}`, + }, + { + type: "mrkdwn", + text: `*Target*\n<${hrefToPartnerPage}|\`${target.id}\`>${target.email ? ` (${target.email})` : ""}`, + }, + ], + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Outcome*\n${outcomeLabel}`, + }, + ...(source.tenantId || target.tenantId + ? [ + { + type: "mrkdwn", + text: `*Tenant ID*\n${source.tenantId ?? "—"} → ${target.tenantId ?? "—"}`, + }, + ] + : []), + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<${hrefToPartnerPage}|View on Dub>`, + }, + ], + }, + ], + }; +}; + const payoutConfirmedTemplate = ({ data, }: { @@ -604,6 +668,7 @@ const slackTemplates: Record = { "lead.created": leadCreatedTemplate, "sale.created": saleCreatedTemplate, "partner.enrolled": partnerEnrolledTemplate, + "partner.merged": partnerMergedTemplate, "partner.application_submitted": partnerApplicationSubmittedTemplate, "commission.created": commissionCreatedTemplate, "bounty.created": bountyTemplates, diff --git a/apps/web/lib/integrations/slack/ui/settings.tsx b/apps/web/lib/integrations/slack/ui/settings.tsx index b62f88cb8fa..b57b963440e 100644 --- a/apps/web/lib/integrations/slack/ui/settings.tsx +++ b/apps/web/lib/integrations/slack/ui/settings.tsx @@ -19,6 +19,7 @@ export const SlackSettings = (props: InstalledIntegrationInfoProps) => { "lead.created", "sale.created", "partner.enrolled", + "partner.merged", "commission.created", "bounty.created", "bounty.updated", diff --git a/apps/web/lib/integrations/zapier/ui/settings.tsx b/apps/web/lib/integrations/zapier/ui/settings.tsx index 6f5c9f874c5..a3e2859dc57 100644 --- a/apps/web/lib/integrations/zapier/ui/settings.tsx +++ b/apps/web/lib/integrations/zapier/ui/settings.tsx @@ -20,6 +20,7 @@ export const ZapierSettings = (props: InstalledIntegrationInfoProps) => { "sale.created", "partner.application_submitted", "partner.enrolled", + "partner.merged", ]} /> )} diff --git a/apps/web/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index 4040bbf31aa..ee783814e11 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -22,6 +22,7 @@ export const WORKSPACE_LEVEL_WEBHOOK_TRIGGERS = [ export const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [ "partner.application_submitted", "partner.enrolled", + "partner.merged", "commission.created", "bounty.created", "bounty.updated", @@ -42,6 +43,7 @@ export const WEBHOOK_TRIGGER_DESCRIPTIONS: Record = { "sale.created": "Sale created", "partner.application_submitted": "Partner application submitted", "partner.enrolled": "Partner enrolled", + "partner.merged": "Partner accounts merged", "commission.created": "Commission created", "bounty.created": "Bounty created", "bounty.updated": "Bounty updated", diff --git a/apps/web/lib/webhook/sample-events/partner-merged.json b/apps/web/lib/webhook/sample-events/partner-merged.json new file mode 100644 index 00000000000..d9e3d8e7228 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/partner-merged.json @@ -0,0 +1,14 @@ +{ + "programId": "prog_CYCu7IMAapjkRpTnr8F1azjN", + "targetAlreadyEnrolled": false, + "source": { + "id": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "old@example.com" + }, + "target": { + "id": "pn_1K06X6FX2GRB31NCM2VVCGJ72", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "kept@example.com" + } +} diff --git a/apps/web/lib/webhook/sample-events/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 546af936567..25073fcf760 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -9,6 +9,7 @@ import linkDeleted from "./link-deleted.json"; import linkUpdated from "./link-updated.json"; import partnerApplicationSubmitted from "./partner-application-submitted.json"; import partnerEnrolled from "./partner-enrolled.json"; +import partnerMerged from "./partner-merged.json"; import payoutConfirmed from "./payout-confirmed.json"; import saleCreated from "./sale-created.json"; @@ -21,6 +22,7 @@ export const samplePayload: Record = { "sale.created": saleCreated, "partner.application_submitted": partnerApplicationSubmitted, "partner.enrolled": partnerEnrolled, + "partner.merged": partnerMerged, "commission.created": commissionCreated, "bounty.created": bountyCreated, "bounty.updated": bountyUpdated, diff --git a/apps/web/lib/webhook/schemas.ts b/apps/web/lib/webhook/schemas.ts index c67e5c8efca..c6b3dac3d9d 100644 --- a/apps/web/lib/webhook/schemas.ts +++ b/apps/web/lib/webhook/schemas.ts @@ -5,6 +5,7 @@ import { CustomerSchema } from "../zod/schemas/customers"; import { linkEventSchema } from "../zod/schemas/links"; import { EnrolledPartnerSchema, + partnerMergedWebhookSchema, WebhookPartnerSchema, } from "../zod/schemas/partners"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; @@ -156,6 +157,20 @@ export const webhookEventSchema = z outputId: "PartnerApplicationSubmittedEvent", }), + z + .object({ + id: z.string(), + event: z.literal("partner.merged"), + createdAt: z.string(), + data: partnerMergedWebhookSchema, + }) + .meta({ + description: + "Triggered when two partner accounts are merged. Fired once per program the source partner was enrolled in.", + id: "PartnerMergedEvent", + outputId: "PartnerMergedEvent", + }), + z .object({ id: z.string(), diff --git a/apps/web/lib/webhook/types.ts b/apps/web/lib/webhook/types.ts index b25707c8a03..58a7fcc832b 100644 --- a/apps/web/lib/webhook/types.ts +++ b/apps/web/lib/webhook/types.ts @@ -2,7 +2,10 @@ import * as z from "zod/v4"; import { BountySchema } from "../zod/schemas/bounties"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { linkEventSchema } from "../zod/schemas/links"; -import { EnrolledPartnerSchema } from "../zod/schemas/partners"; +import { + EnrolledPartnerSchema, + partnerMergedWebhookSchema, +} from "../zod/schemas/partners"; import { payoutWebhookEventSchema } from "../zod/schemas/payouts"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; import { WEBHOOK_TRIGGERS } from "./constants"; @@ -26,6 +29,10 @@ export type PartnerApplicationWebhookPayload = z.infer< typeof partnerApplicationWebhookSchema >; +export type PartnerMergedWebhookPayload = z.infer< + typeof partnerMergedWebhookSchema +>; + export type CommissionEventWebhookPayload = z.infer< typeof CommissionWebhookSchema >; @@ -43,6 +50,7 @@ export type WebhookEventPayload = | SaleEventWebhookPayload | PartnerEventWebhookPayload | PartnerApplicationWebhookPayload + | PartnerMergedWebhookPayload | CommissionEventWebhookPayload | BountyEventWebhookPayload | PayoutEventWebhookPayload; diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 9096fc14533..f114323c309 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -629,6 +629,30 @@ export const WebhookPartnerSchema = PartnerSchema.pick({ totalCommissions: centsSchema, }); +const partnerMergedAccountSchema = z.object({ + id: z.string().describe("The partner's unique ID on Dub."), + tenantId: z + .string() + .nullable() + .describe("The partner's unique ID within your database for this program."), + email: z.string().nullable().describe("The partner's email address."), +}); + +export const partnerMergedWebhookSchema = z.object({ + programId: z.string().describe("The program's unique ID on Dub."), + targetAlreadyEnrolled: z + .boolean() + .describe( + "Whether the surviving partner was already enrolled in this program before the merge. If `true`, the workspace had both partners and should collapse the source into the target. If `false`, only the source was enrolled and its Dub partner ID changed.", + ), + source: partnerMergedAccountSchema.describe( + "The partner account that was merged away. Its ID no longer exists.", + ), + target: partnerMergedAccountSchema.describe( + "The surviving partner account. `tenantId` is the post-merge value for this program.", + ), +}); + export const LeaderboardPartnerSchema = z.object({ id: z.string(), totalCommissions: centsSchemaWithDefault, diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 863522a25f9..34dbdc8a915 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -12,7 +12,10 @@ import { BountySchema } from "@/lib/zod/schemas/bounties"; import { CommissionWebhookSchema } from "@/lib/zod/schemas/commissions"; import { CustomerSchema } from "@/lib/zod/schemas/customers"; import { linkEventSchema } from "@/lib/zod/schemas/links"; -import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; +import { + EnrolledPartnerSchema, + partnerMergedWebhookSchema, +} from "@/lib/zod/schemas/partners"; import { payoutWebhookEventSchema } from "@/lib/zod/schemas/payouts"; import { partnerApplicationWebhookSchema } from "@/lib/zod/schemas/program-application"; import { describe, expect, test } from "vitest"; @@ -95,6 +98,7 @@ const eventSchemas: Record = { "sale.created": saleWebhookEventSchemaExtended, "partner.application_submitted": partnerApplicationWebhookSchema, "partner.enrolled": enrolledPartnerSchemaExtended, + "partner.merged": partnerMergedWebhookSchema, "commission.created": commissionWebhookEventSchemaExtended, "bounty.created": bountyWebhookEventSchemaExtended, "bounty.updated": bountyWebhookEventSchemaExtended, From 59d18a47f0096afcfc7d1ab77d3b50ba4d2c78fd Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Wed, 19 Aug 2026 07:59:25 -0700 Subject: [PATCH 03/28] Commit 2 --- packages/ui/src/modal.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/modal.tsx b/packages/ui/src/modal.tsx index f33e193bbb5..2d7c0fe3ca9 100644 --- a/packages/ui/src/modal.tsx +++ b/packages/ui/src/modal.tsx @@ -17,9 +17,12 @@ import { useMediaQuery } from "./hooks"; // Dev-only detector for modal remount bugs: an open Modal unmounting while // another mounts open in the same commit means its element type changed // (unstable hook dep) and any user state in it was lost -let pendingOpenUnmount = false; +let pendingOpenUnmount: unknown = null; function useWarnOnRemountWhileOpen(showModal?: boolean) { + // Per-instance token so Strict Mode's same-instance setup/cleanup/setup + // cycle doesn't register as a remount + const instance = useRef({}); const showModalRef = useRef(showModal); useEffect(() => { @@ -29,8 +32,12 @@ function useWarnOnRemountWhileOpen(showModal?: boolean) { useEffect(() => { if (process.env.NODE_ENV !== "development") return; - if (showModalRef.current && pendingOpenUnmount) { - pendingOpenUnmount = false; + if ( + showModalRef.current && + pendingOpenUnmount && + pendingOpenUnmount !== instance.current + ) { + pendingOpenUnmount = null; console.warn( "[Modal] remounted while open — the modal's element type changed mid-session (unstable hook dep?) and any user state in it was lost.", ); @@ -38,9 +45,9 @@ function useWarnOnRemountWhileOpen(showModal?: boolean) { return () => { if (showModalRef.current) { - pendingOpenUnmount = true; + pendingOpenUnmount = instance.current; queueMicrotask(() => { - pendingOpenUnmount = false; + pendingOpenUnmount = null; }); } }; From 7c87cc65df609495e43cbc1bd807f0d126087492 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Wed, 19 Aug 2026 08:18:15 -0700 Subject: [PATCH 04/28] Commit 3 --- packages/ui/src/modal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/modal.tsx b/packages/ui/src/modal.tsx index 2d7c0fe3ca9..6f69fa3c1b5 100644 --- a/packages/ui/src/modal.tsx +++ b/packages/ui/src/modal.tsx @@ -47,7 +47,9 @@ function useWarnOnRemountWhileOpen(showModal?: boolean) { if (showModalRef.current) { pendingOpenUnmount = instance.current; queueMicrotask(() => { - pendingOpenUnmount = null; + if (pendingOpenUnmount === instance.current) { + pendingOpenUnmount = null; + } }); } }; From 6fe27559cb0777a754e027b49f4c36479594c470 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 19 Aug 2026 14:31:33 -0300 Subject: [PATCH 05/28] code improvements --- apps/web/lib/zod/schemas/partners.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index f114323c309..372f56cc511 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -646,7 +646,7 @@ export const partnerMergedWebhookSchema = z.object({ "Whether the surviving partner was already enrolled in this program before the merge. If `true`, the workspace had both partners and should collapse the source into the target. If `false`, only the source was enrolled and its Dub partner ID changed.", ), source: partnerMergedAccountSchema.describe( - "The partner account that was merged away. Its ID no longer exists.", + "The partner account that was merged away. Its enrollment in this program no longer exists; use `target.id` instead.", ), target: partnerMergedAccountSchema.describe( "The surviving partner account. `tenantId` is the post-merge value for this program.", From 23bc57d3bdcfae964e41ea0f24bf2a2969d3f775 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 10:47:05 +0530 Subject: [PATCH 06/28] Add POST /clawbacks API and replace the create-clawback server action. --- apps/web/app/(ee)/api/clawbacks/route.ts | 83 +++++++++++ .../commissions/create-clawback-sheet.tsx | 64 ++++----- .../lib/actions/partners/create-clawback.ts | 38 ----- apps/web/lib/auth/product-access-guard.ts | 1 + .../lib/openapi/clawbacks/create-clawback.ts | 34 +++++ apps/web/lib/openapi/clawbacks/index.ts | 8 ++ apps/web/lib/openapi/index.ts | 2 + apps/web/lib/zod/schemas/commissions.ts | 20 +-- .../api/clawbacks/clawbacks.spec.ts | 132 ++++++++++++++++++ .../api/discount-codes/discount-codes.spec.ts | 35 +---- .../api/discounts/discounts.spec.ts | 35 +---- .../api/partners/ban-partner.spec.ts | 36 +---- apps/web/playwright/api/partners/helpers.ts | 48 +++++++ .../playwright/api/partners/partners.spec.ts | 36 +---- 14 files changed, 365 insertions(+), 207 deletions(-) create mode 100644 apps/web/app/(ee)/api/clawbacks/route.ts delete mode 100644 apps/web/lib/actions/partners/create-clawback.ts create mode 100644 apps/web/lib/openapi/clawbacks/create-clawback.ts create mode 100644 apps/web/lib/openapi/clawbacks/index.ts create mode 100644 apps/web/playwright/api/clawbacks/clawbacks.spec.ts create mode 100644 apps/web/playwright/api/partners/helpers.ts diff --git a/apps/web/app/(ee)/api/clawbacks/route.ts b/apps/web/app/(ee)/api/clawbacks/route.ts new file mode 100644 index 00000000000..fcf05260dff --- /dev/null +++ b/apps/web/app/(ee)/api/clawbacks/route.ts @@ -0,0 +1,83 @@ +import { DubApiError } from "@/lib/api/errors"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withWorkspace } from "@/lib/auth"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; +import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; +import { prisma } from "@/lib/prisma"; +import { + createClawbackSchema, + createCommissionResponseSchema, +} from "@/lib/zod/schemas/commissions"; +import { NextResponse } from "next/server"; + +// POST /api/clawbacks – create a clawback for a partner +export const POST = withWorkspace( + async ({ workspace, session, req }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + let { partnerId, tenantId, amount, reason } = createClawbackSchema.parse( + await parseRequestBody(req), + ); + + throwIfNoPartnerIdOrTenantId({ + partnerId, + tenantId, + }); + + if (tenantId && !partnerId) { + const partner = await prisma.programEnrollment.findUnique({ + where: { + tenantId_programId: { + tenantId, + programId, + }, + }, + select: { + partnerId: true, + }, + }); + + if (!partner) { + throw new DubApiError({ + code: "not_found", + message: `Partner with specified tenantId ${tenantId} not found.`, + }); + } + + partnerId = partner.partnerId; + } + + await getProgramEnrollmentOrThrow({ + programId, + partnerId: partnerId!, + include: {}, + }); + + await queuePartnerCommissionCreation({ + event: "custom", + partnerId: partnerId!, + programId, + description: reason, + amount: -amount, + quantity: 1, + userId: session.user.id, + triggerAggregateDueCommissions: true, + }); + + return NextResponse.json( + createCommissionResponseSchema.parse({ + success: true, + message: "Your clawback is being created and will appear shortly.", + }), + { + status: 202, + }, + ); + }, + { + requiredPlan: ["business", "advanced", "enterprise"], + requiredRoles: ["owner", "member"], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx index 2e1b8e80107..961f0a16a9b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx @@ -1,14 +1,14 @@ -import { createClawbackAction } from "@/lib/actions/partners/create-clawback"; import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useWorkspace from "@/lib/swr/use-workspace"; import { CLAWBACK_REASONS, createClawbackSchema, + createCommissionResponseSchema, } from "@/lib/zod/schemas/commissions"; import { PartnerSelector } from "@/ui/partners/partner-selector"; import { X } from "@/ui/shared/icons"; import { Button, Sheet } from "@dub/ui"; -import { useAction } from "next-safe-action/hooks"; import { useParams } from "next/navigation"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -41,42 +41,38 @@ function CreateClawbackSheetContent( } = useForm({ defaultValues: { partnerId: params.partnerId, - description: "", + reason: undefined, }, }); - const [partnerId, amount, description] = watch([ - "partnerId", - "amount", - "description", - ]); + const [partnerId, amount, reason] = watch(["partnerId", "amount", "reason"]); - const { executeAsync, isPending } = useAction(createClawbackAction, { - onSuccess: () => { - toast.success("A clawback has been created for the partner!"); - setIsOpen(false); - mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`); - const currentValues = getValues(); - reset(currentValues); - }, - onError({ error }) { - toast.error(error.serverError || "Failed to create clawback."); - }, - }); + const { makeRequest, isSubmitting: isCreating } = + useApiMutation>(); const onSubmit = async (data: FormData) => { if (!workspaceId || !defaultProgramId) { return; } - await executeAsync({ - ...data, - amount: data.amount * 100, - workspaceId, + await makeRequest("/api/clawbacks", { + method: "POST", + body: { + partnerId: data.partnerId, + amount: data.amount ? Math.round(data.amount * 100) : 0, + reason: data.reason, + }, + onSuccess: async ({ message }) => { + toast.success(message); + setIsOpen(false); + await mutatePrefix("/api/commissions"); + const currentValues = getValues(); + reset(currentValues); + }, }); }; - const disableSubmitButton = !partnerId || !amount || !description; + const disableSubmitButton = !partnerId || !amount || !reason; return (
@@ -110,7 +106,7 @@ function CreateClawbackSheetContent( rules={{ required: true }} render={({ field }) => ( )} @@ -171,21 +167,21 @@ function CreateClawbackSheetContent(
( )} /> - {errors.description && ( + {errors.reason && ( - {errors.description.message} + {errors.reason.message} )}
@@ -216,14 +212,14 @@ function CreateClawbackSheetContent( onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isPending || isSubmitting || isSubmitSuccessful} + disabled={isCreating || isSubmitting || isSubmitSuccessful} />
diff --git a/apps/web/lib/actions/partners/create-clawback.ts b/apps/web/lib/actions/partners/create-clawback.ts deleted file mode 100644 index 5372d8d10f1..00000000000 --- a/apps/web/lib/actions/partners/create-clawback.ts +++ /dev/null @@ -1,38 +0,0 @@ -"use server"; - -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; -import { createClawbackSchema } from "@/lib/zod/schemas/commissions"; -import { authActionClient } from "../safe-action"; -import { throwIfNoPermission } from "../throw-if-no-permission"; - -export const createClawbackAction = authActionClient - .inputSchema(createClawbackSchema) - .action(async ({ parsedInput, ctx }) => { - const { workspace, user } = ctx; - const programId = getDefaultProgramIdOrThrow(workspace); - - throwIfNoPermission({ - role: workspace.role, - requiredRoles: ["owner", "member"], - }); - - const { partnerId, amount, description } = parsedInput; - - await getProgramEnrollmentOrThrow({ - programId, - partnerId, - include: {}, - }); - - await queuePartnerCommissionCreation({ - event: "custom", - partnerId, - programId, - description, - amount: -amount, - quantity: 1, - userId: user.id, - }); - }); diff --git a/apps/web/lib/auth/product-access-guard.ts b/apps/web/lib/auth/product-access-guard.ts index e09a179c775..6cebbd3b97b 100644 --- a/apps/web/lib/auth/product-access-guard.ts +++ b/apps/web/lib/auth/product-access-guard.ts @@ -34,6 +34,7 @@ export const PROGRAM_API_PATHS = [ "/api/programs", "/api/partners", "/api/commissions", + "/api/clawbacks", "/api/payouts", "/api/rewards", "/api/bounties", diff --git a/apps/web/lib/openapi/clawbacks/create-clawback.ts b/apps/web/lib/openapi/clawbacks/create-clawback.ts new file mode 100644 index 00000000000..2e922320cec --- /dev/null +++ b/apps/web/lib/openapi/clawbacks/create-clawback.ts @@ -0,0 +1,34 @@ +import { + createClawbackSchema, + createCommissionResponseSchema, +} from "@/lib/zod/schemas/commissions"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses } from "../responses"; + +export const createClawback: ZodOpenApiOperationObject = { + operationId: "createClawback", + "x-speakeasy-name-override": "create", + summary: "Create clawback", + description: + "Create a clawback for a partner. Clawback creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the clawback is created.", + requestBody: { + content: { + "application/json": { + schema: createClawbackSchema, + }, + }, + }, + responses: { + "202": { + description: "The request was accepted and clawback creation was queued.", + content: { + "application/json": { + schema: createCommissionResponseSchema, + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Commissions"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/clawbacks/index.ts b/apps/web/lib/openapi/clawbacks/index.ts new file mode 100644 index 00000000000..22a2253e50c --- /dev/null +++ b/apps/web/lib/openapi/clawbacks/index.ts @@ -0,0 +1,8 @@ +import { ZodOpenApiPathsObject } from "zod-openapi"; +import { createClawback } from "./create-clawback"; + +export const clawbacksPaths: ZodOpenApiPathsObject = { + "/clawbacks": { + post: createClawback, + }, +}; diff --git a/apps/web/lib/openapi/index.ts b/apps/web/lib/openapi/index.ts index 0044fc30b02..aa294209832 100644 --- a/apps/web/lib/openapi/index.ts +++ b/apps/web/lib/openapi/index.ts @@ -7,6 +7,7 @@ import { LinkErrorSchema, LinkSchema } from "../zod/schemas/links"; import { LinkTagSchema } from "../zod/schemas/tags"; import { analyticsPath } from "./analytics"; import { bountiesPaths } from "./bounties"; +import { clawbacksPaths } from "./clawbacks"; import { commissionsPaths } from "./commissions"; import { customersPaths } from "./customers"; import { discountCodesPaths } from "./discount-codes"; @@ -57,6 +58,7 @@ export const document = createDocument({ ...partnersPaths, ...discountCodesPaths, ...commissionsPaths, + ...clawbacksPaths, ...payoutsPaths, ...embedTokensPaths, ...qrCodePaths, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index a940a4dcfc0..b764fd1ba52 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -7,7 +7,11 @@ import { getCursorPaginationQuerySchema, getPaginationQuerySchema, } from "./misc"; -import { EnrolledPartnerSchema, WebhookPartnerSchema } from "./partners"; +import { + EnrolledPartnerSchema, + partnerIdTenantIdSchema, + WebhookPartnerSchema, +} from "./partners"; import { PayoutSchema } from "./payouts"; import { rewardContextSchema, RewardSchema } from "./rewards"; import { UserSchema } from "./users"; @@ -318,13 +322,13 @@ export const CLAWBACK_REASONS_MAP = Object.fromEntries( CLAWBACK_REASONS.map((r) => [r.value, r]), ); -export const createClawbackSchema = z.object({ - workspaceId: z.string(), - partnerId: z.string(), - amount: z.number().gt(0, "Amount must be greater than 0."), - description: z.enum( - CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]], - ), +export const createClawbackSchema = partnerIdTenantIdSchema.extend({ + amount: centsSchema + .pipe(z.number().gt(0, "Amount must be greater than 0.")) + .describe("The clawback amount in cents (positive)."), + reason: z + .enum(CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]]) + .describe("The reason for the clawback."), }); export const COMMISSION_EXPORT_COLUMNS = [ diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts new file mode 100644 index 00000000000..60cbdc78985 --- /dev/null +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -0,0 +1,132 @@ +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { test } from "../fixtures"; +import { createPartner, deletePartner } from "../partners/helpers"; + +const expectedQueuedResponse = { + success: true, + message: "Your clawback is being created and will appear shortly.", +}; + +test("POST /clawbacks – by partnerId", async ({ api, program }) => { + let partnerId: string | undefined; + + try { + const { status: createStatus, data: created } = await createPartner(api, { + groupId: program.defaultGroupId, + }); + partnerId = created.id; + expect(createStatus).toEqual(201); + + const { status, data } = await api.post("/api/clawbacks", { + partnerId, + amount: 500, + reason: "fraud", + }); + + expect(status).toEqual(202); + expect(data).toStrictEqual(expectedQueuedResponse); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /clawbacks – by tenantId", async ({ api, program }) => { + let partnerId: string | undefined; + const tenantId = nanoid(); + + try { + const { status: createStatus, data: created } = await createPartner(api, { + tenantId, + groupId: program.defaultGroupId, + }); + partnerId = created.id; + expect(createStatus).toEqual(201); + expect(created.tenantId).toBe(tenantId); + + const { status, data } = await api.post("/api/clawbacks", { + tenantId, + amount: 250, + reason: "order_canceled", + }); + + expect(status).toEqual(202); + expect(data).toStrictEqual(expectedQueuedResponse); + } finally { + await deletePartner(partnerId); + } +}); + +const missingPartnerId = `pn_${nanoid()}`; +const missingTenantId = nanoid(); + +const clawbackErrorCases = [ + { + name: "POST /clawbacks – partner not found", + body: { partnerId: missingPartnerId, amount: 500, reason: "fraud" }, + expected: ({ program }: { program: { id: string } }) => + apiError({ + code: "not_found", + message: `Partner ${missingPartnerId} is not enrolled in program ${program.id}.`, + }), + }, + { + name: "POST /clawbacks – tenantId not found", + body: { tenantId: missingTenantId, amount: 500, reason: "fraud" }, + expected: apiError({ + code: "not_found", + message: `Partner with specified tenantId ${missingTenantId} not found.`, + }), + }, + { + name: "POST /clawbacks – missing partnerId and tenantId", + body: { amount: 500, reason: "fraud" }, + expected: apiError({ + code: "bad_request", + message: "Either `partnerId` or `tenantId` must be provided.", + }), + }, + { + name: "POST /clawbacks – amount 0", + body: { partnerId: "pn_test", amount: 0, reason: "fraud" }, + expected: apiError({ + code: "unprocessable_entity", + message: "too_small: amount: Amount must be greater than 0.", + }), + }, + { + name: "POST /clawbacks – amount negative", + body: { partnerId: "pn_test", amount: -100, reason: "fraud" }, + expected: apiError({ + code: "unprocessable_entity", + message: "too_small: amount: Amount must be greater than 0.", + }), + }, + { + name: "POST /clawbacks – missing reason", + body: { partnerId: "pn_test", amount: 500 }, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: reason: Invalid option: expected one of "order_canceled"|"fraud"|"terms_violation"|"tracking_error"|"payment_failed"|"ineligible_partner"|"duplicate_commission"|"other"', + }), + }, + { + name: "POST /clawbacks – invalid reason", + body: { partnerId: "pn_test", amount: 500, reason: "not_a_reason" }, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: reason: Invalid option: expected one of "order_canceled"|"fraud"|"terms_violation"|"tracking_error"|"payment_failed"|"ineligible_partner"|"duplicate_commission"|"other"', + }), + }, +]; + +for (const { name, body, expected } of clawbackErrorCases) { + test(name, async ({ api, program }) => { + expect(await api.post("/api/clawbacks", body)).toEqual( + typeof expected === "function" ? expected({ program }) : expected, + ); + }); +} diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index 6def2c2381c..df7b2d04911 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -1,16 +1,17 @@ import { createId } from "@/lib/api/create-id"; import { constructDiscountCode } from "@/lib/discounts/construct-discount-code"; -import { conn } from "@/lib/planetscale"; import { prisma } from "@/lib/prisma"; -import type { EnrolledPartnerProps } from "@/lib/types"; import { DiscountCodeSchema } from "@/lib/zod/schemas/discount"; import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import { DiscountProvider, RewardStructure } from "@prisma/client"; import * as z from "zod/v4"; -import { randomName, randomPartnerEmail } from "../../utils"; import { test, type ApiClient } from "../fixtures"; +import { + createPartner as createPartnerApi, + deletePartner, +} from "../partners/helpers"; import { TEST_WORKSPACE } from "../setup-test-workspace"; type DiscountCode = z.infer; @@ -123,38 +124,12 @@ async function createPartner( throw new Error("Custom discount group was not seeded."); } - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), + return createPartnerApi(api, { groupId: partnerGroupId, ...overrides, }); } -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.discountCode.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} - async function createDiscountCode( api: ApiClient, overrides: Record = {}, diff --git a/apps/web/playwright/api/discounts/discounts.spec.ts b/apps/web/playwright/api/discounts/discounts.spec.ts index 502caf8ef08..31104f59700 100644 --- a/apps/web/playwright/api/discounts/discounts.spec.ts +++ b/apps/web/playwright/api/discounts/discounts.spec.ts @@ -1,5 +1,4 @@ import { createId } from "@/lib/api/create-id"; -import { conn } from "@/lib/planetscale"; import { prisma } from "@/lib/prisma"; import type { Customer, @@ -12,8 +11,12 @@ import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import { DiscountProvider, RewardStructure } from "@prisma/client"; -import { randomCustomer, randomName, randomPartnerEmail } from "../../utils"; +import { randomCustomer, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; +import { + createPartner as createPartnerApi, + deletePartner, +} from "../partners/helpers"; import { TEST_WORKSPACE } from "../setup-test-workspace"; test.describe.configure({ @@ -139,37 +142,11 @@ async function createPartner(api: ApiClient) { throw new Error("Custom discount group was not seeded."); } - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), + return createPartnerApi(api, { groupId: partnerGroupId, }); } -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.discountCode.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} - async function createCustomerWithCustomDiscount({ api, program, diff --git a/apps/web/playwright/api/partners/ban-partner.spec.ts b/apps/web/playwright/api/partners/ban-partner.spec.ts index 14db60401e1..d0f71fc4083 100644 --- a/apps/web/playwright/api/partners/ban-partner.spec.ts +++ b/apps/web/playwright/api/partners/ban-partner.spec.ts @@ -1,41 +1,9 @@ -import { conn } from "@/lib/planetscale"; -import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; -import { apiError, randomName, randomPartnerEmail } from "../../utils"; +import { apiError } from "../../utils"; import { test, type ApiClient } from "../fixtures"; - -async function createPartner( - api: ApiClient, - overrides: Record = {}, -) { - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), - ...overrides, - }); -} - -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches - // bulkDeletePartners cleanup used by e2e cron. - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} +import { createPartner, deletePartner } from "./helpers"; async function expectPartnerBanned( api: ApiClient, diff --git a/apps/web/playwright/api/partners/helpers.ts b/apps/web/playwright/api/partners/helpers.ts new file mode 100644 index 00000000000..408592e515e --- /dev/null +++ b/apps/web/playwright/api/partners/helpers.ts @@ -0,0 +1,48 @@ +import { conn } from "@/lib/planetscale"; +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps } from "@/lib/types"; +import { randomName, randomPartnerEmail } from "../../utils"; +import type { ApiClient } from "../fixtures"; + +export async function createPartner( + api: ApiClient, + overrides: Record = {}, +) { + return api.post("/api/partners", { + name: randomName(), + email: randomPartnerEmail(), + ...overrides, + }); +} + +export async function deletePartner(partnerId: string | undefined) { + if (!partnerId) return; + + await prisma.commission.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.discountCode.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.link.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.programEnrollment.deleteMany({ + where: { + partnerId, + }, + }); + + // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches + // bulkDeletePartners cleanup used by e2e cron. + await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); +} diff --git a/apps/web/playwright/api/partners/partners.spec.ts b/apps/web/playwright/api/partners/partners.spec.ts index d7fbc62a0bf..cbbc82e0bd2 100644 --- a/apps/web/playwright/api/partners/partners.spec.ts +++ b/apps/web/playwright/api/partners/partners.spec.ts @@ -1,5 +1,3 @@ -import { conn } from "@/lib/planetscale"; -import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; import { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from "@/lib/zod/schemas/partners"; import { nanoid } from "@dub/utils"; @@ -7,8 +5,9 @@ import { expect } from "@playwright/test"; import slugify from "@sindresorhus/slugify"; import * as z from "zod/v4"; import { apiError, randomName, randomPartnerEmail } from "../../utils"; -import { test, type ApiClient } from "../fixtures"; +import { test } from "../fixtures"; import { TEST_WORKSPACE } from "../setup-test-workspace"; +import { createPartner, deletePartner } from "./helpers"; const EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend({ createdAt: z.string(), @@ -22,37 +21,6 @@ function reEscape(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -async function createPartner( - api: ApiClient, - overrides: Record = {}, -) { - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), - ...overrides, - }); -} - -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches - // bulkDeletePartners cleanup used by e2e cron. - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} - test("POST /partners", async ({ api, program }) => { let partnerId: string | undefined; From 46beb0fdb9b2a9ec7aed40cc95612bc35873a979 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 11:14:11 +0530 Subject: [PATCH 07/28] Assert the created clawback --- .../api/clawbacks/clawbacks.spec.ts | 65 +++++++++++++++++++ apps/web/playwright/api/partners/helpers.ts | 6 ++ 2 files changed, 71 insertions(+) diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index 60cbdc78985..52b10a09ef4 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -1,3 +1,4 @@ +import { prisma } from "@/lib/prisma"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import { apiError } from "../../utils"; @@ -27,6 +28,13 @@ test("POST /clawbacks – by partnerId", async ({ api, program }) => { expect(status).toEqual(202); expect(data).toStrictEqual(expectedQueuedResponse); + + await expectClawbackCreated({ + partnerId: created.id, + programId: program.id, + amount: 500, + reason: "fraud", + }); } finally { await deletePartner(partnerId); } @@ -53,6 +61,13 @@ test("POST /clawbacks – by tenantId", async ({ api, program }) => { expect(status).toEqual(202); expect(data).toStrictEqual(expectedQueuedResponse); + + await expectClawbackCreated({ + partnerId: created.id, + programId: program.id, + amount: 250, + reason: "order_canceled", + }); } finally { await deletePartner(partnerId); } @@ -130,3 +145,53 @@ for (const { name, body, expected } of clawbackErrorCases) { ); }); } + +async function expectClawbackCreated({ + partnerId, + programId, + amount, + reason, +}: { + partnerId: string; + programId: string; + amount: number; + reason: string; +}) { + await expect + .poll(async () => { + const commission = await prisma.commission.findFirst({ + where: { + partnerId, + programId, + type: "custom", + description: reason, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + return { + partnerId: commission.partnerId, + programId: commission.programId, + type: commission.type, + amount: Number(commission.amount), + earnings: Number(commission.earnings), + quantity: commission.quantity, + description: commission.description, + }; + }) + .toEqual({ + partnerId, + programId, + type: "custom", + amount: 0, + earnings: -amount, + quantity: 1, + description: reason, + }); +} diff --git a/apps/web/playwright/api/partners/helpers.ts b/apps/web/playwright/api/partners/helpers.ts index 408592e515e..472aa73c3fe 100644 --- a/apps/web/playwright/api/partners/helpers.ts +++ b/apps/web/playwright/api/partners/helpers.ts @@ -24,6 +24,12 @@ export async function deletePartner(partnerId: string | undefined) { }, }); + await prisma.payout.deleteMany({ + where: { + partnerId, + }, + }); + await prisma.discountCode.deleteMany({ where: { partnerId, From a559a71e49b85428cca8b12ed88ea85e0f20756b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 11:42:30 +0530 Subject: [PATCH 08/28] Use POST /commissions for clawbacks instead of dedicated /api/clawbacks endpoint. --- apps/web/app/(ee)/api/clawbacks/route.ts | 83 -------- .../commissions/create-clawback-sheet.tsx | 14 +- apps/web/lib/auth/product-access-guard.ts | 1 - .../lib/openapi/clawbacks/create-clawback.ts | 34 --- apps/web/lib/openapi/clawbacks/index.ts | 8 - .../openapi/commissions/create-commission.ts | 2 +- apps/web/lib/openapi/index.ts | 2 - apps/web/lib/zod/schemas/commissions.ts | 43 ++-- .../api/clawbacks/clawbacks.spec.ts | 196 ++++++++---------- .../commissions/create-commission.test.ts | 2 +- 10 files changed, 128 insertions(+), 257 deletions(-) delete mode 100644 apps/web/app/(ee)/api/clawbacks/route.ts delete mode 100644 apps/web/lib/openapi/clawbacks/create-clawback.ts delete mode 100644 apps/web/lib/openapi/clawbacks/index.ts diff --git a/apps/web/app/(ee)/api/clawbacks/route.ts b/apps/web/app/(ee)/api/clawbacks/route.ts deleted file mode 100644 index fcf05260dff..00000000000 --- a/apps/web/app/(ee)/api/clawbacks/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DubApiError } from "@/lib/api/errors"; -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { parseRequestBody } from "@/lib/api/utils"; -import { withWorkspace } from "@/lib/auth"; -import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; -import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; -import { prisma } from "@/lib/prisma"; -import { - createClawbackSchema, - createCommissionResponseSchema, -} from "@/lib/zod/schemas/commissions"; -import { NextResponse } from "next/server"; - -// POST /api/clawbacks – create a clawback for a partner -export const POST = withWorkspace( - async ({ workspace, session, req }) => { - const programId = getDefaultProgramIdOrThrow(workspace); - - let { partnerId, tenantId, amount, reason } = createClawbackSchema.parse( - await parseRequestBody(req), - ); - - throwIfNoPartnerIdOrTenantId({ - partnerId, - tenantId, - }); - - if (tenantId && !partnerId) { - const partner = await prisma.programEnrollment.findUnique({ - where: { - tenantId_programId: { - tenantId, - programId, - }, - }, - select: { - partnerId: true, - }, - }); - - if (!partner) { - throw new DubApiError({ - code: "not_found", - message: `Partner with specified tenantId ${tenantId} not found.`, - }); - } - - partnerId = partner.partnerId; - } - - await getProgramEnrollmentOrThrow({ - programId, - partnerId: partnerId!, - include: {}, - }); - - await queuePartnerCommissionCreation({ - event: "custom", - partnerId: partnerId!, - programId, - description: reason, - amount: -amount, - quantity: 1, - userId: session.user.id, - triggerAggregateDueCommissions: true, - }); - - return NextResponse.json( - createCommissionResponseSchema.parse({ - success: true, - message: "Your clawback is being created and will appear shortly.", - }), - { - status: 202, - }, - ); - }, - { - requiredPlan: ["business", "advanced", "enterprise"], - requiredRoles: ["owner", "member"], - }, -); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx index 961f0a16a9b..a351a11a924 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx @@ -3,7 +3,6 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useWorkspace from "@/lib/swr/use-workspace"; import { CLAWBACK_REASONS, - createClawbackSchema, createCommissionResponseSchema, } from "@/lib/zod/schemas/commissions"; import { PartnerSelector } from "@/ui/partners/partner-selector"; @@ -21,7 +20,11 @@ interface CreateClawbackSheetProps { nested?: boolean; } -type FormData = z.infer; +type FormData = { + partnerId?: string; + amount?: number; + reason?: (typeof CLAWBACK_REASONS)[number]["value"]; +}; function CreateClawbackSheetContent( props: Omit, @@ -55,12 +58,13 @@ function CreateClawbackSheetContent( return; } - await makeRequest("/api/clawbacks", { + await makeRequest("/api/commissions", { method: "POST", body: { + type: "custom", partnerId: data.partnerId, - amount: data.amount ? Math.round(data.amount * 100) : 0, - reason: data.reason, + amount: data.amount ? -Math.round(data.amount * 100) : 0, + description: data.reason, }, onSuccess: async ({ message }) => { toast.success(message); diff --git a/apps/web/lib/auth/product-access-guard.ts b/apps/web/lib/auth/product-access-guard.ts index 6cebbd3b97b..e09a179c775 100644 --- a/apps/web/lib/auth/product-access-guard.ts +++ b/apps/web/lib/auth/product-access-guard.ts @@ -34,7 +34,6 @@ export const PROGRAM_API_PATHS = [ "/api/programs", "/api/partners", "/api/commissions", - "/api/clawbacks", "/api/payouts", "/api/rewards", "/api/bounties", diff --git a/apps/web/lib/openapi/clawbacks/create-clawback.ts b/apps/web/lib/openapi/clawbacks/create-clawback.ts deleted file mode 100644 index 2e922320cec..00000000000 --- a/apps/web/lib/openapi/clawbacks/create-clawback.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - createClawbackSchema, - createCommissionResponseSchema, -} from "@/lib/zod/schemas/commissions"; -import { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "../responses"; - -export const createClawback: ZodOpenApiOperationObject = { - operationId: "createClawback", - "x-speakeasy-name-override": "create", - summary: "Create clawback", - description: - "Create a clawback for a partner. Clawback creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the clawback is created.", - requestBody: { - content: { - "application/json": { - schema: createClawbackSchema, - }, - }, - }, - responses: { - "202": { - description: "The request was accepted and clawback creation was queued.", - content: { - "application/json": { - schema: createCommissionResponseSchema, - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Commissions"], - security: [{ token: [] }], -}; diff --git a/apps/web/lib/openapi/clawbacks/index.ts b/apps/web/lib/openapi/clawbacks/index.ts deleted file mode 100644 index 22a2253e50c..00000000000 --- a/apps/web/lib/openapi/clawbacks/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZodOpenApiPathsObject } from "zod-openapi"; -import { createClawback } from "./create-clawback"; - -export const clawbacksPaths: ZodOpenApiPathsObject = { - "/clawbacks": { - post: createClawback, - }, -}; diff --git a/apps/web/lib/openapi/commissions/create-commission.ts b/apps/web/lib/openapi/commissions/create-commission.ts index 9c6c9edf0cd..9ccee783d93 100644 --- a/apps/web/lib/openapi/commissions/create-commission.ts +++ b/apps/web/lib/openapi/commissions/create-commission.ts @@ -10,7 +10,7 @@ export const createCommission: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "create", summary: "Create commission", description: - "Create one or more commissions (custom, lead or sale) for a partner. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.", + "Create one or more commissions (custom, lead or sale) for a partner. Custom commissions accept a negative `amount` to create a clawback; in that case `description` is required and may be a known clawback reason or any other string. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.", requestBody: { content: { "application/json": { diff --git a/apps/web/lib/openapi/index.ts b/apps/web/lib/openapi/index.ts index aa294209832..0044fc30b02 100644 --- a/apps/web/lib/openapi/index.ts +++ b/apps/web/lib/openapi/index.ts @@ -7,7 +7,6 @@ import { LinkErrorSchema, LinkSchema } from "../zod/schemas/links"; import { LinkTagSchema } from "../zod/schemas/tags"; import { analyticsPath } from "./analytics"; import { bountiesPaths } from "./bounties"; -import { clawbacksPaths } from "./clawbacks"; import { commissionsPaths } from "./commissions"; import { customersPaths } from "./customers"; import { discountCodesPaths } from "./discount-codes"; @@ -58,7 +57,6 @@ export const document = createDocument({ ...partnersPaths, ...discountCodesPaths, ...commissionsPaths, - ...clawbacksPaths, ...payoutsPaths, ...embedTokensPaths, ...qrCodePaths, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index b764fd1ba52..7cf8af80345 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -7,11 +7,7 @@ import { getCursorPaginationQuerySchema, getPaginationQuerySchema, } from "./misc"; -import { - EnrolledPartnerSchema, - partnerIdTenantIdSchema, - WebhookPartnerSchema, -} from "./partners"; +import { EnrolledPartnerSchema, WebhookPartnerSchema } from "./partners"; import { PayoutSchema } from "./payouts"; import { rewardContextSchema, RewardSchema } from "./rewards"; import { UserSchema } from "./users"; @@ -322,15 +318,6 @@ export const CLAWBACK_REASONS_MAP = Object.fromEntries( CLAWBACK_REASONS.map((r) => [r.value, r]), ); -export const createClawbackSchema = partnerIdTenantIdSchema.extend({ - amount: centsSchema - .pipe(z.number().gt(0, "Amount must be greater than 0.")) - .describe("The clawback amount in cents (positive)."), - reason: z - .enum(CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]]) - .describe("The reason for the clawback."), -}); - export const COMMISSION_EXPORT_COLUMNS = [ { id: "id", label: "ID", type: "string", default: true }, { id: "type", label: "Type", type: "string", default: true }, @@ -465,15 +452,21 @@ export const createPartnerCommissionSchema = z.object({ export const createManualCommissionBodySchema = z .discriminatedUnion("type", [ - // Custom commission + // Custom commission (negative amount = clawback) z.object({ type: z.literal("custom"), partnerId: z .string() .describe("The ID of the partner to create the commission for."), amount: centsSchema - .pipe(z.number().min(1)) - .describe("The commission amount in cents."), + .pipe( + z.number().refine((n) => n !== 0, { + message: "Amount cannot be 0.", + }), + ) + .describe( + "The commission amount in cents. Use a negative amount to create a clawback.", + ), date: parseDateSchema .nullish() .describe("If not provided, the current date will be used."), @@ -481,7 +474,9 @@ export const createManualCommissionBodySchema = z .string() .max(190) .nullish() - .describe("The description of the commission."), + .describe( + "The description of the commission. Required for clawbacks (negative `amount`). May be a known clawback reason (`order_canceled`, `fraud`, `terms_violation`, `tracking_error`, `payment_failed`, `ineligible_partner`, `duplicate_commission`, `other`) or any other string.", + ), }), // Lead commission @@ -577,6 +572,18 @@ export const createManualCommissionBodySchema = z }), ]) .superRefine((data, ctx) => { + if (data.type === "custom" && data.amount < 0) { + if (!data.description?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "`description` is required when creating a clawback (negative amount).", + path: ["description"], + }); + } + return; + } + if (data.type !== "sale") return; if (!data.importStripeInvoices && data.saleAmount == null) { diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index 52b10a09ef4..8a0a6a9fb77 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -7,10 +7,60 @@ import { createPartner, deletePartner } from "../partners/helpers"; const expectedQueuedResponse = { success: true, - message: "Your clawback is being created and will appear shortly.", + message: "Your commissions are being created and will appear shortly.", }; -test("POST /clawbacks – by partnerId", async ({ api, program }) => { +async function expectClawbackCreated({ + partnerId, + programId, + amount, + description, +}: { + partnerId: string; + programId: string; + amount: number; + description: string; +}) { + await expect + .poll(async () => { + const commission = await prisma.commission.findFirst({ + where: { + partnerId, + programId, + type: "custom", + description, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + return { + partnerId: commission.partnerId, + programId: commission.programId, + type: commission.type, + amount: Number(commission.amount), + earnings: Number(commission.earnings), + quantity: commission.quantity, + description: commission.description, + }; + }) + .toEqual({ + partnerId, + programId, + type: "custom", + amount: 0, + earnings: -amount, + quantity: 1, + description, + }); +} + +test("POST /commissions – clawback by partnerId", async ({ api, program }) => { let partnerId: string | undefined; try { @@ -20,10 +70,11 @@ test("POST /clawbacks – by partnerId", async ({ api, program }) => { partnerId = created.id; expect(createStatus).toEqual(201); - const { status, data } = await api.post("/api/clawbacks", { + const { status, data } = await api.post("/api/commissions", { + type: "custom", partnerId, - amount: 500, - reason: "fraud", + amount: -500, + description: "fraud", }); expect(status).toEqual(202); @@ -33,30 +84,32 @@ test("POST /clawbacks – by partnerId", async ({ api, program }) => { partnerId: created.id, programId: program.id, amount: 500, - reason: "fraud", + description: "fraud", }); } finally { await deletePartner(partnerId); } }); -test("POST /clawbacks – by tenantId", async ({ api, program }) => { +test("POST /commissions – clawback with arbitrary description", async ({ + api, + program, +}) => { let partnerId: string | undefined; - const tenantId = nanoid(); + const description = `chargeback-${nanoid()}`; try { const { status: createStatus, data: created } = await createPartner(api, { - tenantId, groupId: program.defaultGroupId, }); partnerId = created.id; expect(createStatus).toEqual(201); - expect(created.tenantId).toBe(tenantId); - const { status, data } = await api.post("/api/clawbacks", { - tenantId, - amount: 250, - reason: "order_canceled", + const { status, data } = await api.post("/api/commissions", { + type: "custom", + partnerId, + amount: -100, + description, }); expect(status).toEqual(202); @@ -65,8 +118,8 @@ test("POST /clawbacks – by tenantId", async ({ api, program }) => { await expectClawbackCreated({ partnerId: created.id, programId: program.id, - amount: 250, - reason: "order_canceled", + amount: 100, + description, }); } finally { await deletePartner(partnerId); @@ -74,12 +127,16 @@ test("POST /clawbacks – by tenantId", async ({ api, program }) => { }); const missingPartnerId = `pn_${nanoid()}`; -const missingTenantId = nanoid(); const clawbackErrorCases = [ { - name: "POST /clawbacks – partner not found", - body: { partnerId: missingPartnerId, amount: 500, reason: "fraud" }, + name: "POST /commissions – clawback partner not found", + body: { + type: "custom", + partnerId: missingPartnerId, + amount: -500, + description: "fraud", + }, expected: ({ program }: { program: { id: string } }) => apiError({ code: "not_found", @@ -87,111 +144,42 @@ const clawbackErrorCases = [ }), }, { - name: "POST /clawbacks – tenantId not found", - body: { tenantId: missingTenantId, amount: 500, reason: "fraud" }, - expected: apiError({ - code: "not_found", - message: `Partner with specified tenantId ${missingTenantId} not found.`, - }), - }, - { - name: "POST /clawbacks – missing partnerId and tenantId", - body: { amount: 500, reason: "fraud" }, - expected: apiError({ - code: "bad_request", - message: "Either `partnerId` or `tenantId` must be provided.", - }), - }, - { - name: "POST /clawbacks – amount 0", - body: { partnerId: "pn_test", amount: 0, reason: "fraud" }, - expected: apiError({ - code: "unprocessable_entity", - message: "too_small: amount: Amount must be greater than 0.", - }), - }, - { - name: "POST /clawbacks – amount negative", - body: { partnerId: "pn_test", amount: -100, reason: "fraud" }, + name: "POST /commissions – clawback missing partnerId", + body: { type: "custom", amount: -500, description: "fraud" }, expected: apiError({ code: "unprocessable_entity", - message: "too_small: amount: Amount must be greater than 0.", + message: + "invalid_type: partnerId: Invalid input: expected string, received undefined", }), }, { - name: "POST /clawbacks – missing reason", - body: { partnerId: "pn_test", amount: 500 }, + name: "POST /commissions – clawback amount 0", + body: { + type: "custom", + partnerId: "pn_test", + amount: 0, + description: "fraud", + }, expected: apiError({ code: "unprocessable_entity", - message: - 'invalid_value: reason: Invalid option: expected one of "order_canceled"|"fraud"|"terms_violation"|"tracking_error"|"payment_failed"|"ineligible_partner"|"duplicate_commission"|"other"', + message: "custom: amount: Amount cannot be 0.", }), }, { - name: "POST /clawbacks – invalid reason", - body: { partnerId: "pn_test", amount: 500, reason: "not_a_reason" }, + name: "POST /commissions – clawback missing description", + body: { type: "custom", partnerId: "pn_test", amount: -500 }, expected: apiError({ code: "unprocessable_entity", message: - 'invalid_value: reason: Invalid option: expected one of "order_canceled"|"fraud"|"terms_violation"|"tracking_error"|"payment_failed"|"ineligible_partner"|"duplicate_commission"|"other"', + "custom: description: `description` is required when creating a clawback (negative amount).", }), }, ]; for (const { name, body, expected } of clawbackErrorCases) { test(name, async ({ api, program }) => { - expect(await api.post("/api/clawbacks", body)).toEqual( + expect(await api.post("/api/commissions", body)).toEqual( typeof expected === "function" ? expected({ program }) : expected, ); }); } - -async function expectClawbackCreated({ - partnerId, - programId, - amount, - reason, -}: { - partnerId: string; - programId: string; - amount: number; - reason: string; -}) { - await expect - .poll(async () => { - const commission = await prisma.commission.findFirst({ - where: { - partnerId, - programId, - type: "custom", - description: reason, - }, - orderBy: { - createdAt: "desc", - }, - }); - - if (!commission) { - return null; - } - - return { - partnerId: commission.partnerId, - programId: commission.programId, - type: commission.type, - amount: Number(commission.amount), - earnings: Number(commission.earnings), - quantity: commission.quantity, - description: commission.description, - }; - }) - .toEqual({ - partnerId, - programId, - type: "custom", - amount: 0, - earnings: -amount, - quantity: 1, - description: reason, - }); -} diff --git a/apps/web/tests/commissions/create-commission.test.ts b/apps/web/tests/commissions/create-commission.test.ts index 04aae324f07..903a87033b7 100644 --- a/apps/web/tests/commissions/create-commission.test.ts +++ b/apps/web/tests/commissions/create-commission.test.ts @@ -31,7 +31,7 @@ const validationCases = [ name: "custom commission with amount 0", body: { type: "custom", partnerId: E2E_PARTNER.id, amount: 0 }, expectedStatus: 422, - expectedMessage: "too_small: amount: Too small: expected number to be >=1", + expectedMessage: "custom: amount: Amount cannot be 0.", }, { name: "sale commission missing saleAmount", From ef55717c70e10c4608c69198c7d0bcd0ca535226 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 12:08:38 +0530 Subject: [PATCH 09/28] Return plain messages for single custom Zod validation errors. --- .../api/commissions/create-manual-commissions.ts | 7 ------- apps/web/lib/api/errors.ts | 13 +++++++++++++ apps/web/lib/zod/schemas/commissions.ts | 5 ++--- apps/web/playwright/api/clawbacks/clawbacks.spec.ts | 4 ++-- .../web/tests/commissions/create-commission.test.ts | 4 ++-- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/web/lib/api/commissions/create-manual-commissions.ts b/apps/web/lib/api/commissions/create-manual-commissions.ts index ffbd3f7ebe3..06809015177 100644 --- a/apps/web/lib/api/commissions/create-manual-commissions.ts +++ b/apps/web/lib/api/commissions/create-manual-commissions.ts @@ -121,13 +121,6 @@ export async function createManualCommissions(args: CreateCommissionsArgs) { productId, } = args; - if (!importStripeInvoices && !saleAmount) { - throw new DubApiError({ - code: "bad_request", - message: "Either saleAmount or importStripeInvoices must be provided.", - }); - } - const hasManualSaleFields = saleAmount || saleEventDate || invoiceId || productId; diff --git a/apps/web/lib/api/errors.ts b/apps/web/lib/api/errors.ts index 0f129910fd3..c4e623f8d4b 100644 --- a/apps/web/lib/api/errors.ts +++ b/apps/web/lib/api/errors.ts @@ -63,6 +63,19 @@ export class DubApiError extends Error { const docErrorUrl = "https://dub.co/docs/api-reference/errors"; export function fromZodError(error: z.ZodError): ErrorResponse { + const [issue] = error.issues; + + // Custom business rules already include human-readable messages. + if (error.issues.length === 1 && issue.code === "custom") { + return { + error: { + code: "unprocessable_entity", + message: issue.message, + doc_url: `${docErrorUrl}#unprocessable-entity`, + }, + }; + } + return { error: { code: "unprocessable_entity", diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 7cf8af80345..147c882f640 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -577,7 +577,7 @@ export const createManualCommissionBodySchema = z ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "`description` is required when creating a clawback (negative amount).", + "Description is required when creating a clawback (negative amount).", path: ["description"], }); } @@ -589,8 +589,7 @@ export const createManualCommissionBodySchema = z if (!data.importStripeInvoices && data.saleAmount == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "`saleAmount` is required when `importStripeInvoices` is false.", + message: "saleAmount is required when importStripeInvoices is false.", path: ["saleAmount"], }); } diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index 8a0a6a9fb77..e36e29103af 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -162,7 +162,7 @@ const clawbackErrorCases = [ }, expected: apiError({ code: "unprocessable_entity", - message: "custom: amount: Amount cannot be 0.", + message: "Amount cannot be 0.", }), }, { @@ -171,7 +171,7 @@ const clawbackErrorCases = [ expected: apiError({ code: "unprocessable_entity", message: - "custom: description: `description` is required when creating a clawback (negative amount).", + "Description is required when creating a clawback (negative amount).", }), }, ]; diff --git a/apps/web/tests/commissions/create-commission.test.ts b/apps/web/tests/commissions/create-commission.test.ts index 903a87033b7..e4a88fc97af 100644 --- a/apps/web/tests/commissions/create-commission.test.ts +++ b/apps/web/tests/commissions/create-commission.test.ts @@ -31,7 +31,7 @@ const validationCases = [ name: "custom commission with amount 0", body: { type: "custom", partnerId: E2E_PARTNER.id, amount: 0 }, expectedStatus: 422, - expectedMessage: "custom: amount: Amount cannot be 0.", + expectedMessage: "Amount cannot be 0.", }, { name: "sale commission missing saleAmount", @@ -43,7 +43,7 @@ const validationCases = [ }, expectedStatus: 422, expectedMessage: - "custom: saleAmount: `saleAmount` is required when `importStripeInvoices` is false.", + "saleAmount is required when importStripeInvoices is false.", }, ]; From 1448e1567f0f68561045d8c76d9230eaa5fb2612 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 12:11:17 +0530 Subject: [PATCH 10/28] Update errors.ts --- apps/web/lib/api/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/api/errors.ts b/apps/web/lib/api/errors.ts index c4e623f8d4b..c58d3eabf84 100644 --- a/apps/web/lib/api/errors.ts +++ b/apps/web/lib/api/errors.ts @@ -66,6 +66,7 @@ export function fromZodError(error: z.ZodError): ErrorResponse { const [issue] = error.issues; // Custom business rules already include human-readable messages. + // Example: "custom: description: Description is required..." → "Description is required..." if (error.issues.length === 1 && issue.code === "custom") { return { error: { From 28bcd9a9675e5cc4d38858417434cc3ca73485cc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 12:13:55 +0530 Subject: [PATCH 11/28] Fix error message --- apps/web/lib/zod/schemas/commissions.ts | 5 +++-- apps/web/playwright/api/clawbacks/clawbacks.spec.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 147c882f640..7cf8af80345 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -577,7 +577,7 @@ export const createManualCommissionBodySchema = z ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "Description is required when creating a clawback (negative amount).", + "`description` is required when creating a clawback (negative amount).", path: ["description"], }); } @@ -589,7 +589,8 @@ export const createManualCommissionBodySchema = z if (!data.importStripeInvoices && data.saleAmount == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "saleAmount is required when importStripeInvoices is false.", + message: + "`saleAmount` is required when `importStripeInvoices` is false.", path: ["saleAmount"], }); } diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index e36e29103af..a28158a260d 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -171,7 +171,7 @@ const clawbackErrorCases = [ expected: apiError({ code: "unprocessable_entity", message: - "Description is required when creating a clawback (negative amount).", + "`description` is required when creating a clawback (negative amount).", }), }, ]; From 7aeb104e99064a4965c3dd00e7728fdf4f079bf9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 12:25:33 +0530 Subject: [PATCH 12/28] Reject $0 sales, restore Zod error prefixes, and fix clawback success UI. --- .../commissions/create-clawback-sheet.tsx | 14 ++++++-------- apps/web/lib/api/errors.ts | 14 -------------- apps/web/lib/zod/schemas/commissions.ts | 9 ++++++++- .../playwright/api/clawbacks/clawbacks.spec.ts | 4 ++-- .../tests/commissions/create-commission.test.ts | 16 ++++++++++++++-- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx index a351a11a924..0fbfb9088d1 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx @@ -40,7 +40,7 @@ function CreateClawbackSheetContent( reset, watch, getValues, - formState: { errors, isSubmitting, isSubmitSuccessful }, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { partnerId: params.partnerId, @@ -66,8 +66,8 @@ function CreateClawbackSheetContent( amount: data.amount ? -Math.round(data.amount * 100) : 0, description: data.reason, }, - onSuccess: async ({ message }) => { - toast.success(message); + onSuccess: async () => { + toast.success("A clawback has been created for the partner!"); setIsOpen(false); await mutatePrefix("/api/commissions"); const currentValues = getValues(); @@ -76,8 +76,6 @@ function CreateClawbackSheetContent( }); }; - const disableSubmitButton = !partnerId || !amount || !reason; - return (
@@ -216,15 +214,15 @@ function CreateClawbackSheetContent( onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isCreating || isSubmitting || isSubmitSuccessful} + disabled={isCreating || isSubmitting} />
diff --git a/apps/web/lib/api/errors.ts b/apps/web/lib/api/errors.ts index c58d3eabf84..0f129910fd3 100644 --- a/apps/web/lib/api/errors.ts +++ b/apps/web/lib/api/errors.ts @@ -63,20 +63,6 @@ export class DubApiError extends Error { const docErrorUrl = "https://dub.co/docs/api-reference/errors"; export function fromZodError(error: z.ZodError): ErrorResponse { - const [issue] = error.issues; - - // Custom business rules already include human-readable messages. - // Example: "custom: description: Description is required..." → "Description is required..." - if (error.issues.length === 1 && issue.code === "custom") { - return { - error: { - code: "unprocessable_entity", - message: issue.message, - doc_url: `${docErrorUrl}#unprocessable-entity`, - }, - }; - } - return { error: { code: "unprocessable_entity", diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 7cf8af80345..e9baeb30f61 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -547,7 +547,14 @@ export const createManualCommissionBodySchema = z "When `true`, import all unimported paid Stripe invoices for the customer and create a commission for each. When `false`, create a single manual sale event using `saleAmount`.", ), saleAmount: centsSchema - .pipe(z.number().min(0)) + .pipe( + z + .number() + .min(0) + .refine((n) => n !== 0, { + message: "Sale amount cannot be 0.", + }), + ) .nullish() .describe( "Required when `importStripeInvoices` is `false`. The sale amount in cents for the manual sale event. Ignored when importing from Stripe.", diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index a28158a260d..8a0a6a9fb77 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -162,7 +162,7 @@ const clawbackErrorCases = [ }, expected: apiError({ code: "unprocessable_entity", - message: "Amount cannot be 0.", + message: "custom: amount: Amount cannot be 0.", }), }, { @@ -171,7 +171,7 @@ const clawbackErrorCases = [ expected: apiError({ code: "unprocessable_entity", message: - "`description` is required when creating a clawback (negative amount).", + "custom: description: `description` is required when creating a clawback (negative amount).", }), }, ]; diff --git a/apps/web/tests/commissions/create-commission.test.ts b/apps/web/tests/commissions/create-commission.test.ts index e4a88fc97af..941c92120eb 100644 --- a/apps/web/tests/commissions/create-commission.test.ts +++ b/apps/web/tests/commissions/create-commission.test.ts @@ -31,7 +31,7 @@ const validationCases = [ name: "custom commission with amount 0", body: { type: "custom", partnerId: E2E_PARTNER.id, amount: 0 }, expectedStatus: 422, - expectedMessage: "Amount cannot be 0.", + expectedMessage: "custom: amount: Amount cannot be 0.", }, { name: "sale commission missing saleAmount", @@ -43,7 +43,19 @@ const validationCases = [ }, expectedStatus: 422, expectedMessage: - "saleAmount is required when importStripeInvoices is false.", + "custom: saleAmount: `saleAmount` is required when `importStripeInvoices` is false.", + }, + { + name: "sale commission with saleAmount 0", + body: { + type: "sale", + partnerId: E2E_PARTNER.id, + customerId: E2E_CUSTOMER_ID, + importStripeInvoices: false, + saleAmount: 0, + }, + expectedStatus: 422, + expectedMessage: "custom: saleAmount: Sale amount cannot be 0.", }, ]; From dc3d4690112df0a5f816ffd4335cbcdcce24ff19 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 20 Aug 2026 13:37:22 +0530 Subject: [PATCH 13/28] Return a clawback-specific queued message from POST /commissions. --- apps/web/app/(ee)/api/commissions/route.ts | 21 ++++++---- .../commissions/create-clawback-sheet.tsx | 4 +- apps/web/lib/zod/schemas/commissions.ts | 42 +++++++++++-------- .../api/clawbacks/clawbacks.spec.ts | 2 +- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/apps/web/app/(ee)/api/commissions/route.ts b/apps/web/app/(ee)/api/commissions/route.ts index 3e130c3a795..d23d65fe924 100644 --- a/apps/web/app/(ee)/api/commissions/route.ts +++ b/apps/web/app/(ee)/api/commissions/route.ts @@ -90,15 +90,18 @@ export const POST = withWorkspace( console.timeEnd("createManualCommissions"); - return NextResponse.json( - createCommissionResponseSchema.parse({ - success: true, - message: "Your commissions are being created and will appear shortly.", - }), - { - status: 202, - }, - ); + const isClawback = body.type === "custom" && body.amount < 0; + + const response = createCommissionResponseSchema.parse({ + success: true, + message: isClawback + ? "A clawback has been queued for the partner!" + : "Your commissions are being created and will appear shortly.", + }); + + return NextResponse.json(response, { + status: 202, + }); }, { requiredPlan: ["business", "advanced", "enterprise"], diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx index 0fbfb9088d1..89355406456 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx @@ -66,8 +66,8 @@ function CreateClawbackSheetContent( amount: data.amount ? -Math.round(data.amount * 100) : 0, description: data.reason, }, - onSuccess: async () => { - toast.success("A clawback has been created for the partner!"); + onSuccess: async ({ message }) => { + toast.success(message); setIsOpen(false); await mutatePrefix("/api/commissions"); const currentValues = getValues(); diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index e9baeb30f61..8b515f49186 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -547,14 +547,7 @@ export const createManualCommissionBodySchema = z "When `true`, import all unimported paid Stripe invoices for the customer and create a commission for each. When `false`, create a single manual sale event using `saleAmount`.", ), saleAmount: centsSchema - .pipe( - z - .number() - .min(0) - .refine((n) => n !== 0, { - message: "Sale amount cannot be 0.", - }), - ) + .pipe(z.number().min(0)) .nullish() .describe( "Required when `importStripeInvoices` is `false`. The sale amount in cents for the manual sale event. Ignored when importing from Stripe.", @@ -579,8 +572,8 @@ export const createManualCommissionBodySchema = z }), ]) .superRefine((data, ctx) => { - if (data.type === "custom" && data.amount < 0) { - if (!data.description?.trim()) { + if (data.type === "custom") { + if (data.amount < 0 && !data.description?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: @@ -591,15 +584,28 @@ export const createManualCommissionBodySchema = z return; } - if (data.type !== "sale") return; + if (data.type === "sale") { + if (data.importStripeInvoices) { + return; + } - if (!data.importStripeInvoices && data.saleAmount == null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "`saleAmount` is required when `importStripeInvoices` is false.", - path: ["saleAmount"], - }); + if (data.saleAmount == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "`saleAmount` is required when `importStripeInvoices` is false.", + path: ["saleAmount"], + }); + return; + } + + if (data.saleAmount === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Sale amount cannot be 0.", + path: ["saleAmount"], + }); + } } }); diff --git a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts index 8a0a6a9fb77..69282245276 100644 --- a/apps/web/playwright/api/clawbacks/clawbacks.spec.ts +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -7,7 +7,7 @@ import { createPartner, deletePartner } from "../partners/helpers"; const expectedQueuedResponse = { success: true, - message: "Your commissions are being created and will appear shortly.", + message: "A clawback has been queued for the partner!", }; async function expectClawbackCreated({ From 0cebbb2049615093a74521fbae462f7d8ec0fd20 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 14:47:37 -0300 Subject: [PATCH 14/28] revert non-ui changes --- .../[campaignId]/send-email-preview-modal.tsx | 11 +++--- .../partners/update-partner-enrollment.ts | 34 ++++++++----------- .../partner-advanced-settings-modal.tsx | 28 ++++++--------- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx index 536287234aa..bfa2982fe31 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx @@ -24,8 +24,7 @@ function SendEmailPreviewModal({ const { handleKeyDown } = useEnterSubmit(); const { control } = useCampaignFormContext(); const { isSubmitting, makeRequest } = useApiMutation(); - const [emailAddresses, setEmailAddresses] = useState(null); - const emailAddressesValue = emailAddresses ?? user?.email ?? ""; + const [emailAddresses, setEmailAddresses] = useState(user?.email ?? ""); const [subject, preview, bodyJson, from] = useWatch({ control, @@ -35,7 +34,7 @@ function SendEmailPreviewModal({ const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!emailAddressesValue.trim()) { + if (!emailAddresses.trim()) { toast.error("Please enter at least one email address."); return; } @@ -47,7 +46,7 @@ function SendEmailPreviewModal({ return; } - const emails = emailAddressesValue + const emails = emailAddresses .split(",") .map((email) => email.trim()) .filter((email) => email.length > 0); @@ -96,7 +95,7 @@ function SendEmailPreviewModal({ placeholder="Separate multiple addresses with commas" autoFocus={!isMobile} required - value={emailAddressesValue} + value={emailAddresses} onChange={(e) => setEmailAddresses(e.target.value)} onKeyDown={handleKeyDown} rows={3} @@ -118,7 +117,7 @@ function SendEmailPreviewModal({ type="submit" text="Send preview" loading={isSubmitting} - disabled={!emailAddressesValue.trim()} + disabled={!emailAddresses.trim()} className="h-8 w-fit" /> diff --git a/apps/web/lib/actions/partners/update-partner-enrollment.ts b/apps/web/lib/actions/partners/update-partner-enrollment.ts index accd7ee47ea..7e51d6ae9c4 100644 --- a/apps/web/lib/actions/partners/update-partner-enrollment.ts +++ b/apps/web/lib/actions/partners/update-partner-enrollment.ts @@ -16,10 +16,10 @@ import { throwIfNoPermission } from "../throw-if-no-permission"; const updatePartnerEnrollmentSchema = z.object({ workspaceId: z.string(), partnerId: z.string(), - tenantId: z.string().nullable().optional(), - customerDataSharingEnabledAt: z.coerce.date().nullable().optional(), - groupMoveDisabledAt: z.coerce.date().nullable().optional(), - riskMonitoringDisabledAt: z.coerce.date().nullable().optional(), + tenantId: z.string().nullable(), + customerDataSharingEnabledAt: z.coerce.date().nullable(), + groupMoveDisabledAt: z.coerce.date().nullable(), + riskMonitoringDisabledAt: z.coerce.date().nullable(), }); // Update a partner's program enrollment data @@ -64,28 +64,22 @@ export const updatePartnerEnrollmentAction = authActionClient } const programEnrollment = await prisma.$transaction(async (tx) => { - if (tenantId !== undefined) { - await tx.link.updateMany({ - where, - data: { - tenantId, - }, - }); - } + await tx.link.updateMany({ + where, + data: { + tenantId, + }, + }); return await tx.programEnrollment.update({ where: { partnerId_programId: where, }, data: { - ...(tenantId !== undefined && { tenantId }), - ...(customerDataSharingEnabledAt !== undefined && { - customerDataSharingEnabledAt, - }), - ...(groupMoveDisabledAt !== undefined && { groupMoveDisabledAt }), - ...(riskMonitoringDisabledAt !== undefined && { - riskMonitoringDisabledAt, - }), + tenantId, + customerDataSharingEnabledAt, + groupMoveDisabledAt, + riskMonitoringDisabledAt, }, include: { links: { diff --git a/apps/web/ui/partners/partner-advanced-settings-modal.tsx b/apps/web/ui/partners/partner-advanced-settings-modal.tsx index 8e5a23b03c5..381c419f04d 100644 --- a/apps/web/ui/partners/partner-advanced-settings-modal.tsx +++ b/apps/web/ui/partners/partner-advanced-settings-modal.tsx @@ -73,13 +73,7 @@ function PartnerAdvancedSettingsModal({ setValue, setError, handleSubmit, - formState: { - errors, - isSubmitting, - isSubmitSuccessful, - isDirty, - dirtyFields, - }, + formState: { errors, isSubmitting, isSubmitSuccessful, isDirty }, } = useForm({ defaultValues: { tenantId: partner.tenantId, @@ -129,16 +123,10 @@ function PartnerAdvancedSettingsModal({ const result = await executeAsync({ workspaceId: workspaceId!, partnerId: partner.id, - ...(dirtyFields.tenantId && { tenantId: data.tenantId || null }), - ...(dirtyFields.customerDataSharingEnabledAt && { - customerDataSharingEnabledAt: data.customerDataSharingEnabledAt, - }), - ...(dirtyFields.groupMoveDisabledAt && { - groupMoveDisabledAt: data.groupMoveDisabledAt, - }), - ...(dirtyFields.riskMonitoringDisabledAt && { - riskMonitoringDisabledAt: data.riskMonitoringDisabledAt, - }), + tenantId: data.tenantId || null, + customerDataSharingEnabledAt: data.customerDataSharingEnabledAt, + groupMoveDisabledAt: data.groupMoveDisabledAt, + riskMonitoringDisabledAt: data.riskMonitoringDisabledAt, }); if (result?.serverError || result?.validationErrors) { @@ -294,7 +282,11 @@ export function usePartnerAdvancedSettingsModal({ partner={partner} /> ); - }, [showPartnerAdvancedSettingsModal, setShowPartnerAdvancedSettingsModal]); + }, [ + showPartnerAdvancedSettingsModal, + setShowPartnerAdvancedSettingsModal, + partner, + ]); return useMemo( () => ({ From eed4ab5c04cc587f26cc65afbb7c2664e97ec684 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 16:37:35 -0300 Subject: [PATCH 15/28] remove pendingOpenUnmount --- .../partner-advanced-settings-modal.tsx | 6 +-- packages/ui/src/modal.tsx | 52 +------------------ 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/apps/web/ui/partners/partner-advanced-settings-modal.tsx b/apps/web/ui/partners/partner-advanced-settings-modal.tsx index 381c419f04d..3b4c342ab8e 100644 --- a/apps/web/ui/partners/partner-advanced-settings-modal.tsx +++ b/apps/web/ui/partners/partner-advanced-settings-modal.tsx @@ -282,11 +282,7 @@ export function usePartnerAdvancedSettingsModal({ partner={partner} /> ); - }, [ - showPartnerAdvancedSettingsModal, - setShowPartnerAdvancedSettingsModal, - partner, - ]); + }, [showPartnerAdvancedSettingsModal, setShowPartnerAdvancedSettingsModal]); return useMemo( () => ({ diff --git a/packages/ui/src/modal.tsx b/packages/ui/src/modal.tsx index 6f69fa3c1b5..d80560391c3 100644 --- a/packages/ui/src/modal.tsx +++ b/packages/ui/src/modal.tsx @@ -4,58 +4,10 @@ import { cn } from "@dub/utils"; import * as Dialog from "@radix-ui/react-dialog"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import { useRouter } from "next/navigation"; -import { - ComponentProps, - Dispatch, - SetStateAction, - useEffect, - useRef, -} from "react"; +import { ComponentProps, Dispatch, SetStateAction } from "react"; import { Drawer } from "vaul"; import { useMediaQuery } from "./hooks"; -// Dev-only detector for modal remount bugs: an open Modal unmounting while -// another mounts open in the same commit means its element type changed -// (unstable hook dep) and any user state in it was lost -let pendingOpenUnmount: unknown = null; - -function useWarnOnRemountWhileOpen(showModal?: boolean) { - // Per-instance token so Strict Mode's same-instance setup/cleanup/setup - // cycle doesn't register as a remount - const instance = useRef({}); - const showModalRef = useRef(showModal); - - useEffect(() => { - showModalRef.current = showModal; - }); - - useEffect(() => { - if (process.env.NODE_ENV !== "development") return; - - if ( - showModalRef.current && - pendingOpenUnmount && - pendingOpenUnmount !== instance.current - ) { - pendingOpenUnmount = null; - console.warn( - "[Modal] remounted while open — the modal's element type changed mid-session (unstable hook dep?) and any user state in it was lost.", - ); - } - - return () => { - if (showModalRef.current) { - pendingOpenUnmount = instance.current; - queueMicrotask(() => { - if (pendingOpenUnmount === instance.current) { - pendingOpenUnmount = null; - } - }); - } - }; - }, []); -} - export function Modal({ children, className, @@ -77,8 +29,6 @@ export function Modal({ }) { const router = useRouter(); - useWarnOnRemountWhileOpen(showModal); - const closeModal = ({ dragged }: { dragged?: boolean } = {}) => { if (preventDefaultClose && !dragged) { return; From 06c8ea237dd1c2a868b940e09b8d409fd16b33fe Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 18:52:42 -0300 Subject: [PATCH 16/28] clear leftover apply events before transferring enrollments --- .../workflows/merge-partner-accounts/route.ts | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index 1b3ad6bb395..441596d9735 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -546,11 +546,48 @@ async function mergeSingleEnrollment({ }); } - // Scope the transfer to the source partner so a concurrent reassignment - // can't make us steal another partner's enrollment. - const { count } = await prisma.programEnrollment.updateMany({ - where: { id: sourceEnrollment.id, partnerId: sourcePartnerId }, - data: { partnerId: targetPartnerId }, + // Target leftover apply-event / discovered-partner rows are unique on + // (programId, partnerId). Rewriting enrollment.partnerId remaps those + // children and collides unless we drop the source row first. + const { count, cleared } = await prisma.$transaction(async (tx) => { + const cleared: string[] = []; + + const targetEvent = await tx.programApplicationEvent.findUnique({ + where: { + programId_partnerId: { programId, partnerId: targetPartnerId }, + }, + select: { id: true }, + }); + + if (targetEvent) { + await tx.programApplicationEvent.deleteMany({ + where: { programId, partnerId: sourcePartnerId }, + }); + cleared.push("cleared source application event"); + } + + const targetDiscoveredPartner = await tx.discoveredPartner.findUnique({ + where: { + programId_partnerId: { programId, partnerId: targetPartnerId }, + }, + select: { id: true }, + }); + + if (targetDiscoveredPartner) { + await tx.discoveredPartner.deleteMany({ + where: { programId, partnerId: sourcePartnerId }, + }); + cleared.push("cleared source discovered partner"); + } + + // Scope the transfer to the source partner so a concurrent reassignment + // can't make us steal another partner's enrollment. + const { count } = await tx.programEnrollment.updateMany({ + where: { id: sourceEnrollment.id, partnerId: sourcePartnerId }, + data: { partnerId: targetPartnerId }, + }); + + return { count, cleared }; }); if (count === 0) { @@ -561,10 +598,12 @@ async function mergeSingleEnrollment({ }); } + const clearedLog = cleared.length > 0 ? ` (${cleared.join(", ")})` : ""; + return logAndReturn({ programId, action: "transfer", - outputLog: `Transferred enrollment for program ${programId}`, + outputLog: `Transferred enrollment for program ${programId}${clearedLog}`, }); } From 9d7226bfa96e43cda84c8d33d01a1b4652983463 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 20 Aug 2026 18:59:02 -0300 Subject: [PATCH 17/28] update mergeSingleEnrollment --- .../workflows/merge-partner-accounts/route.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index 441596d9735..3a8cf97e867 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -546,9 +546,9 @@ async function mergeSingleEnrollment({ }); } - // Target leftover apply-event / discovered-partner rows are unique on - // (programId, partnerId). Rewriting enrollment.partnerId remaps those - // children and collides unless we drop the source row first. + // Application events and discovered partners are unique on + // (programId, partnerId). Move the source row when the target has none; + // otherwise delete it so rewriting enrollment.partnerId cannot collide. const { count, cleared } = await prisma.$transaction(async (tx) => { const cleared: string[] = []; @@ -563,7 +563,17 @@ async function mergeSingleEnrollment({ await tx.programApplicationEvent.deleteMany({ where: { programId, partnerId: sourcePartnerId }, }); - cleared.push("cleared source application event"); + cleared.push("deleted source application event"); + } else { + const { count: transferredEvents } = + await tx.programApplicationEvent.updateMany({ + where: { programId, partnerId: sourcePartnerId }, + data: { partnerId: targetPartnerId }, + }); + + if (transferredEvents > 0) { + cleared.push("transferred application event"); + } } const targetDiscoveredPartner = await tx.discoveredPartner.findUnique({ @@ -577,7 +587,17 @@ async function mergeSingleEnrollment({ await tx.discoveredPartner.deleteMany({ where: { programId, partnerId: sourcePartnerId }, }); - cleared.push("cleared source discovered partner"); + cleared.push("deleted source discovered partner"); + } else { + const { count: transferredDiscovered } = + await tx.discoveredPartner.updateMany({ + where: { programId, partnerId: sourcePartnerId }, + data: { partnerId: targetPartnerId }, + }); + + if (transferredDiscovered > 0) { + cleared.push("transferred discovered partner"); + } } // Scope the transfer to the source partner so a concurrent reassignment From 0756951236cf2517a0f993f822565e7f7fb626f1 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 16:34:54 -0700 Subject: [PATCH 18/28] refactor merge-partner-accounts workflow --- .../workflows/merge-partner-accounts/route.ts | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index 1b3ad6bb395..fda726434e5 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -11,6 +11,8 @@ import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; +import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; +import { partnerMergedWebhookSchema } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import PartnerAccountMerged from "@dub/email/templates/partner-account-merged"; import { log, prettyPrint, R2_URL } from "@dub/utils"; @@ -87,6 +89,8 @@ export const { POST } = serve( enrollmentId, sourcePartnerId, targetPartnerId, + sourceEmail, + targetEmail, }); }); } @@ -432,13 +436,29 @@ async function mergeSingleEnrollment({ enrollmentId, sourcePartnerId, targetPartnerId, + sourceEmail, + targetEmail, }: { enrollmentId: string; sourcePartnerId: string; targetPartnerId: string; + sourceEmail: string; + targetEmail: string; }) { const sourceEnrollment = await prisma.programEnrollment.findUnique({ where: { id: enrollmentId }, + include: { + program: { + select: { + workspace: { + select: { + id: true, + webhookEnabled: true, + }, + }, + }, + }, + }, }); if (!sourceEnrollment) { @@ -484,6 +504,8 @@ async function mergeSingleEnrollment({ programId, }); + let action: "overlap" | "transfer"; + if (targetEnrollment) { await prisma.$transaction(async (tx) => { if ( @@ -539,32 +561,52 @@ async function mergeSingleEnrollment({ } }); - return logAndReturn({ - programId, - action: "overlap", - outputLog: `Merged overlapping enrollment for program ${programId}`, + action = "overlap"; + } else { + // Scope the transfer to the source partner so a concurrent reassignment + // can't make us steal another partner's enrollment. + const { count } = await prisma.programEnrollment.updateMany({ + where: { id: sourceEnrollment.id, partnerId: sourcePartnerId }, + data: { partnerId: targetPartnerId }, }); - } - // Scope the transfer to the source partner so a concurrent reassignment - // can't make us steal another partner's enrollment. - const { count } = await prisma.programEnrollment.updateMany({ - where: { id: sourceEnrollment.id, partnerId: sourcePartnerId }, - data: { partnerId: targetPartnerId }, - }); + if (count === 0) { + return logAndReturn({ + programId, + action: "skip", + outputLog: `Enrollment ${sourceEnrollment.id} no longer owned by ${sourcePartnerId}, skipping transfer`, + }); + } - if (count === 0) { - return logAndReturn({ - programId, - action: "skip", - outputLog: `Enrollment ${sourceEnrollment.id} no longer owned by ${sourcePartnerId}, skipping transfer`, - }); + action = "transfer"; } + await sendWorkspaceWebhook({ + workspace: sourceEnrollment.program.workspace, + trigger: "partner.merged", + data: partnerMergedWebhookSchema.parse({ + programId, + targetAlreadyEnrolled: Boolean(targetEnrollment), + source: { + id: sourcePartnerId, + tenantId: sourceEnrollment.tenantId, + email: sourceEmail, + }, + target: { + id: targetPartnerId, + tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId, + email: targetEmail, + }, + }), + }); + return logAndReturn({ programId, - action: "transfer", - outputLog: `Transferred enrollment for program ${programId}`, + action, + outputLog: + action === "overlap" + ? `Merged overlapping enrollment for program ${programId}: ${sourceEmail} (${sourcePartnerId}) -> ${targetEmail} (${targetPartnerId}).` + : `Transferred enrollment for program ${programId}: ${sourceEmail} (${sourcePartnerId}) -> ${targetEmail} (${targetPartnerId}).`, }); } From 8249f8b9ced2632e5513ddd286fcad54bbe10ce4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 17:19:53 -0700 Subject: [PATCH 19/28] update openAPI description + webhook payload --- .../workflows/merge-partner-accounts/route.ts | 9 ++-- apps/web/lib/integrations/slack/transform.ts | 12 ++--- .../webhook/sample-events/partner-merged.json | 11 ++-- apps/web/lib/zod/schemas/partners.ts | 51 ++++++++++--------- 4 files changed, 42 insertions(+), 41 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index fda726434e5..cbee3e044d0 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -585,18 +585,17 @@ async function mergeSingleEnrollment({ workspace: sourceEnrollment.program.workspace, trigger: "partner.merged", data: partnerMergedWebhookSchema.parse({ - programId, - targetAlreadyEnrolled: Boolean(targetEnrollment), - source: { + sourcePartner: { id: sourcePartnerId, tenantId: sourceEnrollment.tenantId, email: sourceEmail, }, - target: { + targetPartner: { id: targetPartnerId, - tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId, + tenantId: targetEnrollment?.tenantId ?? null, email: targetEmail, }, + targetAlreadyEnrolled: Boolean(targetEnrollment), }), }); diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index d3af84ff736..202e45e2c17 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -542,8 +542,8 @@ const partnerMergedTemplate = ({ }: { data: PartnerMergedWebhookPayload; }) => { - const { targetAlreadyEnrolled, source, target } = data; - const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${target.id}`; + const { targetAlreadyEnrolled, sourcePartner, targetPartner } = data; + const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${targetPartner.id}`; const outcomeLabel = targetAlreadyEnrolled ? "Target was already enrolled" : "Target was not enrolled"; @@ -562,11 +562,11 @@ const partnerMergedTemplate = ({ fields: [ { type: "mrkdwn", - text: `*Source*\n\`${source.id}\`${source.email ? ` (${source.email})` : ""}`, + text: `*Source*\n\`${sourcePartner.id}\`${sourcePartner.email ? ` (${sourcePartner.email})` : ""}`, }, { type: "mrkdwn", - text: `*Target*\n<${hrefToPartnerPage}|\`${target.id}\`>${target.email ? ` (${target.email})` : ""}`, + text: `*Target*\n<${hrefToPartnerPage}|\`${targetPartner.id}\`>${targetPartner.email ? ` (${targetPartner.email})` : ""}`, }, ], }, @@ -577,11 +577,11 @@ const partnerMergedTemplate = ({ type: "mrkdwn", text: `*Outcome*\n${outcomeLabel}`, }, - ...(source.tenantId || target.tenantId + ...(sourcePartner.tenantId || targetPartner.tenantId ? [ { type: "mrkdwn", - text: `*Tenant ID*\n${source.tenantId ?? "—"} → ${target.tenantId ?? "—"}`, + text: `*Tenant ID*\n${sourcePartner.tenantId ?? "—"} → ${targetPartner.tenantId ?? "—"}`, }, ] : []), diff --git a/apps/web/lib/webhook/sample-events/partner-merged.json b/apps/web/lib/webhook/sample-events/partner-merged.json index d9e3d8e7228..79a5e8bbf54 100644 --- a/apps/web/lib/webhook/sample-events/partner-merged.json +++ b/apps/web/lib/webhook/sample-events/partner-merged.json @@ -1,14 +1,13 @@ { - "programId": "prog_CYCu7IMAapjkRpTnr8F1azjN", - "targetAlreadyEnrolled": false, - "source": { + "sourcePartner": { "id": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", "email": "old@example.com" }, - "target": { + "targetPartner": { "id": "pn_1K06X6FX2GRB31NCM2VVCGJ72", - "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "tenantId": null, "email": "kept@example.com" - } + }, + "targetAlreadyEnrolled": false } diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 7f09442ba9a..3ac7c8e7949 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -629,30 +629,6 @@ export const WebhookPartnerSchema = PartnerSchema.pick({ totalCommissions: centsSchema, }); -const partnerMergedAccountSchema = z.object({ - id: z.string().describe("The partner's unique ID on Dub."), - tenantId: z - .string() - .nullable() - .describe("The partner's unique ID within your database for this program."), - email: z.string().nullable().describe("The partner's email address."), -}); - -export const partnerMergedWebhookSchema = z.object({ - programId: z.string().describe("The program's unique ID on Dub."), - targetAlreadyEnrolled: z - .boolean() - .describe( - "Whether the surviving partner was already enrolled in this program before the merge. If `true`, the workspace had both partners and should collapse the source into the target. If `false`, only the source was enrolled and its Dub partner ID changed.", - ), - source: partnerMergedAccountSchema.describe( - "The partner account that was merged away. Its enrollment in this program no longer exists; use `target.id` instead.", - ), - target: partnerMergedAccountSchema.describe( - "The surviving partner account. `tenantId` is the post-merge value for this program.", - ), -}); - export const LeaderboardPartnerSchema = z.object({ id: z.string(), totalCommissions: centsSchemaWithDefault, @@ -1094,3 +1070,30 @@ export const partnerSharedPlatformSchema = z.object({ }), ), }); + +const partnerMergedAccountSchema = z.object({ + id: z.string().describe("The partner's unique ID on Dub."), + tenantId: z + .string() + .nullable() + .describe("The partner's unique ID in your system"), + email: z.string().nullable().describe("The partner's email address."), +}); + +export const partnerMergedWebhookSchema = z.object({ + sourcePartner: partnerMergedAccountSchema.describe( + "The source partner account that was merged away. Its enrollment in this program no longer exists; use `target.id` instead.", + ), + targetPartner: partnerMergedAccountSchema.describe( + "The target partner account that the source account was merged into.", + ), + targetAlreadyEnrolled: z + .boolean() + .describe( + [ + "Whether the target partner account was already enrolled in this program before the merge.", + "If `true`, both partners were already enrolled in the program and the merge process will collapse the source account into the target account.", + "If `false`, only the source partner account was enrolled in the program, which means the partner's ID in your program will be updated to the target partner's ID.", + ].join("\n"), + ), +}); From 7c2fb6cca8f4ef37779e173357ccf474a6484a88 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 17:25:35 -0700 Subject: [PATCH 20/28] address CR feedback --- apps/web/lib/zod/schemas/partners.ts | 2 +- .../merge-partner-accounts-workflow.test.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 3ac7c8e7949..81c3218afc3 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -1082,7 +1082,7 @@ const partnerMergedAccountSchema = z.object({ export const partnerMergedWebhookSchema = z.object({ sourcePartner: partnerMergedAccountSchema.describe( - "The source partner account that was merged away. Its enrollment in this program no longer exists; use `target.id` instead.", + "The source partner account that was merged away. Its enrollment in this program no longer exists; use `targetPartner.id` instead.", ), targetPartner: partnerMergedAccountSchema.describe( "The target partner account that the source account was merged into.", diff --git a/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts b/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts index cc4ffa0e2ca..7b9812a9b98 100644 --- a/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts +++ b/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts @@ -64,18 +64,21 @@ describe.sequential("Workflow - MergePartnerAccounts", async () => { "Overlap merge upgrades target status from pending to approved", { timeout: VITEST_TEST_TIMEOUT_MS }, async () => { - const source = await createEnrolledPartner("upgrade-source"); - const target = await createEnrolledPartner("upgrade-target"); + const sourcePartner = await createEnrolledPartner("upgrade-source"); + const targetPartner = await createEnrolledPartner("upgrade-target"); const { status: pendingStatus } = await http.post({ path: "/e2e/partners/pending-program-application", - body: { partnerId: target.id }, + body: { partnerId: targetPartner.id }, }); expect(pendingStatus).toEqual(200); const { status: triggerStatus } = await http.post({ path: "/e2e/trigger-merge-accounts", - body: { sourceEmail: source.email, targetEmail: target.email }, + body: { + sourceEmail: sourcePartner.email, + targetEmail: targetPartner.email, + }, }); expect(triggerStatus).toEqual(200); @@ -84,8 +87,10 @@ describe.sequential("Workflow - MergePartnerAccounts", async () => { while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { const [sourceRes, targetRes] = await Promise.all([ - http.get({ path: `/partners/${source.id}` }), - http.get({ path: `/partners/${target.id}` }), + http.get({ path: `/partners/${sourcePartner.id}` }), + http.get({ + path: `/partners/${targetPartner.id}`, + }), ]); lastTargetStatus = From 84e8e851654a2b570ef8e1432300fe0e21e82f1e Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 17:30:13 -0700 Subject: [PATCH 21/28] tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId --- apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index cbee3e044d0..674af8b3ac0 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -592,7 +592,7 @@ async function mergeSingleEnrollment({ }, targetPartner: { id: targetPartnerId, - tenantId: targetEnrollment?.tenantId ?? null, + tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId, email: targetEmail, }, targetAlreadyEnrolled: Boolean(targetEnrollment), From d60da214b736b739e2d7f3b4e749eebf73cee05d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 18:09:12 -0700 Subject: [PATCH 22/28] Update partner-merged.json --- apps/web/lib/webhook/sample-events/partner-merged.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/webhook/sample-events/partner-merged.json b/apps/web/lib/webhook/sample-events/partner-merged.json index 79a5e8bbf54..ed27a5f9efa 100644 --- a/apps/web/lib/webhook/sample-events/partner-merged.json +++ b/apps/web/lib/webhook/sample-events/partner-merged.json @@ -2,12 +2,12 @@ "sourcePartner": { "id": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", - "email": "old@example.com" + "email": "source@example.com" }, "targetPartner": { "id": "pn_1K06X6FX2GRB31NCM2VVCGJ72", - "tenantId": null, - "email": "kept@example.com" + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "target@example.com" }, "targetAlreadyEnrolled": false } From 225a8122462536c0c249eb74abda9bdafdb0f681 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 18:27:46 -0700 Subject: [PATCH 23/28] small openapi changes --- apps/web/lib/zod/schemas/commissions.ts | 11 +++++++---- apps/web/lib/zod/schemas/opens.ts | 2 +- apps/web/lib/zod/schemas/submitted-lead-form.ts | 4 ++-- apps/web/lib/zod/schemas/tags.ts | 2 +- apps/web/lib/zod/schemas/token.ts | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 8b515f49186..261da9727d3 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -475,7 +475,10 @@ export const createManualCommissionBodySchema = z .max(190) .nullish() .describe( - "The description of the commission. Required for clawbacks (negative `amount`). May be a known clawback reason (`order_canceled`, `fraud`, `terms_violation`, `tracking_error`, `payment_failed`, `ineligible_partner`, `duplicate_commission`, `other`) or any other string.", + [ + "The description of the commission. Required for clawbacks (negative `amount`).", + "May be a known clawback reason (`order_canceled`, `fraud`, `terms_violation`, `tracking_error`, `payment_failed`, `ineligible_partner`, `duplicate_commission`) or an arbitrary string (max 190 characters).", + ].join("\n"), ), }), @@ -575,7 +578,7 @@ export const createManualCommissionBodySchema = z if (data.type === "custom") { if (data.amount < 0 && !data.description?.trim()) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "`description` is required when creating a clawback (negative amount).", path: ["description"], @@ -591,7 +594,7 @@ export const createManualCommissionBodySchema = z if (data.saleAmount == null) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "`saleAmount` is required when `importStripeInvoices` is false.", path: ["saleAmount"], @@ -601,7 +604,7 @@ export const createManualCommissionBodySchema = z if (data.saleAmount === 0) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "Sale amount cannot be 0.", path: ["saleAmount"], }); diff --git a/apps/web/lib/zod/schemas/opens.ts b/apps/web/lib/zod/schemas/opens.ts index 96f2637bb68..e8f539b7208 100644 --- a/apps/web/lib/zod/schemas/opens.ts +++ b/apps/web/lib/zod/schemas/opens.ts @@ -18,7 +18,7 @@ export const trackOpenRequestSchema = z .superRefine((data, ctx) => { if (!data.deepLink && !data.dubDomain) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "You need to provide either `deepLink` or `dubDomain` for deferred deep linking.", }); diff --git a/apps/web/lib/zod/schemas/submitted-lead-form.ts b/apps/web/lib/zod/schemas/submitted-lead-form.ts index fef682bc803..87935eff93c 100644 --- a/apps/web/lib/zod/schemas/submitted-lead-form.ts +++ b/apps/web/lib/zod/schemas/submitted-lead-form.ts @@ -100,7 +100,7 @@ export const formFieldsSchema = z ctx.addIssue({ path: ["fields"], message: `Duplicate field key: ${field.key}`, - code: z.ZodIssueCode.custom, + code: "custom", }); } @@ -108,7 +108,7 @@ export const formFieldsSchema = z ctx.addIssue({ path: ["fields"], message: `Duplicate field position: ${field.position}`, - code: z.ZodIssueCode.custom, + code: "custom", }); } diff --git a/apps/web/lib/zod/schemas/tags.ts b/apps/web/lib/zod/schemas/tags.ts index 2f4116a17f2..cc9fc26ece1 100644 --- a/apps/web/lib/zod/schemas/tags.ts +++ b/apps/web/lib/zod/schemas/tags.ts @@ -69,7 +69,7 @@ export const createTagBodySchema = z .superRefine((data, ctx) => { if (!data.name && !data.tag) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["name"], message: "Name is required.", }); diff --git a/apps/web/lib/zod/schemas/token.ts b/apps/web/lib/zod/schemas/token.ts index b9e2d280256..7c6931229ac 100644 --- a/apps/web/lib/zod/schemas/token.ts +++ b/apps/web/lib/zod/schemas/token.ts @@ -51,7 +51,7 @@ export const createReferralsEmbedTokenSchema = z .superRefine((data, ctx) => { if (!data.partnerId && !data.tenantId && !data.partner) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "You must provide either partnerId, tenantId, or partner.", }); } From f16a2ecd77c70ed94ad5f63a152c47469ce4c79a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 18:40:15 -0700 Subject: [PATCH 24/28] improve WEBHOOK_TRIGGER_DESCRIPTIONS --- apps/web/lib/webhook/constants.ts | 35 +++++++++++-------- .../web/ui/modals/send-test-webhook-modal.tsx | 16 ++++----- .../ui/webhooks/webhook-trigger-selector.tsx | 16 ++++++--- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/apps/web/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index c4d23954294..1cc12e5b0e8 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -37,21 +37,26 @@ export const WEBHOOK_TRIGGERS = [ ] as const; export const WEBHOOK_TRIGGER_DESCRIPTIONS: Record = { - "link.created": "Link created", - "link.updated": "Link updated", - "link.deleted": "Link deleted", - "link.clicked": "Link clicked", - "lead.created": "Lead created", - "sale.created": "Sale created", - "partner.application_submitted": "Partner application submitted", - "partner.enrolled": "Partner enrolled", - "partner.merged": "Partner accounts merged", - "commission.created": "Commission created", - "bounty.created": "Bounty created", - "bounty.updated": "Bounty updated", - "payout.confirmed": "Payout confirmed", - "discount_code.created": "Discount code created", - "discount_code.deleted": "Discount code deleted", + "link.created": "Occurs whenever a link is created", + "link.updated": "Occurs whenever a link is updated", + "link.deleted": "Occurs whenever a link is deleted", + "link.clicked": "Occurs whenever a link is clicked", + "lead.created": "Occurs whenever a lead is created", + "sale.created": "Occurs whenever a sale is created", + "partner.application_submitted": + "Occurs whenever a partner submits an application to your program", + "partner.enrolled": + "Occurs whenever a partner is enrolled in your program (either their application was approved, they accepted your invite, or via the API)", + "partner.merged": "Occurs when two partner accounts are merged", + "commission.created": + "Occurs whenever a commission is created for a partner (clawbacks will also trigger this event with a negative amount)", + "bounty.created": "Occurs whenever a bounty is created in your program", + "bounty.updated": "Occurs whenever a bounty in your program is updated", + "payout.confirmed": "Occurs whenever a payout in your program is confirmed", + "discount_code.created": + "Occurs whenever a discount code is created for a partner", + "discount_code.deleted": + "Occurs whenever a discount code for a partner is deleted", } as const; export const WEBHOOK_FAILURE_NOTIFY_THRESHOLDS = [5, 10, 15] as const; diff --git a/apps/web/ui/modals/send-test-webhook-modal.tsx b/apps/web/ui/modals/send-test-webhook-modal.tsx index cec9ee9f239..2df29f8edf0 100644 --- a/apps/web/ui/modals/send-test-webhook-modal.tsx +++ b/apps/web/ui/modals/send-test-webhook-modal.tsx @@ -1,7 +1,7 @@ import { sendTestWebhookEvent } from "@/lib/actions/send-test-webhook"; import useWorkspace from "@/lib/swr/use-workspace"; import { WebhookProps } from "@/lib/types"; -import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "@/lib/webhook/constants"; +import { WEBHOOK_TRIGGERS } from "@/lib/webhook/constants"; import type { WebhookTrigger } from "@/lib/webhook/types"; import { Button, Combobox, ComboboxOption, Modal } from "@dub/ui"; import { useAction } from "next-safe-action/hooks"; @@ -38,13 +38,6 @@ function SendTestWebhookModal({ }, }); - const triggers = Object.entries(WEBHOOK_TRIGGER_DESCRIPTIONS).map( - ([key, value]) => ({ - value: key, - label: value, - }), - ); - return ( ({ + value: trigger, + label: trigger, + }))} selected={selectedTrigger} setSelected={setSelectedTrigger} placeholder="Select a webhook event" matchTriggerWidth caret + labelProps={{ className: "font-mono text-sm text-neutral-800" }} + optionClassName="font-mono" /> diff --git a/apps/web/ui/webhooks/webhook-trigger-selector.tsx b/apps/web/ui/webhooks/webhook-trigger-selector.tsx index b1841d1e7b9..2d43f05d64d 100644 --- a/apps/web/ui/webhooks/webhook-trigger-selector.tsx +++ b/apps/web/ui/webhooks/webhook-trigger-selector.tsx @@ -162,10 +162,10 @@ export function WebhookTriggerSelector({ }; return ( -
+
{availableTriggers.map((trigger) => (
-
+
From 44db3ed952ea2b8b21d431578e1f3a5d3869c8d7 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 19:07:58 -0700 Subject: [PATCH 25/28] improve esc keyboard shortcut --- apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | 3 --- packages/ui/src/hooks/use-keyboard-shortcut.tsx | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx index d5da8a07f84..52bc38c5779 100644 --- a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx @@ -570,9 +570,6 @@ export function AppSidebarNav({ () => router.push(`/${slug}/${defaultProduct}`), { enabled: currentArea === "workspaceSettings", - priority: 2, - modal: false, - sheet: false, }, ); diff --git a/packages/ui/src/hooks/use-keyboard-shortcut.tsx b/packages/ui/src/hooks/use-keyboard-shortcut.tsx index a87f4ef34a7..ce7477bce7e 100644 --- a/packages/ui/src/hooks/use-keyboard-shortcut.tsx +++ b/packages/ui/src/hooks/use-keyboard-shortcut.tsx @@ -57,6 +57,10 @@ export function useKeyboardShortcut( (e: KeyboardEvent) => { if (options.enabled === false) return; + // Skip if another handler already consumed this key (e.g. Radix + // popovers/menus/dialogs close on Escape in the capture phase). + if (e.defaultPrevented) return; + const target = e.target as HTMLElement; const existingModalBackdrop = document.getElementById("modal-backdrop"); const existingSheetBackdrop = document.querySelector( From 61f601e35a4b8deb717415a85eac79fd393bee0b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 19:12:41 -0700 Subject: [PATCH 26/28] improve description --- apps/web/lib/openapi/commissions/create-commission.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/openapi/commissions/create-commission.ts b/apps/web/lib/openapi/commissions/create-commission.ts index 9ccee783d93..2a8744603a5 100644 --- a/apps/web/lib/openapi/commissions/create-commission.ts +++ b/apps/web/lib/openapi/commissions/create-commission.ts @@ -10,7 +10,7 @@ export const createCommission: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "create", summary: "Create commission", description: - "Create one or more commissions (custom, lead or sale) for a partner. Custom commissions accept a negative `amount` to create a clawback; in that case `description` is required and may be a known clawback reason or any other string. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.", + "Create one or more commissions (custom, lead or sale) for a partner. Custom commissions accept a negative `amount` to create a clawback. Commission creation is processed asynchronously – use the GET /commissions endpoint or webhooks to be notified when the commission is created.", requestBody: { content: { "application/json": { From e1bcfcc95698ab1b2f9f43ba2f8f500e664322f4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 20 Aug 2026 21:03:45 -0700 Subject: [PATCH 27/28] transferIfNotExistElseDelete --- .../workflows/merge-partner-accounts/route.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts index 674af8b3ac0..a67d8cd004e 100644 --- a/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts +++ b/apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts @@ -347,6 +347,7 @@ async function loadMergePlan({ }; } +// High-volume resources: move in batches of PRISMA_UPDATEMANY_LIMIT async function transferRowsInBatches( updateBatch: () => Promise, { @@ -364,6 +365,31 @@ async function transferRowsInBatches( } } +// Rows unique on (programId, partnerId): move the source row when the target +// has none, otherwise delete it so rewriting enrollment.partnerId cannot collide. +async function transferIfNotExistElseDelete({ + findTarget, + transferSource, + deleteSource, + resourceName, +}: { + findTarget: () => Promise; + transferSource: () => Promise; + deleteSource: () => Promise; + resourceName: string; +}) { + if (await findTarget()) { + const count = await deleteSource(); + console.log( + `Deleted ${count} source ${resourceName} (target already exists)`, + ); + return; + } + + const count = await transferSource(); + console.log(`Transferred ${count} ${resourceName}`); +} + async function transferPartnerProgramData({ sourcePartnerId, targetPartnerId, @@ -422,6 +448,33 @@ async function transferPartnerProgramData({ prisma.notificationEmail.updateMany(payload), prisma.message.updateMany(payload), prisma.partnerComment.updateMany(payload), + // Unique on (programId, partnerId) — transfer or delete, never rewrite over + transferIfNotExistElseDelete({ + findTarget: () => + prisma.programApplicationEvent.findUnique({ + where: { + programId_partnerId: { programId, partnerId: targetPartnerId }, + }, + }), + transferSource: async () => + (await prisma.programApplicationEvent.updateMany(payload)).count, + deleteSource: async () => + (await prisma.programApplicationEvent.deleteMany({ where })).count, + resourceName: "application event", + }), + transferIfNotExistElseDelete({ + findTarget: () => + prisma.discoveredPartner.findUnique({ + where: { + programId_partnerId: { programId, partnerId: targetPartnerId }, + }, + }), + transferSource: async () => + (await prisma.discoveredPartner.updateMany(payload)).count, + deleteSource: async () => + (await prisma.discoveredPartner.deleteMany({ where })).count, + resourceName: "discovered partner", + }), ]); // After payouts are moved onto the target partner, fold any duplicate From 4fdf310ce37ad70dfe428a0d7edc7edd3306947d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 21 Aug 2026 10:27:51 +0530 Subject: [PATCH 28/28] Return 400 instead of 500 when partner analytics times out --- .../programs/[programId]/analytics/route.ts | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts index 442fe0c2baa..bd4945c79e6 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts @@ -9,7 +9,7 @@ import { MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING, } from "@/lib/constants/partner-profile"; import { partnerProfileAnalyticsQuerySchema } from "@/lib/zod/schemas/partner-profile"; -import { parseFilterValue, toCentsNumber } from "@dub/utils"; +import { parseFilterValue, serializeError, toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/analytics – get analytics for a program enrollment link @@ -81,21 +81,34 @@ export const GET = withPartnerProfile( }; } - const response = await getAnalytics({ - ...(LARGE_PROGRAM_IDS.includes(program.id) && - toCentsNumber(totalCommissions) < - LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS - ? { event: parsedParams.event, groupBy: "count", interval: "all" } - : parsedParams), - workspaceId: program.workspaceId, - ...(parsedParams.linkId - ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING - ? { partnerId: partner.id } - : { linkId: parseFilterValue(links.map((link) => link.id)) }), - dataAvailableFrom: program.startedAt ?? program.createdAt, - }); + try { + const response = await getAnalytics({ + ...(LARGE_PROGRAM_IDS.includes(program.id) && + toCentsNumber(totalCommissions) < + LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS + ? { event: parsedParams.event, groupBy: "count", interval: "all" } + : parsedParams), + workspaceId: program.workspaceId, + ...(parsedParams.linkId + ? { linkId: parsedParams.linkId } + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + ? { partnerId: partner.id } + : { linkId: parseFilterValue(links.map((link) => link.id)) }), + dataAvailableFrom: program.startedAt ?? program.createdAt, + }); + + return NextResponse.json(response); + } catch (error) { + // Tinybird times out after 30s on heavy partner queries; return a 400 + // instead of 500 so the UI can show a retry hint. + if (serializeError(error).includes("Timeout exceeded")) { + throw new DubApiError({ + code: "bad_request", + message: "Failed to fetch analytics. Refresh the page and try again.", + }); + } - return NextResponse.json(response); + throw error; + } }, );