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/(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; + } }, ); 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..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 @@ -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, }); }); } @@ -343,6 +347,7 @@ async function loadMergePlan({ }; } +// High-volume resources: move in batches of PRISMA_UPDATEMANY_LIMIT async function transferRowsInBatches( updateBatch: () => Promise, { @@ -360,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, @@ -418,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 @@ -432,13 +489,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 +557,8 @@ async function mergeSingleEnrollment({ programId, }); + let action: "overlap" | "transfer"; + if (targetEnrollment) { await prisma.$transaction(async (tx) => { if ( @@ -539,32 +614,51 @@ 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({ + sourcePartner: { + id: sourcePartnerId, + tenantId: sourceEnrollment.tenantId, + email: sourceEmail, + }, + targetPartner: { + id: targetPartnerId, + tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId, + email: targetEmail, + }, + targetAlreadyEnrolled: Boolean(targetEnrollment), + }), + }); + 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}).`, }); } 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..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 @@ -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"; @@ -135,15 +135,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/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..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 @@ -1,14 +1,13 @@ -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"; @@ -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, @@ -37,47 +40,42 @@ function CreateClawbackSheetContent( reset, watch, getValues, - formState: { errors, isSubmitting, isSubmitSuccessful }, + formState: { errors, isSubmitting }, } = 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/commissions", { + method: "POST", + body: { + type: "custom", + partnerId: data.partnerId, + amount: data.amount ? -Math.round(data.amount * 100) : 0, + description: data.reason, + }, + onSuccess: async ({ message }) => { + toast.success(message); + setIsOpen(false); + await mutatePrefix("/api/commissions"); + const currentValues = getValues(); + reset(currentValues); + }, }); }; - const disableSubmitButton = !partnerId || !amount || !description; - return (
@@ -110,7 +108,7 @@ function CreateClawbackSheetContent( rules={{ required: true }} render={({ field }) => ( )} @@ -171,21 +169,21 @@ function CreateClawbackSheetContent(
( )} /> - {errors.description && ( + {errors.reason && ( - {errors.description.message} + {errors.reason.message} )}
@@ -216,15 +214,15 @@ function CreateClawbackSheetContent( onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isPending || isSubmitting || isSubmitSuccessful} + disabled={isCreating || isSubmitting} />
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/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/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index 28bddd1720e..202e45e2c17 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -14,6 +14,7 @@ import { LeadEventWebhookPayload, PartnerApplicationWebhookPayload, PartnerEventWebhookPayload, + PartnerMergedWebhookPayload, PayoutEventWebhookPayload, SaleEventWebhookPayload, } from "../../webhook/types"; @@ -536,6 +537,69 @@ const bountyTemplates = ({ }; }; +const partnerMergedTemplate = ({ + data, +}: { + data: PartnerMergedWebhookPayload; +}) => { + const { targetAlreadyEnrolled, sourcePartner, targetPartner } = data; + const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${targetPartner.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\`${sourcePartner.id}\`${sourcePartner.email ? ` (${sourcePartner.email})` : ""}`, + }, + { + type: "mrkdwn", + text: `*Target*\n<${hrefToPartnerPage}|\`${targetPartner.id}\`>${targetPartner.email ? ` (${targetPartner.email})` : ""}`, + }, + ], + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Outcome*\n${outcomeLabel}`, + }, + ...(sourcePartner.tenantId || targetPartner.tenantId + ? [ + { + type: "mrkdwn", + text: `*Tenant ID*\n${sourcePartner.tenantId ?? "—"} → ${targetPartner.tenantId ?? "—"}`, + }, + ] + : []), + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<${hrefToPartnerPage}|View on Dub>`, + }, + ], + }, + ], + }; +}; + const payoutConfirmedTemplate = ({ data, }: { @@ -643,6 +707,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/openapi/commissions/create-commission.ts b/apps/web/lib/openapi/commissions/create-commission.ts index 9c6c9edf0cd..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. 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": { 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/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index 951492774d9..1cc12e5b0e8 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", @@ -36,20 +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", - "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/lib/webhook/sample-events/partner-merged.json b/apps/web/lib/webhook/sample-events/partner-merged.json new file mode 100644 index 00000000000..ed27a5f9efa --- /dev/null +++ b/apps/web/lib/webhook/sample-events/partner-merged.json @@ -0,0 +1,13 @@ +{ + "sourcePartner": { + "id": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "source@example.com" + }, + "targetPartner": { + "id": "pn_1K06X6FX2GRB31NCM2VVCGJ72", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "target@example.com" + }, + "targetAlreadyEnrolled": false +} diff --git a/apps/web/lib/webhook/sample-events/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 88d783eef34..3028ef21b75 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -11,6 +11,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"; @@ -23,6 +24,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 2aa2d8fceee..f0270f0fa08 100644 --- a/apps/web/lib/webhook/schemas.ts +++ b/apps/web/lib/webhook/schemas.ts @@ -6,6 +6,7 @@ import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { linkEventSchema } from "../zod/schemas/links"; import { EnrolledPartnerSchema, + partnerMergedWebhookSchema, WebhookPartnerSchema, } from "../zod/schemas/partners"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; @@ -157,6 +158,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 1ff442a78de..eaed23b0eea 100644 --- a/apps/web/lib/webhook/types.ts +++ b/apps/web/lib/webhook/types.ts @@ -3,7 +3,10 @@ import { BountySchema } from "../zod/schemas/bounties"; import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; 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"; @@ -27,6 +30,10 @@ export type PartnerApplicationWebhookPayload = z.infer< typeof partnerApplicationWebhookSchema >; +export type PartnerMergedWebhookPayload = z.infer< + typeof partnerMergedWebhookSchema +>; + export type CommissionEventWebhookPayload = z.infer< typeof CommissionWebhookSchema >; @@ -48,6 +55,7 @@ export type WebhookEventPayload = | SaleEventWebhookPayload | PartnerEventWebhookPayload | PartnerApplicationWebhookPayload + | PartnerMergedWebhookPayload | CommissionEventWebhookPayload | BountyEventWebhookPayload | PayoutEventWebhookPayload diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index a940a4dcfc0..261da9727d3 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -318,15 +318,6 @@ 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 COMMISSION_EXPORT_COLUMNS = [ { id: "id", label: "ID", type: "string", default: true }, { id: "type", label: "Type", type: "string", default: true }, @@ -461,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."), @@ -477,7 +474,12 @@ 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`) or an arbitrary string (max 190 characters).", + ].join("\n"), + ), }), // Lead commission @@ -573,15 +575,40 @@ export const createManualCommissionBodySchema = z }), ]) .superRefine((data, ctx) => { - if (data.type !== "sale") return; + if (data.type === "custom") { + if (data.amount < 0 && !data.description?.trim()) { + ctx.addIssue({ + code: "custom", + message: + "`description` is required when creating a clawback (negative amount).", + path: ["description"], + }); + } + return; + } + + if (data.type === "sale") { + if (data.importStripeInvoices) { + return; + } + + if (data.saleAmount == null) { + ctx.addIssue({ + code: "custom", + message: + "`saleAmount` is required when `importStripeInvoices` is false.", + path: ["saleAmount"], + }); + 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 === 0) { + ctx.addIssue({ + 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/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 8effe60da94..81c3218afc3 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -1070,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 `targetPartner.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"), + ), +}); 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.", }); } 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..69282245276 --- /dev/null +++ b/apps/web/playwright/api/clawbacks/clawbacks.spec.ts @@ -0,0 +1,185 @@ +import { prisma } from "@/lib/prisma"; +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: "A clawback has been queued for the partner!", +}; + +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 { + 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/commissions", { + type: "custom", + partnerId, + amount: -500, + description: "fraud", + }); + + expect(status).toEqual(202); + expect(data).toStrictEqual(expectedQueuedResponse); + + await expectClawbackCreated({ + partnerId: created.id, + programId: program.id, + amount: 500, + description: "fraud", + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /commissions – clawback with arbitrary description", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + const description = `chargeback-${nanoid()}`; + + 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/commissions", { + type: "custom", + partnerId, + amount: -100, + description, + }); + + expect(status).toEqual(202); + expect(data).toStrictEqual(expectedQueuedResponse); + + await expectClawbackCreated({ + partnerId: created.id, + programId: program.id, + amount: 100, + description, + }); + } finally { + await deletePartner(partnerId); + } +}); + +const missingPartnerId = `pn_${nanoid()}`; + +const clawbackErrorCases = [ + { + 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", + message: `Partner ${missingPartnerId} is not enrolled in program ${program.id}.`, + }), + }, + { + name: "POST /commissions – clawback missing partnerId", + body: { type: "custom", amount: -500, description: "fraud" }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_type: partnerId: Invalid input: expected string, received undefined", + }), + }, + { + name: "POST /commissions – clawback amount 0", + body: { + type: "custom", + partnerId: "pn_test", + amount: 0, + description: "fraud", + }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: amount: Amount cannot be 0.", + }), + }, + { + name: "POST /commissions – clawback missing description", + body: { type: "custom", partnerId: "pn_test", amount: -500 }, + expected: apiError({ + code: "unprocessable_entity", + message: + "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/commissions", 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..472aa73c3fe --- /dev/null +++ b/apps/web/playwright/api/partners/helpers.ts @@ -0,0 +1,54 @@ +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.payout.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; diff --git a/apps/web/tests/commissions/create-commission.test.ts b/apps/web/tests/commissions/create-commission.test.ts index 04aae324f07..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: "too_small: amount: Too small: expected number to be >=1", + expectedMessage: "custom: amount: Amount cannot be 0.", }, { name: "sale commission missing saleAmount", @@ -45,6 +45,18 @@ const validationCases = [ expectedMessage: "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.", + }, ]; validationCases.forEach(({ name, body, expectedStatus, expectedMessage }) => { diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 96f0510bf30..368180c6398 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -13,7 +13,10 @@ import { CommissionWebhookSchema } from "@/lib/zod/schemas/commissions"; import { CustomerSchema } from "@/lib/zod/schemas/customers"; import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; 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"; @@ -96,6 +99,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, 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 = 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/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/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/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..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/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) => (
-
+
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-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( 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, + [], + ); +}