Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { prisma } from "@dub/prisma";

// Mark the commissions as cancelled
export async function cancelCommissions({
programId,
partnerId,
}: {
programId: string;
partnerId: string;
}) {
let canceledCommissions = 0;
let failedBatches = 0;
const maxRetries = 3;

while (true) {
try {
const commissions = await prisma.commission.findMany({
where: {
programId,
partnerId,
status: "pending",
},
select: {
id: true,
},
orderBy: {
id: "asc",
},
take: 500,
});

if (commissions.length === 0) {
break;
}

const { count } = await prisma.commission.updateMany({
where: {
id: {
in: commissions.map((c) => c.id),
},
},
data: {
status: "canceled",
},
});

canceledCommissions += count;
} catch (error) {
failedBatches++;

// If we've failed too many times, break to avoid infinite loop
if (failedBatches >= maxRetries) {
console.error(
`Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`,
);
break;
}

// Wait a bit before retrying the same batch
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
Comment on lines +11 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retry counter is cumulative across batches instead of per-batch.

The failedBatches counter accumulates across all batches. If batch 1 fails twice then succeeds (failedBatches = 2), and batch 2 fails once (failedBatches = 3), the loop exits even though each batch hasn't exhausted its retries. This reduces resilience for large commission sets.

   let canceledCommissions = 0;
-  let failedBatches = 0;
   const maxRetries = 3;

   while (true) {
+    let retries = 0;
+
     try {
       const commissions = await prisma.commission.findMany({
         where: {
           programId,
           partnerId,
           status: "pending",
         },
         select: {
           id: true,
         },
         orderBy: {
           id: "asc",
         },
         take: 500,
       });

       if (commissions.length === 0) {
         break;
       }

       const { count } = await prisma.commission.updateMany({
         where: {
           id: {
             in: commissions.map((c) => c.id),
           },
         },
         data: {
           status: "canceled",
         },
       });

       canceledCommissions += count;
     } catch (error) {
-      failedBatches++;
+      retries++;

       // If we've failed too many times, break to avoid infinite loop
-      if (failedBatches >= maxRetries) {
+      if (retries >= maxRetries) {
         console.error(
           `Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`,
         );
         break;
       }

       // Wait a bit before retrying the same batch
       await new Promise((resolve) => setTimeout(resolve, 1000));
+      continue;
     }
   }

-  if (failedBatches > 0) {
-    console.warn(
-      `Cancelled ${canceledCommissions} commissions with ${failedBatches} failed batch(es).`,
-    );
-  } else {
-    console.info(`Cancelled ${canceledCommissions} commissions.`);
-  }
+  console.info(`Cancelled ${canceledCommissions} commissions.`);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts around
lines 11 to 62, the retry counter `failedBatches` is cumulative across all
batches causing early exit; change to a per-batch retry mechanism by removing
the global `failedBatches` usage and instead implement a per-batch attempt loop
(e.g., initialize an `attempts` counter before trying to process the current
batch, retry up to `maxRetries` on failures, and only break the outer loop when
a batch succeeds or the per-batch attempts exceed `maxRetries`), or reset
`failedBatches` to 0 after a successful batch; ensure waits between retries
remain and that total canceledCommissions is incremented only on successful
updates.


if (failedBatches > 0) {
console.warn(
`Cancelled ${canceledCommissions} commissions with ${failedBatches} failed batch(es).`,
);
} else {
console.info(`Cancelled ${canceledCommissions} commissions.`);
}
}
229 changes: 229 additions & 0 deletions apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { createFraudEvents } from "@/lib/api/fraud/create-fraud-events";
import { resolveFraudEvents } from "@/lib/api/fraud/resolve-fraud-events";
import { linkCache } from "@/lib/api/links/cache";
import { includeTags } from "@/lib/api/links/include-tags";
import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { recordLink } from "@/lib/tinybird";
import { BAN_PARTNER_REASONS } from "@/lib/zod/schemas/partners";
import { sendEmail } from "@dub/email";
import PartnerBanned from "@dub/email/templates/partner-banned";
import { prisma } from "@dub/prisma";
import { log } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../../../utils";
import { cancelCommissions } from "./cancel-commissions";

const schema = z.object({
programId: z.string(),
partnerId: z.string(),
userId: z.string(),
});

// POST /api/cron/partners/ban/process - do the post-ban processing
export async function POST(req: Request) {
try {
const rawBody = await req.text();

await verifyQstashSignature({
req,
rawBody,
});

const { programId, partnerId, userId } = schema.parse(JSON.parse(rawBody));

console.info(`Banning partner ${partnerId} from program ${programId}...`);

const { partner, links, ...programEnrollment } =
await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {
partner: true,
links: {
include: {
...includeTags,
discountCode: true,
},
},
},
});

if (programEnrollment.status !== "banned") {
return logAndRespond(
`Partner ${programEnrollment.partnerId} is not banned from program ${programEnrollment.programId}.`,
);
}

const commonWhere = {
programId,
partnerId,
};

const [linksUpdated, bountySubmissions, discountCodes, payouts] =
await prisma.$transaction([
// Disable links
prisma.link.updateMany({
where: {
...commonWhere,
},
data: {
disabledAt: new Date(),
expiresAt: new Date(),
},
}),

// Reject bounty submissions
prisma.bountySubmission.updateMany({
where: {
...commonWhere,
status: {
not: "approved",
},
},
data: {
status: "rejected",
},
}),

// Remove discount codes
prisma.discountCode.updateMany({
where: {
...commonWhere,
},
data: {
discountId: null,
},
}),

// Cancel payouts
prisma.payout.updateMany({
where: {
...commonWhere,
status: "pending",
},
data: {
status: "canceled",
},
}),
]);

console.info(`Disabled ${linksUpdated.count} links.`);
console.info(`Rejected ${bountySubmissions.count} bounty submissions.`);
console.info(`Removed ${discountCodes.count} discount codes.`);
console.info(`Cancelled ${payouts.count} payouts.`);

// Mark the commissions as cancelled
await cancelCommissions({
programId,
partnerId,
});

await Promise.all([
// Sync total commissions
syncTotalCommissions({
programId,
partnerId,
}),

// Expire links from cache
linkCache.expireMany(links),

// Delete links from Tinybird links metadata
recordLink(links, { deleted: true }),

// Queue discount code deletions
queueDiscountCodeDeletion(
links
.map((link) => link.discountCode?.id)
.filter((id): id is string => id !== undefined),
),
]);

// Find other programs where this partner is enrolled and approved
const programEnrollments = await prisma.programEnrollment.findMany({
where: {
partnerId,
programId: {
not: programId,
},
status: {
in: ["approved"],
},
},
});

await Promise.all([
// Automatically resolve all pending fraud events for this partner in the current program
resolveFraudEvents({
where: {
...commonWhere,
},
userId,
resolutionReason:
"Resolved automatically because the partner was banned.",
}),

// Create partnerCrossProgramBan fraud events for other programs where this partner
// is enrolled and approved, to flag potential cross-program fraud risk
createFraudEvents(
programEnrollments.map(({ programId }) => ({
programId,
partnerId,
type: "partnerCrossProgramBan",
})),
),
]);

// Send email
if (partner.email) {
const program = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
},
select: {
name: true,
slug: true,
supportEmail: true,
},
});

try {
await sendEmail({
to: partner.email,
subject: `You've been banned from the ${program.name} Partner Program`,
variant: "notifications",
replyTo: program.supportEmail || "noreply",
react: PartnerBanned({
partner: {
name: partner.name,
email: partner.email,
},
program: {
name: program.name,
slug: program.slug,
},
// A reason is always present because we validate the schema
bannedReason: programEnrollment.bannedReason
? BAN_PARTNER_REASONS[programEnrollment.bannedReason!]
: "",
}),
});
} catch {}
}

