Skip to content

Commit 8b331ef

Browse files
authored
Merge pull request #3341 from dubinc/bulk-partners
Bulk partner actions update
2 parents 65cde14 + 63d72e2 commit 8b331ef

File tree

6 files changed

+669
-26
lines changed

6 files changed

+669
-26
lines changed

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import useWorkspace from "@/lib/swr/use-workspace";
1010
import { EnrolledPartnerProps } from "@/lib/types";
1111
import { useArchivePartnerModal } from "@/ui/modals/archive-partner-modal";
1212
import { useBanPartnerModal } from "@/ui/modals/ban-partner-modal";
13+
import { useBulkArchivePartnersModal } from "@/ui/modals/bulk-archive-partners-modal";
1314
import { useBulkBanPartnersModal } from "@/ui/modals/bulk-ban-partners-modal";
15+
import { useBulkDeactivatePartnersModal } from "@/ui/modals/bulk-deactivate-partners-modal";
1416
import { useChangeGroupModal } from "@/ui/modals/change-group-modal";
1517
import { useDeactivatePartnerModal } from "@/ui/modals/deactivate-partner-modal";
1618
import { useReactivatePartnerModal } from "@/ui/modals/reactivate-partner-modal";
@@ -167,6 +169,30 @@ export function PartnersTable() {
167169
partners: pendingChangeGroupPartners,
168170
});
169171

172+
const [pendingArchivePartners, setPendingArchivePartners] = useState<
173+
EnrolledPartnerProps[]
174+
>([]);
175+
176+
const { BulkArchivePartnersModal, setShowBulkArchivePartnersModal } =
177+
useBulkArchivePartnersModal({
178+
partners: pendingArchivePartners,
179+
onConfirm: async () => {
180+
await mutatePrefix("/api/partners");
181+
},
182+
});
183+
184+
const [pendingDeactivatePartners, setPendingDeactivatePartners] = useState<
185+
EnrolledPartnerProps[]
186+
>([]);
187+
188+
const { BulkDeactivatePartnersModal, setShowBulkDeactivatePartnersModal } =
189+
useBulkDeactivatePartnersModal({
190+
partners: pendingDeactivatePartners,
191+
onConfirm: async () => {
192+
await mutatePrefix("/api/partners");
193+
},
194+
});
195+
170196
const [pendingBanPartners, setPendingBanPartners] = useState<
171197
EnrolledPartnerProps[]
172198
>([]);
@@ -484,7 +510,7 @@ export function PartnersTable() {
484510
<>
485511
<Button
486512
variant="primary"
487-
text="Add to group"
513+
text="Change group"
488514
icon={<Users6 className="size-3.5 shrink-0" />}
489515
className="h-7 w-fit rounded-lg px-2.5"
490516
loading={false}
@@ -498,9 +524,18 @@ export function PartnersTable() {
498524
}}
499525
/>
500526

