-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add API endpoint for partner deactivation #3414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 4c7d262
Add partner access checks to plan change actions
marcusljf b2a38ea
Enhance failed payment email with partner program warning
marcusljf 013f2e4
Update failed payment email heading text
marcusljf 95e18d2
Add loading state to plan change confirmation modal
marcusljf 82067a9
Fix extra space in failed payment heading
marcusljf 078a65e
Remove premature modal close on plan change confirm
marcusljf 8519cc5
Revise partner program warning in plan change modal
marcusljf 22f9d83
Merge branch 'main' into deactivate-program-email
devkiran 15eb6ec
format
devkiran c88822f
refactor: extract bulk deactivate partners into reusable utility
devkiran ecd44e2
Update user-check.tsx
devkiran cc4305a
refactor: reuse bulk deactivate utility in single partner deactivation
devkiran c42c48c
refactor: move partner deactivation background tasks to cron endpoint
devkiran 0a88fb2
feat: add program-wide partner deactivation with offset pagination
devkiran 27d6d41
feat: add programDeactivated flag to partner deactivation flow
devkiran 643c627
feat: deactivate partner programs on workspace downgrade
devkiran 95cf83c
Update deactivate-program.ts
devkiran 1dbdab2
Update index.ts
devkiran e667787
Update route.ts
devkiran d9d50cc
Update route.ts
devkiran 25088dd
Refactor wouldLosePartnerAccess to use named parameters
devkiran a60e059
Update failed-payment.tsx
devkiran 6dedc9f
Update types.ts
devkiran 001d813
Track program deactivation and improve email subjects
devkiran b35339f
Update customer-subscription-deleted.ts
devkiran 08356fc
Update deactivate-program.ts
devkiran 5eba2f7
Update deactivate-program.ts
devkiran c28e316
Update deactivate-program.ts
devkiran c68f830
Update deactivate-program.ts
devkiran 8a9256b
Merge branch 'main' into deactivate-program-email
steven-tey 17f2fc6
use ACTIVE_ENROLLMENT_STATUSES
steven-tey 2e51c0a
Update deactivate-programs.ts
steven-tey da72e43
Add API endpoint for partner deactivation
devkiran 977cc91
Merge branch 'deactivate-program-email' into add-deactivate-partner-api
devkiran 8eed2c3
Add single partner deactivation utility and tests
devkiran 6e5219a
Merge branch 'main' into deactivate-program-email
steven-tey c2fddfc
Merge branch 'main' into deactivate-program-email
devkiran 5195b6a
update copy
steven-tey 9bf48f5
Merge branch 'deactivate-program-email' of https://github.com/dubinc/…
steven-tey 68e59b0
Merge branch 'deactivate-program-email' into add-deactivate-partner-api
devkiran 6324ff8
Merge branch 'main' into add-deactivate-partner-api
devkiran bdf8194
Add activity log tracking for partner group changes
devkiran 30c229a
Extract shared partner deactivation logic into separate file
devkiran 5431285
Improve the tests
devkiran 7ad90ea
Update process-partner-deactivation.ts
devkiran 77c48bd
Merge branch 'main' into add-deactivate-partner-api
steven-tey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"], | ||
| }, | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
115
apps/web/lib/api/partners/process-partner-deactivation.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: [] }], | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.