return logAndRespond(
`Partner ${partnerId} banned from the program ${programId}.`,
);
} catch (error) {
await log({
message: `Error banning partner /api/cron/partners/ban/process: ${error instanceof Error ? error.message : String(error)}`,
type: "cron",
});

return handleAndReturnErrorResponse(error);
}
}
20 changes: 17 additions & 3 deletions apps/web/app/(ee)/api/partners/ban/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { banPartner } from "@/lib/actions/partners/ban-partner";
import { DubApiError } from "@/lib/api/errors";
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 { banPartnerApiSchema } from "@/lib/zod/schemas/partners";
Expand All @@ -10,22 +12,34 @@ import { NextResponse } from "next/server";
export const POST = withWorkspace(
async ({ workspace, req, session }) => {
let { partnerId, tenantId, reason } = banPartnerApiSchema.parse(
await req.json(),
await parseRequestBody(req),
);

throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });

if (tenantId && !partnerId) {
const programId = getDefaultProgramIdOrThrow(workspace);
const partner = await prisma.programEnrollment.findUniqueOrThrow({

const programEnrollment = await prisma.programEnrollment.findUnique({
where: {
tenantId_programId: {
tenantId,
programId,
},
},
select: {
partnerId: true,
},
});
partnerId = partner.partnerId;

if (!programEnrollment) {
throw new DubApiError({
code: "not_found",
message: `Partner with tenantId ${tenantId} not found in program.`,
});
}

partnerId = programEnrollment.partnerId;
}

const response = await banPartner({
Expand Down
Loading