Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c410925
Site wide modal fixes
marcusljf Aug 19, 2026
d6e4d2d
partner.merged webhook for account merges
pepeladeira Aug 19, 2026
59d18a4
Commit 2
marcusljf Aug 19, 2026
7c87cc6
Commit 3
marcusljf Aug 19, 2026
6fe2755
code improvements
pepeladeira Aug 19, 2026
d26d4d4
Merge branch 'main' into add-partner-merged-webhook
pepeladeira Aug 19, 2026
36873ea
Merge branch 'main' into remounting-modals
pepeladeira Aug 19, 2026
23bc57d
Add POST /clawbacks API and replace the create-clawback server action.
devkiran Aug 20, 2026
46beb0f
Assert the created clawback
devkiran Aug 20, 2026
a559a71
Use POST /commissions for clawbacks instead of dedicated /api/clawbac…
devkiran Aug 20, 2026
ef55717
Return plain messages for single custom Zod validation errors.
devkiran Aug 20, 2026
1448e15
Update errors.ts
devkiran Aug 20, 2026
28bcd9a
Fix error message
devkiran Aug 20, 2026
7aeb104
Reject $0 sales, restore Zod error prefixes, and fix clawback success…
devkiran Aug 20, 2026
2f9cc60
Merge branch 'main' into clawback-api
devkiran Aug 20, 2026
dc3d469
Return a clawback-specific queued message from POST /commissions.
devkiran Aug 20, 2026
e5fe754
Merge branch 'main' into remounting-modals
pepeladeira Aug 20, 2026
0cebbb2
revert non-ui changes
pepeladeira Aug 20, 2026
eed4ab5
remove pendingOpenUnmount
pepeladeira Aug 20, 2026
06c8ea2
clear leftover apply events before transferring enrollments
pepeladeira Aug 20, 2026
9d7226b
update mergeSingleEnrollment
pepeladeira Aug 20, 2026
6479585
Merge branch 'main' into add-partner-merged-webhook
steven-tey Aug 20, 2026
0756951
refactor merge-partner-accounts workflow
steven-tey Aug 20, 2026
8249f8b
update openAPI description + webhook payload
steven-tey Aug 21, 2026
5bc9004
Merge branch 'main' into clawback-api
steven-tey Aug 21, 2026
7c2fb6c
address CR feedback
steven-tey Aug 21, 2026
84e8e85
tenantId: targetEnrollment?.tenantId ?? sourceEnrollment.tenantId
steven-tey Aug 21, 2026
eb219ea
Merge pull request #4371 from dubinc/clawback-api
steven-tey Aug 21, 2026
cb1bf12
Merge branch 'main' into add-partner-merged-webhook
steven-tey Aug 21, 2026
d60da21
Update partner-merged.json
steven-tey Aug 21, 2026
e45a2c1
Merge pull request #4365 from dubinc/add-partner-merged-webhook
steven-tey Aug 21, 2026
225a812
small openapi changes
steven-tey Aug 21, 2026
f16a2ec
improve WEBHOOK_TRIGGER_DESCRIPTIONS
steven-tey Aug 21, 2026
18c8646
Merge pull request #4357 from dubinc/remounting-modals
steven-tey Aug 21, 2026
44db3ed
improve esc keyboard shortcut
steven-tey Aug 21, 2026
61f601e
improve description
steven-tey Aug 21, 2026
33f7788
Merge branch 'main' into fix-merge-transfer-unique-collision
steven-tey Aug 21, 2026
e1bcfcc
transferIfNotExistElseDelete
steven-tey Aug 21, 2026
9f7fb41
Merge pull request #4377 from dubinc/fix-merge-transfer-unique-collision
steven-tey Aug 21, 2026
4fdf310
Return 400 instead of 500 when partner analytics times out
devkiran Aug 21, 2026
1b76e6c
Merge pull request #4379 from dubinc/partner-analytics-timeout
steven-tey Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions apps/web/app/(ee)/api/commissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
},
);
132 changes: 113 additions & 19 deletions apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +89,8 @@ export const { POST } = serve<Input>(
enrollmentId,
sourcePartnerId,
targetPartnerId,
sourceEmail,
targetEmail,
});
});
}
Expand Down Expand Up @@ -343,6 +347,7 @@ async function loadMergePlan({
};
}

// High-volume resources: move in batches of PRISMA_UPDATEMANY_LIMIT
async function transferRowsInBatches(
updateBatch: () => Promise<number>,
{
Expand All @@ -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<unknown>;
transferSource: () => Promise<number>;
deleteSource: () => Promise<number>;
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,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -484,6 +557,8 @@ async function mergeSingleEnrollment({
programId,
});

let action: "overlap" | "transfer";

if (targetEnrollment) {
await prisma.$transaction(async (tx) => {
if (
Expand Down Expand Up @@ -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}).`,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ function BountySubmissionDetailsSheetContent({
</div>
</div>

<RejectBountySubmissionModal />
{RejectBountySubmissionModal}
{ConfirmApproveBountySubmissionModal}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,15 +135,20 @@ export function useSendEmailPreviewModal({
const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] =
useState(false);

return {
showSendEmailPreviewModal,
setShowSendEmailPreviewModal,
SendEmailPreviewModal: () => (
const SendEmailPreviewModalCallback = useCallback(
() => (
<SendEmailPreviewModal
showModal={showSendEmailPreviewModal}
setShowModal={setShowSendEmailPreviewModal}
campaignId={campaignId}
/>
),
[showSendEmailPreviewModal, campaignId],
);

return {
showSendEmailPreviewModal,
setShowSendEmailPreviewModal,
SendEmailPreviewModal: SendEmailPreviewModalCallback,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function useDeleteCampaignModal(
campaign={campaign}
/>
);
}, [showDeleteCampaignModal, setShowDeleteCampaignModal, campaign]);
}, [showDeleteCampaignModal, setShowDeleteCampaignModal]);

return useMemo(
() => ({
Expand Down
Loading
Loading