Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
1a6877c
Add support for program-wide deactivation in email template
marcusljf Jan 16, 2026
4c7d262
Add partner access checks to plan change actions
marcusljf Jan 27, 2026
b2a38ea
Enhance failed payment email with partner program warning
marcusljf Jan 27, 2026
013f2e4
Update failed payment email heading text
marcusljf Jan 27, 2026
95e18d2
Add loading state to plan change confirmation modal
marcusljf Jan 27, 2026
82067a9
Fix extra space in failed payment heading
marcusljf Jan 27, 2026
078a65e
Remove premature modal close on plan change confirm
marcusljf Jan 27, 2026
8519cc5
Revise partner program warning in plan change modal
marcusljf Jan 28, 2026
22f9d83
Merge branch 'main' into deactivate-program-email
devkiran Feb 3, 2026
15eb6ec
format
devkiran Feb 3, 2026
c88822f
refactor: extract bulk deactivate partners into reusable utility
devkiran Feb 3, 2026
ecd44e2
Update user-check.tsx
devkiran Feb 3, 2026
cc4305a
refactor: reuse bulk deactivate utility in single partner deactivation
devkiran Feb 3, 2026
c42c48c
refactor: move partner deactivation background tasks to cron endpoint
devkiran Feb 3, 2026
0a88fb2
feat: add program-wide partner deactivation with offset pagination
devkiran Feb 3, 2026
27d6d41
feat: add programDeactivated flag to partner deactivation flow
devkiran Feb 3, 2026
643c627
feat: deactivate partner programs on workspace downgrade
devkiran Feb 3, 2026
95cf83c
Update deactivate-program.ts
devkiran Feb 3, 2026
1dbdab2
Update index.ts
devkiran Feb 3, 2026
e667787
Update route.ts
devkiran Feb 3, 2026
d9d50cc
Update route.ts
devkiran Feb 3, 2026
25088dd
Refactor wouldLosePartnerAccess to use named parameters
devkiran Feb 3, 2026
a60e059
Update failed-payment.tsx
devkiran Feb 3, 2026
6dedc9f
Update types.ts
devkiran Feb 3, 2026
001d813
Track program deactivation and improve email subjects
devkiran Feb 3, 2026
b35339f
Update customer-subscription-deleted.ts
devkiran Feb 3, 2026
08356fc
Update deactivate-program.ts
devkiran Feb 3, 2026
5eba2f7
Update deactivate-program.ts
devkiran Feb 3, 2026
c28e316
Update deactivate-program.ts
devkiran Feb 3, 2026
c68f830
Update deactivate-program.ts
devkiran Feb 3, 2026
8a9256b
Merge branch 'main' into deactivate-program-email
steven-tey Feb 4, 2026
17f2fc6
use ACTIVE_ENROLLMENT_STATUSES
steven-tey Feb 4, 2026
2e51c0a
Update deactivate-programs.ts
steven-tey Feb 4, 2026
da72e43
Add API endpoint for partner deactivation
devkiran Feb 4, 2026
977cc91
Merge branch 'deactivate-program-email' into add-deactivate-partner-api
devkiran Feb 4, 2026
8eed2c3
Add single partner deactivation utility and tests
devkiran Feb 4, 2026
6e5219a
Merge branch 'main' into deactivate-program-email
steven-tey Feb 4, 2026
c2fddfc
Merge branch 'main' into deactivate-program-email
devkiran Feb 4, 2026
5195b6a
update copy
steven-tey Feb 4, 2026
9bf48f5
Merge branch 'deactivate-program-email' of https://github.com/dubinc/…
steven-tey Feb 4, 2026
68e59b0
Merge branch 'deactivate-program-email' into add-deactivate-partner-api
devkiran Feb 4, 2026
6324ff8
Merge branch 'main' into add-deactivate-partner-api
devkiran Feb 4, 2026
bdf8194
Add activity log tracking for partner group changes
devkiran Feb 4, 2026
30c229a
Extract shared partner deactivation logic into separate file
devkiran Feb 4, 2026
5431285
Improve the tests
devkiran Feb 4, 2026
7ad90ea
Update process-partner-deactivation.ts
devkiran Feb 4, 2026
77c48bd
Merge branch 'main' into add-deactivate-partner-api
steven-tey Feb 4, 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
64 changes: 64 additions & 0 deletions apps/web/app/(ee)/api/partners/deactivate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { DubApiError } from "@/lib/api/errors";
import { deactivatePartner } from "@/lib/api/partners/deactivate-partner";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";

