Skip to content

Commit 2d2cdb9

Browse files
devkiranmarcusljfsteven-tey
authored
Add API endpoint for partner deactivation (#3414)
Co-authored-by: Marcus Farrell <marcusljf@gmail.com> Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent ee9b58c commit 2d2cdb9

File tree

11 files changed

+412
-105
lines changed

11 files changed

+412
-105
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { DubApiError } from "@/lib/api/errors";
2+
import { deactivatePartner } from "@/lib/api/partners/deactivate-partner";
3+
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
4+
import { parseRequestBody } from "@/lib/api/utils";
5+
import { withWorkspace } from "@/lib/auth";
6+
7+
import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid";
8+
import { deactivatePartnerApiSchema } from "@/lib/zod/schemas/partners";
9+
import { prisma } from "@dub/prisma";
10+
import { NextResponse } from "next/server";
11+
12+
// POST /api/partners/deactivate – Deactivate a partner via API
13+
export const POST = withWorkspace(
14+
async ({ workspace, req, session }) => {
15+
let { partnerId, tenantId } = deactivatePartnerApiSchema.parse(
16+
await parseRequestBody(req),
17+
);
18+
19+
throwIfNoPartnerIdOrTenantId({
20+
partnerId,
21+
tenantId,
22+
});
23+
24+
const programId = getDefaultProgramIdOrThrow(workspace);
25+
26+
if (tenantId && !partnerId) {
27+
const programEnrollment = await prisma.programEnrollment.findUnique({
28+
where: {
29+
tenantId_programId: {
30+
tenantId,
31+
programId,
32+
},
33+
},
34+
select: {
35+
partnerId: true,
36+
},
37+
});
38+
39+
if (!programEnrollment) {
40+
throw new DubApiError({
41+
code: "not_found",
42+
message: `Partner with tenantId ${tenantId} not found in program.`,
43+
});
44+
}
45+
46+
partnerId = programEnrollment.partnerId;
47+
}
48+
49+
await deactivatePartner({
50+
workspaceId: workspace.id,
51+
programId,
52+
partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId
53+
user: session.user,
54+
});
55+
56+
return NextResponse.json({
57+
partnerId,
58+
});
59+
},
60+
{
61+
requiredPlan: ["advanced", "enterprise"],
62+
requiredRoles: ["owner", "member"],
63+
},
64+
);

apps/web/lib/actions/partners/deactivate-partner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use server";
22

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

2121
const programId = getDefaultProgramIdOrThrow(workspace);
2222

23-
await bulkDeactivatePartners({
23+
await deactivatePartner({
2424
workspaceId: workspace.id,
2525
programId,
26-
partnerIds: [partnerId],
26+
partnerId,
2727
user,
2828
});
2929
});
Lines changed: 7 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { Session } from "@/lib/auth";
2-
import { qstash } from "@/lib/cron";
32
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
43
import { prisma } from "@dub/prisma";
5-
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
6-
import { waitUntil } from "@vercel/functions";
7-
import { recordAuditLog } from "../audit-logs/record-audit-log";
4+
import { processPartnerDeactivation } from "./process-partner-deactivation";
85

96
interface BulkDeactivatePartnersParams {
107
workspaceId: string;
@@ -52,94 +49,13 @@ export async function bulkDeactivatePartners({
5249
return;
5350
}
5451

55-
const partnerIdsToDeactivate = programEnrollments.map(
56-
({ partner }) => partner.id,
57-
);
52+
const partners = programEnrollments.map(({ partner }) => partner);
5853

59-
await prisma.$transaction([
60-
prisma.link.updateMany({
61-
where: {
62-
programId,
63-
partnerId: {
64-
in: partnerIdsToDeactivate,
65-
},
66-
},
67-
data: {
68-
expiresAt: new Date(),
69-
},
70-
}),
71-
72-
prisma.programEnrollment.updateMany({
73-
where: {
74-
partnerId: {
75-
in: partnerIdsToDeactivate,
76-
},
77-
programId,
78-
status: {
79-
in: ACTIVE_ENROLLMENT_STATUSES,
80-
},
81-
},
82-
data: {
83-
status: "deactivated",
84-
clickRewardId: null,
85-
leadRewardId: null,
86-
saleRewardId: null,
87-
discountId: null,
88-
},
89-
}),
90-
]);
91-
92-
console.log("[bulkDeactivatePartners] Deactivated partners in program.", {
54+
await processPartnerDeactivation({
55+
workspaceId,
9356
programId,
94-
partnerIds: partnerIdsToDeactivate,
57+
partners,
58+
user,
59+
programDeactivated,
9560
});
96-
97-
if (user) {
98-
waitUntil(
99-
recordAuditLog(
100-
programEnrollments.map(({ partner }) => ({
101-
workspaceId,
102-
programId,
103-
action: "partner.deactivated",
104-
description: `Partner ${partner.id} deactivated`,
105-
actor: user,
106-
targets: [
107-
{
108-
type: "partner",
109-
id: partner.id,
110-
metadata: {
111-
name: partner.name,
112-
email: partner.email ?? null,
113-
},
114-
},
115-
],
116-
})),
117-
),
118-
);
119-
}
120-
121-
const qstashResponse = await qstash.publishJSON({
122-
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/deactivate`,
123-
body: {
124-
programId,
125-
partnerIds: partnerIdsToDeactivate,
126-
programDeactivated,
127-
},
128-
});
129-
130-
if (qstashResponse.messageId) {
131-
console.log(
132-
"[bulkDeactivatePartners] Deactivation job enqueued successfully.",
133-
{
134-
response: qstashResponse,
135-
},
136-
);
137-
} else {
138-
console.error(
139-
"[bulkDeactivatePartners] Failed to enqueue deactivation job",
140-
{
141-
response: qstashResponse,
142-
},
143-
);
144-
}
14561
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Session } from "@/lib/auth";
2+
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
3+
import { DubApiError } from "../errors";
4+
import { getProgramEnrollmentOrThrow } from "../programs/get-program-enrollment-or-throw";
5+
import { processPartnerDeactivation } from "./process-partner-deactivation";
6+
7+
interface DeactivatePartnerParams {
8+
workspaceId: string;
9+
programId: string;
10+
partnerId: string;
11+
user?: Session["user"];
12+
}
13+
14+
export async function deactivatePartner({
15+
workspaceId,
16+
programId,
17+
partnerId,
18+
user,
19+
}: DeactivatePartnerParams) {
20+
const programEnrollment = await getProgramEnrollmentOrThrow({
21+
programId,
22+
partnerId,
23+
include: {
24+
partner: true,
25+
},
26+
});
27+
28+
if (
29+
!ACTIVE_ENROLLMENT_STATUSES.includes(
30+
programEnrollment.status as (typeof ACTIVE_ENROLLMENT_STATUSES)[number],
31+
)
32+
) {
33+
throw new DubApiError({
34+
code: "bad_request",
35+
message: `Only partners with an "approved" or "archived" status can be deactivated. The partner's status in this program is "${programEnrollment.status}".`,
36+
});
37+
}
38+
39+
const { partner } = programEnrollment;
40+
41+
await processPartnerDeactivation({
42+
workspaceId,
43+
programId,
44+
partners: [partner],
45+
user,
46+
});
47+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Session } from "@/lib/auth";
2+
import { qstash } from "@/lib/cron";
3+
import { prisma } from "@dub/prisma";
4+
import { Partner } from "@dub/prisma/client";
5+
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
6+
import { waitUntil } from "@vercel/functions";
7+
import { recordAuditLog } from "../audit-logs/record-audit-log";
8+
9+
interface ProcessPartnerDeactivationParams {
10+
workspaceId: string;
11+
programId: string;
12+
partners: Pick<Partner, "id" | "name" | "email">[];
13+
user?: Session["user"];
14+
programDeactivated?: boolean;
15+
}
16+
17+
// Core function that executes the deactivation logic
18+
// Called by both deactivatePartner and bulkDeactivatePartners
19+
export async function processPartnerDeactivation({
20+
workspaceId,
21+
programId,
22+
partners,
23+
user,
24+
programDeactivated = false,
25+
}: ProcessPartnerDeactivationParams) {
26+
if (partners.length === 0) {
27+
return;
28+
}
29+
30+
const partnerIds = partners.map((p) => p.id);
31+
32+
await prisma.$transaction([
33+
prisma.link.updateMany({
34+
where: {
35+
programId,
36+
partnerId: {
37+
in: partnerIds,
38+
},
39+
},
40+
data: {
41+
expiresAt: new Date(),
42+
},
43+
}),
44+
45+
prisma.programEnrollment.updateMany({
46+
where: {
47+
partnerId: {
48+
in: partnerIds,
49+
},
50+
programId,
51+
},
52+
data: {
53+
status: "deactivated",
54+
clickRewardId: null,
55+
leadRewardId: null,
56+
saleRewardId: null,
57+
discountId: null,
58+
},
59+
}),
60+
]);
61+
62+
console.log("[processPartnerDeactivation] Deactivated partners in program.", {
63+
programId,
64+
partnerIds,
65+
});
66+
67+
if (user) {
68+
waitUntil(
69+
recordAuditLog(
70+
partners.map((partner) => ({
71+
workspaceId,
72+
programId,
73+
action: "partner.deactivated",
74+
description: `Partner ${partner.id} deactivated`,
75+
actor: user,
76+
targets: [
77+
{
78+
type: "partner",
79+
id: partner.id,
80+
metadata: {
81+
name: partner.name,
82+
email: partner.email,
83+
},
84+
},
85+
],
86+
})),
87+
),
88+
);
89+
}
90+
91+
const qstashResponse = await qstash.publishJSON({
92+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/deactivate`,
93+
body: {
94+
programId,
95+
partnerIds,
96+
programDeactivated,
97+
},
98+
});
99+
100+
if (qstashResponse.messageId) {
101+
console.log(
102+
"[processPartnerDeactivation] Deactivation job enqueued successfully.",
103+
{
104+
response: qstashResponse,
105+
},
106+
);
107+
} else {
108+
console.error(
109+
"[processPartnerDeactivation] Failed to enqueue deactivation job",
110+
{
111+
response: qstashResponse,
112+
},
113+
);
114+
}
115+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { openApiErrorResponses } from "@/lib/openapi/responses";
2+
import { deactivatePartnerApiSchema } from "@/lib/zod/schemas/partners";
3+
import { ZodOpenApiOperationObject } from "zod-openapi";
4+
import * as z from "zod/v4";
5+
6+
export const deactivatePartner: ZodOpenApiOperationObject = {
7+
operationId: "deactivatePartner",
8+
"x-speakeasy-name-override": "deactivate",
9+
summary: "Deactivate a partner",
10+
description:
11+
"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.",
12+
requestBody: {
13+
content: {
14+
"application/json": {
15+
schema: deactivatePartnerApiSchema,
16+
},
17+
},
18+
},
19+
responses: {
20+
"200": {
21+
description: "The deactivated partner",
22+
content: {
23+
"application/json": {
24+
schema: z.object({
25+
partnerId: z
26+
.string()
27+
.describe("The ID of the deactivated partner."),
28+
}),
29+
},
30+
},
31+
},
32+
...openApiErrorResponses,
33+
},
34+
tags: ["Partners"],
35+
security: [{ token: [] }],
36+
};

0 commit comments

Comments
 (0)