501-
{status !== "banned" && (
527+
{(status === "approved" ||
528+
searchParams.get("status") === "approved") && (
502529
<BulkActionsMenu
503530
table={table}
531+
onArchivePartners={(partners) => {
532+
setPendingArchivePartners(partners);
533+
setShowBulkArchivePartnersModal(true);
534+
}}
535+
onDeactivatePartners={(partners) => {
536+
setPendingDeactivatePartners(partners);
537+
setShowBulkDeactivatePartnersModal(true);
538+
}}
504539
onBanPartners={(partners) => {
505540
setPendingBanPartners(partners);
506541
setShowBulkBanPartnersModal(true);
@@ -520,6 +555,8 @@ export function PartnersTable() {
520555
return (
521556
<div className="flex flex-col gap-6">
522557
<ChangeGroupModal />
558+
<BulkArchivePartnersModal />
559+
<BulkDeactivatePartnersModal />
523560
<BulkBanPartnersModal />
524561
<div>
525562
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -575,13 +612,23 @@ export function PartnersTable() {
575612

576613
function BulkActionsMenu({
577614
table,
615+
onArchivePartners,
616+
onDeactivatePartners,
578617
onBanPartners,
579618
}: {
580619
table: TableType<EnrolledPartnerProps>;
620+
onArchivePartners: (partners: EnrolledPartnerProps[]) => void;
621+
onDeactivatePartners: (partners: EnrolledPartnerProps[]) => void;
581622
onBanPartners: (partners: EnrolledPartnerProps[]) => void;
582623
}) {
583624
const [isOpen, setIsOpen] = useState(false);
584625

626+
const selectedPartners = table
627+
.getSelectedRowModel()
628+
.rows.map((row) => row.original);
629+
630+
const partnerWord = selectedPartners.length === 1 ? "partner" : "partners";
631+
585632
return (
586633
<Popover
587634
openPopover={isOpen}
@@ -590,15 +637,28 @@ function BulkActionsMenu({
590637
<Command tabIndex={0} loop className="focus:outline-none">
591638
<Command.List className="w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]">
592639
<Command.Group className="grid gap-px p-1.5">
640+
<MenuItem
641+
icon={BoxArchive}
642+
label={`Archive ${partnerWord}`}
643+
onSelect={() => {
644+
onArchivePartners(selectedPartners);
645+
setIsOpen(false);
646+
}}
647+
/>
648+
<MenuItem
649+
icon={CircleXmark}
650+
label={`Deactivate ${partnerWord}`}
651+
onSelect={() => {
652+
onDeactivatePartners(selectedPartners);
653+
setIsOpen(false);
654+
}}
655+
/>
593656
<MenuItem
594657
icon={UserDelete}
595-
label="Ban partners"
658+
label={`Ban ${partnerWord}`}
596659
variant="danger"
597660
onSelect={() => {
598-
const partners = table
599-
.getSelectedRowModel()
600-
.rows.map((row) => row.original);
601-
onBanPartners(partners);
661+
onBanPartners(selectedPartners);
602662
setIsOpen(false);
603663
}}
604664
/>

apps/web/lib/actions/partners/bulk-archive-partners.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,21 @@ export const bulkArchivePartnersAction = authActionClient
5454
});
5555

5656
waitUntil(
57-
(async () => {
58-
// Record audit log for each partner
59-
await recordAuditLog(
60-
programEnrollments.map(({ partner }) => ({
61-
workspaceId: workspace.id,
62-
programId,
63-
action: "partner.archived",
64-
description: `Partner ${partner.id} archived`,
65-
actor: user,
66-
targets: [
67-
{
68-
type: "partner",
69-
id: partner.id,
70-
metadata: partner,
71-
},
72-
],
73-
})),
74-
);
75-
})(),
57+
recordAuditLog(
58+
programEnrollments.map(({ partner }) => ({
59+
workspaceId: workspace.id,
60+
programId,
61+
action: "partner.archived",
62+
description: `Partner ${partner.id} archived`,
63+
actor: user,
64+
targets: [
65+
{
66+
type: "partner",
67+
id: partner.id,
68+
metadata: partner,
69+
},
70+
],
71+
})),
72+
),
7673
);
7774
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"use server";
2+
3+
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
4+
import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion";
5+
import { linkCache } from "@/lib/api/links/cache";
6+
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
7+
import { bulkDeactivatePartnersSchema } from "@/lib/zod/schemas/partners";
8+
import { sendBatchEmail } from "@dub/email";
9+
import PartnerDeactivated from "@dub/email/templates/partner-deactivated";
10+
import { prisma } from "@dub/prisma";
11+
import { ProgramEnrollmentStatus } from "@dub/prisma/client";
12+
import { waitUntil } from "@vercel/functions";
13+
import { authActionClient } from "../safe-action";
14+
15+
export const bulkDeactivatePartnersAction = authActionClient
16+
.inputSchema(bulkDeactivatePartnersSchema)
17+
.action(async ({ parsedInput, ctx }) => {
18+
const { workspace, user } = ctx;
19+
const { partnerIds } = parsedInput;
20+
21+
const programId = getDefaultProgramIdOrThrow(workspace);
22+
23+
const programEnrollments = await prisma.programEnrollment.findMany({
24+
where: {
25+
partnerId: {
26+
in: partnerIds,
27+
},
28+
programId,
29+
status: {
30+
in: ["approved", "archived"],
31+
},
32+
},
33+
include: {
34+
partner: {
35+
select: {
36+
id: true,
37+
name: true,
38+
email: true,
39+
},
40+
},
41+
links: {
42+
select: {
43+
domain: true,
44+
key: true,
45+
},
46+
},
47+
discountCodes: true,
48+
},
49+
});
50+
51+
if (programEnrollments.length === 0) {
52+
throw new Error("You must provide at least one valid partner ID.");
53+
}
54+
55+
const partnerIdsToDeactivate = programEnrollments.map((pe) => pe.partnerId);
56+
const now = new Date();
57+
58+
await prisma.$transaction([
59+
prisma.link.updateMany({
60+
where: {
61+
programId,
62+
partnerId: {
63+
in: partnerIdsToDeactivate,
64+
},
65+
},
66+
data: {
67+
expiresAt: now,
68+
},
69+
}),
70+
71+
prisma.programEnrollment.updateMany({
72+
where: {
73+
partnerId: {
74+
in: partnerIdsToDeactivate,
75+
},
76+
programId,
77+
status: {
78+
in: ["approved", "archived"],
79+
},
80+
},
81+
data: {
82+
status: ProgramEnrollmentStatus.deactivated,
83+
clickRewardId: null,
84+
leadRewardId: null,
85+
saleRewardId: null,
86+
discountId: null,
87+
},
88+
}),
89+
]);
90+
91+
waitUntil(
92+
(async () => {
93+
const allLinks = programEnrollments.flatMap((pe) => pe.links);
94+
const allDiscountCodeIds = programEnrollments.flatMap((pe) =>
95+
pe.discountCodes.map((dc) => dc.id),
96+
);
97+
98+
const program = await prisma.program.findUniqueOrThrow({
99+
where: {
100+
id: programId,
101+
},
102+
select: {
103+
name: true,
104+
supportEmail: true,
105+
slug: true,
106+
},
107+
});
108+
109+
await Promise.allSettled([
110+
// Expire all links in cache
111+
linkCache.expireMany(allLinks),
112+
113+
// Queue discount code deletions
114+
queueDiscountCodeDeletion(allDiscountCodeIds),
115+
116+
// Record audit logs
117+
recordAuditLog(
118+
programEnrollments.map(({ partner }) => ({
119+
workspaceId: workspace.id,
120+
programId,
121+
action: "partner.deactivated",
122+
description: `Partner ${partner.id} deactivated`,
123+
actor: user,
124+
targets: [
125+
{
126+
type: "partner",
127+
id: partner.id,
128+
metadata: {
129+
name: partner.name,
130+
email: partner.email ?? null,
131+
},
132+
},
133+
],
134+
})),
135+
),
136+
]);
137+
138+
// Send notification emails
139+
await sendBatchEmail(
140+
programEnrollments.map(({ partner }) => ({
141+
variant: "notifications",
142+
subject: `Your partnership with ${program.name} has been deactivated`,
143+
to: partner.email!,
144+
replyTo: program.supportEmail || "noreply",
145+
react: PartnerDeactivated({
146+
partner: {
147+
name: partner.name,
148+
email: partner.email!,
149+
},
150+
program: {
151+
name: program.name,
152+
slug: program.slug,
153+
},
154+
}),
155+
})),
156+
);
157+
})(),
158+
);
159+
});

apps/web/lib/zod/schemas/partners.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,15 @@ export const bulkArchivePartnersSchema = z.object({
835835
.transform((v) => [...new Set(v)]),
836836
});
837837

838+
export const bulkDeactivatePartnersSchema = z.object({
839+
workspaceId: z.string(),
840+
partnerIds: z
841+
.array(z.string())
842+
.max(100)
843+
.min(1)
844+
.transform((v) => [...new Set(v)]),
845+
});
846+
838847
export const partnerPayoutSettingsSchema = z.object({
839848
companyName: z.string().max(190).trim().nullish(),
840849
address: z.string().max(500).trim().nullish(),

0 commit comments

Comments
 (0)