Skip to content

Commit 28eb1b4

Browse files
authored
Auto-reactivate partners when workspace upgrades plan (dubinc#3639)
1 parent baa5042 commit 28eb1b4

5 files changed

Lines changed: 398 additions & 2 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { bulkReactivatePartners } from "@/lib/api/partners/bulk-reactivate-partners";
2+
import { CRON_BATCH_SIZE, qstash } from "@/lib/cron";
3+
import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs";
4+
import { withCron } from "@/lib/cron/with-cron";
5+
import { prisma } from "@dub/prisma";
6+
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
7+
import * as z from "zod/v4";
8+
import { logAndRespond } from "../../utils";
9+
10+
const inputSchema = z.object({
11+
programId: z.string(),
12+
});
13+
14+
// POST /api/cron/partners/reactivate - reactivate partners in a program
15+
export const POST = withCron(async ({ rawBody }) => {
16+
const { programId } = inputSchema.parse(JSON.parse(rawBody));
17+
18+
const program = await prisma.program.findUnique({
19+
where: {
20+
id: programId,
21+
},
22+
select: {
23+
id: true,
24+
workspaceId: true,
25+
name: true,
26+
deactivatedAt: true,
27+
slug: true,
28+
defaultGroupId: true,
29+
supportEmail: true,
30+
},
31+
});
32+
33+
if (!program) {
34+
return logAndRespond(`Program ${programId} not found.`);
35+
}
36+
37+
if (program.deactivatedAt) {
38+
return logAndRespond(
39+
`Program ${programId} is still deactivated. Skipping...`,
40+
);
41+
}
42+
43+
const programEnrollments = await prisma.programEnrollment.findMany({
44+
where: {
45+
programId,
46+
status: "deactivated",
47+
},
48+
select: {
49+
id: true,
50+
partnerId: true,
51+
groupId: true,
52+
partner: {
53+
select: {
54+
id: true,
55+
name: true,
56+
email: true,
57+
},
58+
},
59+
},
60+
take: CRON_BATCH_SIZE,
61+
});
62+
63+
if (programEnrollments.length > 0) {
64+
await bulkReactivatePartners({
65+
program,
66+
programEnrollments,
67+
});
68+
69+
// Self-queue the next batch if there are more partners to process
70+
if (programEnrollments.length === CRON_BATCH_SIZE) {
71+
const response = await qstash.publishJSON({
72+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/reactivate`,
73+
body: {
74+
programId,
75+
},
76+
});
77+
78+
return logAndRespond(
79+
`[reactivatePartners] Processed ${programEnrollments.length} partners. Queued next batch (messageId: ${response.messageId}).`,
80+
);
81+
}
82+
}
83+
84+
// All batches done – queue discount code creation for discounts with auto-provision enabled
85+
const discounts = await prisma.discount.findMany({
86+
where: {
87+
programId,
88+
autoProvisionEnabledAt: {
89+
not: null,
90+
},
91+
},
92+
select: {
93+
id: true,
94+
},
95+
});
96+
97+
if (discounts.length > 0) {
98+
await enqueueBatchJobs(
99+
discounts.map((discount) => ({
100+
queueName: "create-discount-code",
101+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create/queue-batches`,
102+
deduplicationId: `reactivate-discount-${discount.id}`,
103+
body: {
104+
discountId: discount.id,
105+
},
106+
})),
107+
);
108+
109+
console.log(
110+
`[reactivatePartners] Queued discount code creation for ${discounts.length} discounts.`,
111+
);
112+
}
113+
114+
return logAndRespond(
115+
`[reactivatePartners] Finished reactivating ${programEnrollments.length} partners for program ${programId}.`,
116+
);
117+
});

apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { deleteWorkspaceFolders } from "@/lib/api/folders/delete-workspace-folders";
22
import { deactivateProgram } from "@/lib/api/programs/deactivate-program";
33
import { tokenCache } from "@/lib/auth/token-cache";
4+
import { qstash } from "@/lib/cron";
45
import { syncUserPlanToPlain } from "@/lib/plain/sync-user-plan";
56
import { getPlanCapabilities } from "@/lib/plan-capabilities";
6-
import { wouldLosePartnerAccess } from "@/lib/plans/has-partner-access";
7+
import {
8+
wouldGainPartnerAccess,
9+
wouldLosePartnerAccess,
10+
} from "@/lib/plans/has-partner-access";
711
import { WorkspaceProps } from "@/lib/types";
812
import { webhookCache } from "@/lib/webhook/cache";
913
import { prisma } from "@dub/prisma";
10-
import { getPlanAndTierFromPriceId } from "@dub/utils";
14+
import { APP_DOMAIN_WITH_NGROK, getPlanAndTierFromPriceId } from "@dub/utils";
1115
import { NEW_BUSINESS_PRICE_IDS } from "@dub/utils/src";
1216
import { waitUntil } from "@vercel/functions";
1317

@@ -176,6 +180,28 @@ export async function updateWorkspacePlan({
176180
}
177181
}
178182