import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid";
import { deactivatePartnerApiSchema } from "@/lib/zod/schemas/partners";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

// POST /api/partners/deactivate – Deactivate a partner via API
export const POST = withWorkspace(
async ({ workspace, req, session }) => {
let { partnerId, tenantId } = deactivatePartnerApiSchema.parse(
await parseRequestBody(req),
);

throwIfNoPartnerIdOrTenantId({
partnerId,
tenantId,
});

const programId = getDefaultProgramIdOrThrow(workspace);

if (tenantId && !partnerId) {
const programEnrollment = await prisma.programEnrollment.findUnique({
where: {
tenantId_programId: {
tenantId,
programId,
},
},
select: {
partnerId: true,
},
});

if (!programEnrollment) {
throw new DubApiError({
code: "not_found",
message: `Partner with tenantId ${tenantId} not found in program.`,
});
}

partnerId = programEnrollment.partnerId;
}

await deactivatePartner({
workspaceId: workspace.id,
programId,
partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId
user: session.user,
});

return NextResponse.json({
partnerId,
});
},
{
requiredPlan: ["advanced", "enterprise"],
requiredRoles: ["owner", "member"],
},
);
6 changes: 3 additions & 3 deletions apps/web/lib/actions/partners/deactivate-partner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { bulkDeactivatePartners } from "@/lib/api/partners/bulk-deactivate-partners";
import { deactivatePartner } from "@/lib/api/partners/deactivate-partner";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { deactivatePartnerSchema } from "@/lib/zod/schemas/partners";
import { authActionClient } from "../safe-action";
Expand All @@ -20,10 +20,10 @@ export const deactivatePartnerAction = authActionClient

const programId = getDefaultProgramIdOrThrow(workspace);

await bulkDeactivatePartners({
await deactivatePartner({
workspaceId: workspace.id,
programId,
partnerIds: [partnerId],
partnerId,
user,
});
});
98 changes: 7 additions & 91 deletions apps/web/lib/api/partners/bulk-deactivate-partners.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { Session } from "@/lib/auth";
import { qstash } from "@/lib/cron";
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
import { prisma } from "@dub/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { recordAuditLog } from "../audit-logs/record-audit-log";
import { processPartnerDeactivation } from "./process-partner-deactivation";

interface BulkDeactivatePartnersParams {
workspaceId: string;
Expand Down Expand Up @@ -52,94 +49,13 @@ export async function bulkDeactivatePartners({
return;
}

const partnerIdsToDeactivate = programEnrollments.map(
({ partner }) => partner.id,
);
const partners = programEnrollments.map(({ partner }) => partner);

await prisma.$transaction([
prisma.link.updateMany({
where: {
programId,
partnerId: {
in: partnerIdsToDeactivate,
},
},
data: {
expiresAt: new Date(),
},
}),

prisma.programEnrollment.updateMany({
where: {
partnerId: {
in: partnerIdsToDeactivate,
},
programId,
status: {
in: ACTIVE_ENROLLMENT_STATUSES,
},
},
data: {
status: "deactivated",
clickRewardId: null,
leadRewardId: null,
saleRewardId: null,
discountId: null,
},
}),
]);

console.log("[bulkDeactivatePartners] Deactivated partners in program.", {
await processPartnerDeactivation({
workspaceId,
programId,
partnerIds: partnerIdsToDeactivate,
partners,
user,
programDeactivated,
});

if (user) {
waitUntil(
recordAuditLog(
programEnrollments.map(({ partner }) => ({
workspaceId,
programId,
action: "partner.deactivated",
description: `Partner ${partner.id} deactivated`,
actor: user,
targets: [
{
type: "partner",
id: partner.id,
metadata: {
name: partner.name,
email: partner.email ?? null,
},
},
],
})),
),
);
}

const qstashResponse = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/deactivate`,
body: {
programId,
partnerIds: partnerIdsToDeactivate,
programDeactivated,
},
});

if (qstashResponse.messageId) {
console.log(
"[bulkDeactivatePartners] Deactivation job enqueued successfully.",
{
response: qstashResponse,
},
);
} else {
console.error(
"[bulkDeactivatePartners] Failed to enqueue deactivation job",
{
response: qstashResponse,
},
);
}
}
47 changes: 47 additions & 0 deletions apps/web/lib/api/partners/deactivate-partner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Session } from "@/lib/auth";
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
import { DubApiError } from "../errors";
import { getProgramEnrollmentOrThrow } from "../programs/get-program-enrollment-or-throw";
import { processPartnerDeactivation } from "./process-partner-deactivation";

interface DeactivatePartnerParams {
workspaceId: string;
programId: string;
partnerId: string;
user?: Session["user"];
}

export async function deactivatePartner({
workspaceId,
programId,
partnerId,
user,
}: DeactivatePartnerParams) {
const programEnrollment = await getProgramEnrollmentOrThrow({
programId,
partnerId,
include: {
partner: true,
},
});

if (
!ACTIVE_ENROLLMENT_STATUSES.includes(
programEnrollment.status as (typeof ACTIVE_ENROLLMENT_STATUSES)[number],
)
) {
throw new DubApiError({
code: "bad_request",
message: `Only partners with an "approved" or "archived" status can be deactivated. The partner's status in this program is "${programEnrollment.status}".`,
});
}

const { partner } = programEnrollment;

await processPartnerDeactivation({
workspaceId,
programId,
partners: [partner],
user,
});
}
115 changes: 115 additions & 0 deletions apps/web/lib/api/partners/process-partner-deactivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Session } from "@/lib/auth";
import { qstash } from "@/lib/cron";
import { prisma } from "@dub/prisma";
import { Partner } from "@dub/prisma/client";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { recordAuditLog } from "../audit-logs/record-audit-log";

