diff --git a/apps/web/app/(ee)/api/partners/deactivate/route.ts b/apps/web/app/(ee)/api/partners/deactivate/route.ts new file mode 100644 index 00000000000..b95ed06b54d --- /dev/null +++ b/apps/web/app/(ee)/api/partners/deactivate/route.ts @@ -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"], + }, +); diff --git a/apps/web/lib/actions/partners/deactivate-partner.ts b/apps/web/lib/actions/partners/deactivate-partner.ts index 38a9cddc788..bf1f4e86fa1 100644 --- a/apps/web/lib/actions/partners/deactivate-partner.ts +++ b/apps/web/lib/actions/partners/deactivate-partner.ts @@ -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"; @@ -20,10 +20,10 @@ export const deactivatePartnerAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - await bulkDeactivatePartners({ + await deactivatePartner({ workspaceId: workspace.id, programId, - partnerIds: [partnerId], + partnerId, user, }); }); diff --git a/apps/web/lib/api/partners/bulk-deactivate-partners.ts b/apps/web/lib/api/partners/bulk-deactivate-partners.ts index 5134d8dfe93..d11b8071ff4 100644 --- a/apps/web/lib/api/partners/bulk-deactivate-partners.ts +++ b/apps/web/lib/api/partners/bulk-deactivate-partners.ts @@ -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; @@ -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, - }, - ); - } } diff --git a/apps/web/lib/api/partners/deactivate-partner.ts b/apps/web/lib/api/partners/deactivate-partner.ts new file mode 100644 index 00000000000..cb1c23d5ea6 --- /dev/null +++ b/apps/web/lib/api/partners/deactivate-partner.ts @@ -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, + }); +} diff --git a/apps/web/lib/api/partners/process-partner-deactivation.ts b/apps/web/lib/api/partners/process-partner-deactivation.ts new file mode 100644 index 00000000000..75da2c94a22 --- /dev/null +++ b/apps/web/lib/api/partners/process-partner-deactivation.ts @@ -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[]; + 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, + }, + ); + } +} diff --git a/apps/web/lib/openapi/partners/deactivate-partner.ts b/apps/web/lib/openapi/partners/deactivate-partner.ts new file mode 100644 index 00000000000..e64f0eea628 --- /dev/null +++ b/apps/web/lib/openapi/partners/deactivate-partner.ts @@ -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: [] }], +}; diff --git a/apps/web/lib/openapi/partners/index.ts b/apps/web/lib/openapi/partners/index.ts index ec7d7cebe00..ebcf395095e 100644 --- a/apps/web/lib/openapi/partners/index.ts +++ b/apps/web/lib/openapi/partners/index.ts @@ -2,6 +2,7 @@ import { ZodOpenApiPathsObject } from "zod-openapi"; import { banPartner } from "./ban-partner"; import { createPartner } from "./create-partner"; import { createPartnerLink } from "./create-partner-link"; +import { deactivatePartner } from "./deactivate-partner"; import { listPartners } from "./list-partners"; import { retrievePartnerAnalytics } from "./retrieve-analytics"; import { retrievePartnerLinks } from "./retrieve-partner-links"; @@ -25,4 +26,7 @@ export const partnersPaths: ZodOpenApiPathsObject = { "/partners/ban": { post: banPartner, }, + "/partners/deactivate": { + post: deactivatePartner, + }, }; diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 1fb23588f76..3bf16605822 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -821,6 +821,8 @@ export const deactivatePartnerSchema = z.object({ partnerId: z.string(), }); +export const deactivatePartnerApiSchema = partnerIdTenantIdSchema; + export const archivePartnerSchema = z.object({ workspaceId: z.string(), partnerId: z.string(), diff --git a/apps/web/tests/partners/ban-partner.test.ts b/apps/web/tests/partners/ban-partner.test.ts index 4441679144c..65f03aa2cbf 100644 --- a/apps/web/tests/partners/ban-partner.test.ts +++ b/apps/web/tests/partners/ban-partner.test.ts @@ -1,7 +1,7 @@ import { generateRandomName } from "@/lib/names"; import { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from "@/lib/zod/schemas/partners"; -import { Partner } from "@dub/prisma/client"; import { describe, expect, test } from "vitest"; +import { fetchPartner } from "../utils/fetch-partner"; import { randomEmail, randomId } from "../utils/helpers"; import { IntegrationHarness } from "../utils/integration"; import { E2E_PARTNER_GROUP } from "../utils/resource"; @@ -22,11 +22,10 @@ describe.sequential("POST /partners/ban", async () => { groupId: E2E_PARTNER_GROUP.id, }; - const { data: createdData, status: createStatus } = - await http.post({ - path: "/partners", - body: partner, - }); + const { data: createdData, status: createStatus } = await http.post({ + path: "/partners", + body: partner, + }); expect(createStatus).toEqual(201); const createdPartner = EnrolledPartnerSchema.parse(createdData); @@ -43,6 +42,13 @@ describe.sequential("POST /partners/ban", async () => { expect(banStatus).toEqual(200); expect(banData.partnerId).toBe(createdPartner.id); + + // Verify the partner is banned + const fetchedPartner = await fetchPartner({ + http, + partnerId: createdPartner.id, + }); + expect(fetchedPartner.status).toBe("banned"); }); test("ban partner by tenantId", async () => { @@ -55,11 +61,10 @@ describe.sequential("POST /partners/ban", async () => { groupId: E2E_PARTNER_GROUP.id, }; - const { data: createdData, status: createStatus } = - await http.post({ - path: "/partners", - body: partner, - }); + const { data: createdData, status: createStatus } = await http.post({ + path: "/partners", + body: partner, + }); expect(createStatus).toEqual(201); const createdPartner = EnrolledPartnerSchema.parse(createdData); @@ -77,5 +82,12 @@ describe.sequential("POST /partners/ban", async () => { expect(banStatus).toEqual(200); expect(banData.partnerId).toBe(createdPartner.id); + + // Verify the partner is banned + const fetchedPartner = await fetchPartner({ + http, + partnerId: createdPartner.id, + }); + expect(fetchedPartner.status).toBe("banned"); }); }); diff --git a/apps/web/tests/partners/deactivate-partner.test.ts b/apps/web/tests/partners/deactivate-partner.test.ts new file mode 100644 index 00000000000..39a1dae7176 --- /dev/null +++ b/apps/web/tests/partners/deactivate-partner.test.ts @@ -0,0 +1,91 @@ +import { generateRandomName } from "@/lib/names"; +import { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from "@/lib/zod/schemas/partners"; +import { describe, expect, test } from "vitest"; +import { fetchPartner } from "../utils/fetch-partner"; +import { randomEmail, randomId } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PARTNER_GROUP } from "../utils/resource"; +import { normalizedPartnerDateFields } from "./resource"; + +const EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend( + normalizedPartnerDateFields.shape, +); + +describe.concurrent("POST /partners/deactivate", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + test("deactivate partner by partnerId", async () => { + const partner = { + name: generateRandomName(), + email: randomEmail(), + groupId: E2E_PARTNER_GROUP.id, + }; + + const { data: createdData, status: createStatus } = await http.post({ + path: "/partners", + body: partner, + }); + + expect(createStatus).toEqual(201); + const createdPartner = EnrolledPartnerSchema.parse(createdData); + + const { data: deactivateData, status: deactivateStatus } = await http.post<{ + partnerId: string; + }>({ + path: "/partners/deactivate", + body: { + partnerId: createdPartner.id, + }, + }); + + expect(deactivateStatus).toEqual(200); + expect(deactivateData.partnerId).toBe(createdPartner.id); + + // Verify the partner is deactivated + const fetchedPartner = await fetchPartner({ + http, + partnerId: createdPartner.id, + }); + expect(fetchedPartner.status).toBe("deactivated"); + }); + + test("deactivate partner by tenantId", async () => { + const tenantId = randomId(); + + const partner = { + name: generateRandomName(), + email: randomEmail(), + tenantId, + groupId: E2E_PARTNER_GROUP.id, + }; + + const { data: createdData, status: createStatus } = await http.post({ + path: "/partners", + body: partner, + }); + + expect(createStatus).toEqual(201); + const createdPartner = EnrolledPartnerSchema.parse(createdData); + expect(createdPartner.tenantId).toBe(tenantId); + + const { data: deactivateData, status: deactivateStatus } = await http.post<{ + partnerId: string; + }>({ + path: "/partners/deactivate", + body: { + tenantId, + }, + }); + + expect(deactivateStatus).toEqual(200); + expect(deactivateData.partnerId).toBe(createdPartner.id); + + // Verify the partner is deactivated + const fetchedPartner = await fetchPartner({ + http, + partnerId: createdPartner.id, + }); + expect(fetchedPartner.status).toBe("deactivated"); + }); +}); diff --git a/apps/web/tests/utils/fetch-partner.ts b/apps/web/tests/utils/fetch-partner.ts new file mode 100644 index 00000000000..46bf9a44f5d --- /dev/null +++ b/apps/web/tests/utils/fetch-partner.ts @@ -0,0 +1,20 @@ +import { EnrolledPartnerProps } from "@/lib/types"; +import { expect } from "vitest"; +import { HttpClient } from "./http"; + +export async function fetchPartner({ + http, + partnerId, +}: { + http: HttpClient; + partnerId: string; +}) { + const { data, status } = await http.get({ + path: `/partners?partnerIds=${partnerId}`, + }); + + expect(status).toEqual(200); + expect(data.length).toBeGreaterThan(0); + + return data[0]; +}