183+
// Reactivate all partners if the workspace gains partner access (Pro/Free -> Business/Enterprise)
184+
if (
185+
wouldGainPartnerAccess({
186+
currentPlan: workspace.plan,
187+
newPlan: newPlanName,
188+
})
189+
) {
190+
if (workspace.defaultProgramId) {
191+
const response = await qstash.publishJSON({
192+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/reactivate`,
193+
body: {
194+
programId: workspace.defaultProgramId,
195+
},
196+
deduplicationId: `reactivate-program-${workspace.defaultProgramId}`,
197+
});
198+
199+
console.log("Reactivation job enqueued.", {
200+
response,
201+
});
202+
}
203+
}
204+
179205
if (
180206
updatedWorkspace.status === "fulfilled" &&
181207
updatedWorkspace.value.users.length
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { sendBatchEmail } from "@dub/email";
2+
import PartnerReactivated from "@dub/email/templates/partner-reactivated";
3+
import { prisma } from "@dub/prisma";
4+
import { Partner, Program, ProgramEnrollment } from "@dub/prisma/client";
5+
import { linkCache } from "../links/cache";
6+
7+
type ProgramEnrollmentWithPartner = Pick<
8+
ProgramEnrollment,
9+
"id" | "partnerId" | "groupId"
10+
> & {
11+
partner: Pick<Partner, "id" | "name" | "email">;
12+
};
13+
14+
interface BulkReactivatePartnersParams {
15+
program: Pick<
16+
Program,
17+
"id" | "name" | "slug" | "workspaceId" | "defaultGroupId" | "supportEmail"
18+
>;
19+
programEnrollments: ProgramEnrollmentWithPartner[];
20+
}
21+
22+
export async function bulkReactivatePartners({
23+
program,
24+
programEnrollments,
25+
}: BulkReactivatePartnersParams) {
26+
if (programEnrollments.length === 0) {
27+
return;
28+
}
29+
30+
const partnerIds = programEnrollments.map((e) => e.partnerId);
31+
32+
// Resolve effective groupId per enrollment (fall back to program default)
33+
const resolvedGroupIds = programEnrollments.map(
34+
(e) => e.groupId || program.defaultGroupId,
35+
);
36+
37+
// Find the groups
38+
const groups = await prisma.partnerGroup.findMany({
39+
where: {
40+
id: {
41+
in: [...new Set(resolvedGroupIds.filter(Boolean))],
42+
},
43+
},
44+
select: {
45+
id: true,
46+
clickRewardId: true,
47+
leadRewardId: true,
48+
saleRewardId: true,
49+
discountId: true,
50+
},
51+
});
52+
53+
// Build a map from resolved groupId -> partnerIds
54+
const partnerIdsByGroupId = new Map<string, string[]>();
55+
56+
for (const enrollment of programEnrollments) {
57+
const groupId = enrollment.groupId || program.defaultGroupId;
58+
59+
if (!groupId) {
60+
continue;
61+
}
62+
63+
const partnerIds = partnerIdsByGroupId.get(groupId) || [];
64+
partnerIds.push(enrollment.partnerId);
65+
partnerIdsByGroupId.set(groupId, partnerIds);
66+
}
67+
68+
// Un-expire all links
69+
await prisma.link.updateMany({
70+
where: {
71+
programId: program.id,
72+
partnerId: {
73+
in: partnerIds,
74+
},
75+
},
76+
data: {
77+
expiresAt: null,
78+
},
79+
});
80+
81+
// Find all links and expire cache
82+
const allLinks = await prisma.link.findMany({
83+
where: {
84+
programId: program.id,
85+
partnerId: {
86+
in: partnerIds,
87+
},
88+
},
89+
select: {
90+
domain: true,
91+
key: true,
92+
},
93+
});
94+
95+
await linkCache.expireMany(allLinks);
96+
97+
// Update enrollments per group to restore rewards
98+
const groupsById = new Map(groups.map((g) => [g.id, g]));
99+
100+
for (const [groupId, groupPartnerIds] of partnerIdsByGroupId) {
101+
const group = groupsById.get(groupId);
102+
103+
if (!group) {
104+
continue;
105+
}
106+
107+
await prisma.programEnrollment.updateMany({
108+
where: {
109+
programId: program.id,
110+
partnerId: {
111+
in: groupPartnerIds,
112+
},
113+
status: "deactivated",
114+
},
115+
data: {
116+
status: "approved",
117+
groupId,
118+
clickRewardId: group.clickRewardId,
119+
leadRewardId: group.leadRewardId,
120+
saleRewardId: group.saleRewardId,
121+
discountId: group.discountId,
122+
},
123+
});
124+
}
125+
126+
// Send email notifications
127+
const emailResponse = await sendBatchEmail(
128+
programEnrollments.map(({ partner }) => ({
129+
variant: "notifications",
130+
subject: `The ${program.name} program has been reactivated`,
131+
to: partner.email!,
132+
replyTo: program.supportEmail || "noreply",
133+
react: PartnerReactivated({
134+
partner: {
135+
name: partner.name,
136+
email: partner.email!,
137+
},
138+
program: {
139+
name: program.name,
140+
slug: program.slug,
141+
},
142+
}),
143+
})),
144+
);
145+
146+
console.log("[bulkReactivatePartners] Sent notification emails.", {
147+
response: emailResponse,
148+
});
149+
}

apps/web/lib/plans/has-partner-access.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ export function wouldLosePartnerAccess({
3737
// Losing access means going from having access to not having access
3838
return hasCurrentAccess && !hasNewAccess;
3939
}
40+
41+
// Check if changing from currentPlan to newPlan would gain partner access
42+
export function wouldGainPartnerAccess({
43+
currentPlan,
44+
newPlan,
45+
}: {
46+
currentPlan: string;
47+
newPlan: string;
48+
}): boolean {
49+
const hasCurrentAccess = planHasPartnerAccess(currentPlan);
50+
const hasNewAccess = planHasPartnerAccess(newPlan);
51+
52+
return !hasCurrentAccess && hasNewAccess;
53+
}

0 commit comments

Comments
 (0)