interface ProcessPartnerDeactivationParams {
workspaceId: string;
programId: string;
partners: Pick<Partner, "id" | "name" | "email">[];
user?: Session["user"];
programDeactivated?: boolean;
}

// Core function that executes the deactivation logic
// Called by both deactivatePartner and bulkDeactivatePartners
export async function processPartnerDeactivation({
workspaceId,
programId,
partners,
user,
programDeactivated = false,
}: ProcessPartnerDeactivationParams) {
if (partners.length === 0) {
return;
}

const partnerIds = partners.map((p) => p.id);

await prisma.$transaction([
prisma.link.updateMany({
where: {
programId,
partnerId: {
in: partnerIds,
},
},
data: {
expiresAt: new Date(),
},
}),

prisma.programEnrollment.updateMany({
where: {
partnerId: {
in: partnerIds,
},
programId,
},
data: {
status: "deactivated",
clickRewardId: null,
leadRewardId: null,
saleRewardId: null,
discountId: null,
},
}),
]);

console.log("[processPartnerDeactivation] Deactivated partners in program.", {
programId,
partnerIds,
});

if (user) {
waitUntil(
recordAuditLog(
partners.map((partner) => ({
workspaceId,
programId,
action: "partner.deactivated",
description: `Partner ${partner.id} deactivated`,
actor: user,
targets: [
{
type: "partner",
id: partner.id,
metadata: {
name: partner.name,
email: partner.email,
},
},
],
})),
),
);
}

const qstashResponse = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/deactivate`,
body: {
programId,
partnerIds,
programDeactivated,
},
});

if (qstashResponse.messageId) {
console.log(
"[processPartnerDeactivation] Deactivation job enqueued successfully.",
{
response: qstashResponse,
},
);
} else {
console.error(
"[processPartnerDeactivation] Failed to enqueue deactivation job",
{
response: qstashResponse,
},
);
}
}
36 changes: 36 additions & 0 deletions apps/web/lib/openapi/partners/deactivate-partner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { openApiErrorResponses } from "@/lib/openapi/responses";
import { deactivatePartnerApiSchema } from "@/lib/zod/schemas/partners";
import { ZodOpenApiOperationObject } from "zod-openapi";
import * as z from "zod/v4";

export const deactivatePartner: ZodOpenApiOperationObject = {
operationId: "deactivatePartner",
"x-speakeasy-name-override": "deactivate",
summary: "Deactivate a partner",
description:
"This will deactivate the partner from your program and disable all their active links. Their commissions and payouts will remain intact. You can reactivate them later if needed.",
requestBody: {
content: {
"application/json": {
schema: deactivatePartnerApiSchema,
},
},
},
responses: {
"200": {
description: "The deactivated partner",
content: {
"application/json": {
schema: z.object({
partnerId: z
.string()
.describe("The ID of the deactivated partner."),
}),
},
},
},
...openApiErrorResponses,
},
tags: ["Partners"],
security: [{ token: [] }],
};
Loading