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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Nov 26, 2025

Summary by CodeRabbit

  • New Features

    • Bulk partner banning UI: select multiple partners, pick a reason, confirm, and ban via a new modal and bulk actions.
    • New background processing flow and API endpoint to handle post-ban cleanup and commission cancellations.
    • Batch job enqueueing to trigger asynchronous ban processing.
  • Behavioral Changes

    • Post-ban side effects (links, commissions, payouts, emails, fraud handling) now run asynchronously via queued jobs instead of in-process.

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link
Contributor

vercel bot commented Nov 26, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Nov 26, 2025 8:18pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 26, 2025

Walkthrough

Adds a cron POST handler and helper to process post-ban work asynchronously, a batch QStash enqueue utility, UI and server changes to support bulk banning (modal, table wiring, server actions), and refactors single/bulk ban actions to enqueue external cron jobs while simplifying in-process side effects.

Changes

Cohort / File(s) Summary
Cron Post-Ban Processing
apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts, apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
New cancelCommissions to iteratively cancel pending commissions in batches (<=500) with retry (max 3, 1s delay) and logging; new POST route that validates QStash, loads enrollment, performs transactional bulk updates (links, submissions, discounts, payouts), invokes cancelCommissions, and triggers parallel post-processing (syncs, cache, Tinybird, discount deletion, emails).
Batch Job Enqueueing
apps/web/lib/cron/enqueue-batch-jobs.ts
New enqueueBatchJobs<T>(jobs) helper that calls qstash.batchJSON, logs success, and handles/errors by logging and returning an empty array on failure.
Ban Server Action (single)
apps/web/lib/actions/partners/ban-partner.ts
banPartner now accepts explicit reason, reduces in-process side effects to a single enrollment update, records an audit log, and enqueues an external cron POST for post-ban processing; returns the updated ProgramEnrollment.
Bulk Ban Server Action
apps/web/lib/actions/partners/bulk-ban-partners.ts
Reworked to include reason, select programId/partnerId, early-return on empty results, create per-enrollment audit logs, and enqueue batch jobs (via enqueueBatchJobs) instead of performing synchronous multi-entity mutations and emails.
Bulk-Ban Modal (UI)
apps/web/ui/modals/bulk-ban-partners-modal.tsx
New useBulkBanPartnersModal hook and BulkBanPartnersModal component with form (reason, confirm text), validation, calls bulkBanPartnersAction, shows toasts, closes modal, and invalidates /api/partners cache on success.
Partners Table Integration
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
Wires bulk-ban flow: pending partner state, useBulkBanPartnersModal usage, BulkActionsMenu to trigger modal, and renders the modal in the table UI.
Fraud Event Table Integration
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
Adds bulk-ban selection/control to derive unique partners from selected rows, renders a "Ban" action that opens BulkBanPartnersModal, and resets selection on confirm.
API Body / Not-found Handling
apps/web/app/(ee)/api/partners/ban/route.ts
Replaced raw req.json() with parseRequestBody(req); when tenantId provided but partnerId missing, uses findUnique with manual not-found check and retrieves partnerId from the enrollment.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as Dashboard UI
    participant User
    participant Action as bulkBanPartnersAction
    participant Enqueue as enqueueBatchJobs / QStash
    participant Cron as /api/cron/partners/ban/process
    participant DB as Database

    User->>UI: Select partners & confirm ban
    UI->>Action: Call bulkBanPartnersAction({ partnerIds, reason })
    Action->>DB: Update programEnrollment.status = "banned"
    Action->>Action: recordAuditLog
    Action->>Enqueue: enqueueBatchJobs([{ url: Cron, body: { programId, partnerId, userId } }])
    Enqueue-->>Action: enqueue response
    Action-->>UI: Success toast, close modal

    Enqueue->>Cron: POST /api/cron/partners/ban/process (per enrollment)
    Cron->>DB: Load enrollment & related records
    Cron->>DB: Transactional bulk updates (disable/expire links, reject submissions, nullify discounts, cancel payouts)
    loop cancel pending commissions
        Cron->>DB: fetch pending commissions (<=500)
        alt found
            Cron->>DB: update commissions -> status = "canceled"
        else none
            Cron->>Cron: break loop
        end
        alt error
            Cron->>Cron: retry up to 3 with 1s delay
            note right of Cron: log warning if any batch failed
        end
    end
    Cron->>DB: trigger syncTotalCommissions, expire caches, mark Tinybird, queue discount deletion
    Cron->>Cron: send ban notification email (if email present)
    Cron-->>Enqueue: finished
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Review retry/termination logic and logging in cancelCommissions.
  • Verify transaction boundaries and that transactional bulk updates in route.ts cover intended entities safely.
  • Inspect enqueueBatchJobs error handling and downstream behavior when enqueue fails.
  • Confirm that removing in-process side effects in ban-partner.ts and bulk-ban-partners.ts preserves required auditability and triggers for downstream consumers.

Possibly related PRs

Poem

🐇
I hopped through queues with tiny paws,
Batched the bans and fixed the flaws,
Commissions folded, logs kept neat,
Modals clicked and QStash beat —
A rabbit's work: swift, tidy, sweet.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add bulk ban UI' accurately summarizes the main change: introducing bulk partner banning user interface components.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ban-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@devkiran devkiran requested a review from steven-tey November 26, 2025 08:45
@devkiran
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 26, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (9)
apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1)

15-61: Consider logging error details for debugging.

The retry logic correctly re-attempts the same batch on failure. However, when logging the failure at line 53-55, the actual error details are discarded. Including error information would help diagnose issues in production.

       if (failedBatches >= maxRetries) {
         console.error(
-          `Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`,
+          `Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`,
+          { programId, partnerId, error },
         );
         break;
       }
apps/web/lib/cron/enqueue-batch-jobs.ts (1)

20-24: Consider better error serialization.

JSON.stringify(error) on an Error object will often produce {} since message and stack are non-enumerable. Consider extracting relevant properties explicitly.

   } catch (error) {
+    const errorDetails = error instanceof Error 
+      ? { message: error.message, stack: error.stack }
+      : error;
     console.error("[enqueueBatchJobs] Failed to enqueue batch jobs", {
-      error: JSON.stringify(error, null, 2),
+      error: errorDetails,
       jobs,
     });
 
     await log({
-      message: `[enqueueBatchJobs] Failed to enqueue batch jobs: ${JSON.stringify(error, null, 2)}`,
+      message: `[enqueueBatchJobs] Failed to enqueue batch jobs: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
       type: "errors",
       mention: true,
     });
apps/web/app/(ee)/api/cron/partners/ban/route.ts (3)

125-129: Sequential operations could be parallelized for better performance.

cancelCommissions and syncTotalCommissions are called sequentially, but syncTotalCommissions doesn't depend on the result of cancelCommissions completing first. Consider parallelizing these if order doesn't matter:

-    // Mark the commissions as cancelled
-    await cancelCommissions({
-      programId,
-      partnerId,
-    });
-
-    // Sync total commissions
-    await syncTotalCommissions({
-      programId,
-      partnerId,
-    });
+    // Mark commissions as cancelled and sync totals
+    await Promise.all([
+      cancelCommissions({ programId, partnerId }),
+      syncTotalCommissions({ programId, partnerId }),
+    ]);

131-135: Sequential async operations could be parallelized.

These cache and Tinybird operations are independent and can run in parallel:

-    // Expire links from cache
-    await linkCache.expireMany(links);
-
-    // Delete links from Tinybird links metadata
-    await recordLink(links, { deleted: true });
+    // Expire links from cache and delete from Tinybird in parallel
+    await Promise.all([
+      linkCache.expireMany(links),
+      recordLink(links, { deleted: true }),
+    ]);

192-213: Empty catch block silently swallows email errors.

The empty catch {} block on line 213 silently ignores email sending failures. Consider logging the error for debugging purposes:

       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 {}
+      } catch (emailError) {
+        console.error(`Failed to send ban email to partner ${partnerId}:`, emailError);
+      }
     }
apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1)

194-215: Empty catch block silently swallows email errors.

Same issue as ban/route.ts - consider logging email failures:

       try {
         await sendEmail({
           // ... email options
         });
-      } catch {}
+      } catch (emailError) {
+        console.error(`Failed to send ban email to partner ${partnerId}:`, emailError);
+      }
apps/web/ui/modals/bulk-ban-partners-modal.tsx (3)

42-52: Form state persists when modal is reopened.

The form is not reset when the modal closes. If a user partially fills the form, closes the modal, and reopens it, they'll see the previous values. Consider resetting the form when the modal closes or when it opens:

+import { useEffect } from "react";

 function BulkBanPartnersModal({
   showBulkBanPartnersModal,
   setShowBulkBanPartnersModal,
   partners,
   onConfirm,
 }: BulkBanPartnersProps) {
   const { id: workspaceId } = useWorkspace();

   const {
     register,
     handleSubmit,
     watch,
+    reset,
     formState: { errors },
   } = useForm<BulkBanPartnersFormData>({
     defaultValues: {
       reason: "tos_violation",
       confirm: "",
     },
   });

+  // Reset form when modal opens
+  useEffect(() => {
+    if (showBulkBanPartnersModal) {
+      reset({ reason: "tos_violation", confirm: "" });
+    }
+  }, [showBulkBanPartnersModal, reset]);

Based on learnings, when modals are cancelled, local state should be reverted to prevent UI from showing unconfirmed changes.


56-68: Error handling could be more informative.

toast.error(error.serverError) may display undefined if the error doesn't have a serverError property. Consider a fallback message:

     onError({ error }) {
-      toast.error(error.serverError);
+      toast.error(error.serverError || "Failed to ban partners. Please try again.");
     },

232-246: setShowBulkBanPartnersModal doesn't need to be in useCallback dependencies.

setShowBulkBanPartnersModal is a React setState function and has a stable identity. Including it in dependencies is harmless but unnecessary:

   const BulkBanPartnersModalCallback = useCallback(() => {
     return (
       <BulkBanPartnersModal
         showBulkBanPartnersModal={showBulkBanPartnersModal}
         setShowBulkBanPartnersModal={setShowBulkBanPartnersModal}
         partners={partners}
         onConfirm={onConfirm}
       />
     );
-  }, [
-    showBulkBanPartnersModal,
-    setShowBulkBanPartnersModal,
-    partners,
-    onConfirm,
-  ]);
+  }, [showBulkBanPartnersModal, partners, onConfirm]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 14795da and 5c5b487.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/partners/ban/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/ban/route.ts (3 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (5 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (4 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/bulk-ban-partners.ts (4 hunks)
  • apps/web/lib/cron/enqueue-batch-jobs.ts (1 hunks)
  • apps/web/ui/modals/bulk-ban-partners-modal.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-11-24T09:10:12.494Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/lib/api/fraud/fraud-rules-registry.ts:17-25
Timestamp: 2025-11-24T09:10:12.494Z
Learning: In apps/web/lib/api/fraud/fraud-rules-registry.ts, the fraud rules `partnerCrossProgramBan` and `partnerDuplicatePayoutMethod` intentionally have stub implementations that return `{ triggered: false }` because they are partner-scoped rules handled separately during partner application/onboarding flows (e.g., in detect-record-fraud-application.ts), rather than being evaluated per conversion event like other rules in the registry. The stubs exist only to satisfy the `Record<FraudRuleType, ...>` type constraint.

Applied to files:

  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/(ee)/api/cron/partners/ban/route.ts
  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
  • apps/web/ui/modals/bulk-ban-partners-modal.tsx
📚 Learning: 2025-11-12T22:23:10.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 3098
File: apps/web/lib/actions/partners/message-program.ts:49-58
Timestamp: 2025-11-12T22:23:10.414Z
Learning: In apps/web/lib/actions/partners/message-program.ts, when checking if a partner can continue messaging after messaging is disabled, the code intentionally requires `senderPartnerId: null` (program-initiated messages) to prevent partners from continuing to send junk messages. Only conversations started by the program should continue after messaging is disabled, as a spam prevention mechanism.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/lib/actions/partners/bulk-ban-partners.ts
🧬 Code graph analysis (7)
apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/app/(ee)/api/cron/partners/ban/route.ts (12)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-67)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1)
  • cancelCommissions (4-71)
apps/web/lib/api/partners/sync-total-commissions.ts (1)
  • syncTotalCommissions (5-50)
apps/web/lib/api/links/cache.ts (1)
  • linkCache (148-148)
apps/web/lib/tinybird/record-link.ts (1)
  • recordLink (75-86)
apps/web/lib/api/discounts/queue-discount-code-deletion.ts (1)
  • queueDiscountCodeDeletion (13-37)
apps/web/lib/api/fraud/resolve-fraud-events.ts (1)
  • resolveFraudEvents (6-73)
apps/web/lib/api/fraud/create-fraud-events.ts (1)
  • createFraudEvents (6-37)
packages/email/src/index.ts (1)
  • sendEmail (6-29)
apps/web/lib/zod/schemas/partners.ts (1)
  • BAN_PARTNER_REASONS (66-73)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (162-165)
apps/web/app/(ee)/api/partners/ban/route.ts (2)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/partners/throw-if-no-partnerid-tenantid.ts (1)
  • throwIfNoPartnerIdOrTenantId (5-14)
apps/web/lib/actions/partners/ban-partner.ts (5)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (39-88)
apps/web/lib/zod/schemas/partners.ts (1)
  • banPartnerSchema (754-763)
apps/web/lib/openapi/partners/ban-partner.ts (1)
  • banPartner (6-34)
packages/prisma/client.ts (1)
  • ProgramEnrollmentStatus (31-31)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-73)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (2)
apps/web/lib/types.ts (1)
  • EnrolledPartnerProps (459-459)
apps/web/ui/modals/bulk-ban-partners-modal.tsx (1)
  • useBulkBanPartnersModal (222-255)
apps/web/lib/actions/partners/bulk-ban-partners.ts (3)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-73)
apps/web/lib/cron/enqueue-batch-jobs.ts (1)
  • enqueueBatchJobs (11-34)
apps/web/ui/modals/bulk-ban-partners-modal.tsx (5)
apps/web/lib/zod/schemas/partners.ts (2)
  • bulkBanPartnersSchema (769-782)
  • BAN_PARTNER_REASONS (66-73)
apps/web/lib/types.ts (1)
  • EnrolledPartnerProps (459-459)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (7-48)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)
  • bulkBanPartnersAction (13-99)
packages/utils/src/constants/misc.ts (1)
  • OG_AVATAR_URL (29-29)
🔇 Additional comments (12)
apps/web/app/(ee)/api/partners/ban/route.ts (2)

13-14: LGTM!

Using parseRequestBody instead of raw req.json() provides better error handling with a clear error message for malformed JSON requests.


19-31: LGTM!

The tenant-to-partner resolution logic correctly uses findUniqueOrThrow to fail fast when the enrollment doesn't exist. The partnerId! coercion on line 36 is safe since the code either has a partnerId from the request or assigns one after the lookup.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (1)

85-99: LGTM!

The modal hook integration and table ref pattern for resetting selection after bulk action confirmation is well-structured.

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

493-509: LGTM!

The conditional rendering of the Ban button based on status !== "banned" is correct - it prevents showing the ban action when viewing already-banned partners. The selection and modal trigger logic is sound.

apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1)

42-44: The review comment is incorrect—"canceled" (single 'l') is the correct spelling used in the Prisma schema.

The CommissionStatus enum in ./packages/prisma/schema/commission.prisma defines canceled (single 'l') at line 8, confirming that the code at lines 42-44 in the reviewed file uses the correct enum value.

apps/web/lib/actions/partners/bulk-ban-partners.ts (2)

45-48: Early return behavior is appropriate for bulk operations.

Silently returning when no eligible partners are found is a reasonable UX choice for bulk operations, as it prevents unnecessary errors when some selected partners are already banned.


67-98: Async processing pattern looks good.

The use of waitUntil with Promise.allSettled ensures that audit logging and batch job enqueueing happen asynchronously without blocking the response while still ensuring failures in one don't prevent the other from executing.

apps/web/lib/actions/partners/ban-partner.ts (2)

15-17: Queue initialization at module scope is appropriate.

The queue instance is created once at module load, which is efficient for reuse across multiple calls.


59-75: Database update correctly clears all reward and discount associations.

The update properly sets the banned status, timestamp, reason, and nullifies all reward/discount references in a single atomic operation.

apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1)

125-144: Good use of parallelization for independent operations.

This implementation correctly parallelizes independent operations (sync commissions, cache expiry, Tinybird recording, discount code deletion), which is more efficient than the sequential approach in the sibling ban/route.ts.

apps/web/ui/modals/bulk-ban-partners-modal.tsx (2)

106-145: Partner avatar display implementation looks good.

The conditional rendering for single vs. multiple partners with stacked avatars is a nice UX touch. The use of OG_AVATAR_URL fallback for missing images ensures consistent display.


222-255: Hook API design is clean and reusable.

The useBulkBanPartnersModal hook pattern returns both the setter and the component, making it easy to integrate into parent components. The memoization ensures stable references.

Base automatically changed from ban-api to main November 26, 2025 19:35
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)

170-173: Missing onConfirm callback to reset row selection.

The hook call doesn't pass an onConfirm callback to reset the table selection after a successful bulk ban. After banning, the selected rows will remain visually selected even though the partners are now banned.

🧹 Nitpick comments (2)
apps/web/app/(ee)/api/partners/ban/route.ts (1)

23-42: Consider using findUniqueOrThrow for consistency.

The manual error handling works but adds complexity. You could achieve the same result with:

try {
  const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({
    where: {
      tenantId_programId: {
        tenantId,
        programId,
      },
    },
    select: {
      partnerId: true,
    },
  });
  
  partnerId = programEnrollment.partnerId;
} catch (error) {
  throw new DubApiError({
    code: "not_found",
    message: `Partner with tenantId ${tenantId} not found in program.`,
  });
}

This maintains the custom error message while reducing duplication.

apps/web/lib/actions/partners/ban-partner.ts (1)

81-108: Consider logging failures in async processing.

Promise.allSettled silently swallows rejections from audit logging and queue enqueuing. If the queue enqueue fails, the partner will be marked as banned but side effects (disabling links, canceling commissions) will never execute.

Consider adding error logging:

waitUntil(
  Promise.allSettled([
    recordAuditLog({ /* ... */ }),
    queue.enqueueJSON({ /* ... */ }),
  ]).then((results) => {
    results.forEach((result, index) => {
      if (result.status === "rejected") {
        console.error(
          `Ban async operation ${index} failed for partner ${partnerId}:`,
          result.reason
        );
      }
    });
  })
);

This provides visibility into failures without blocking the response.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c5b487 and de0bfd2.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/ban/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (7 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-11-24T09:10:12.536Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/lib/api/fraud/fraud-rules-registry.ts:17-25
Timestamp: 2025-11-24T09:10:12.536Z
Learning: In apps/web/lib/api/fraud/fraud-rules-registry.ts, the fraud rules `partnerCrossProgramBan` and `partnerDuplicatePayoutMethod` intentionally have stub implementations that return `{ triggered: false }` because they are partner-scoped rules handled separately during partner application/onboarding flows (e.g., in detect-record-fraud-application.ts), rather than being evaluated per conversion event like other rules in the registry. The stubs exist only to satisfy the `Record<FraudRuleType, ...>` type constraint.

Applied to files:

  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
  • apps/web/lib/actions/partners/ban-partner.ts
📚 Learning: 2025-11-12T22:23:10.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 3098
File: apps/web/lib/actions/partners/message-program.ts:49-58
Timestamp: 2025-11-12T22:23:10.414Z
Learning: In apps/web/lib/actions/partners/message-program.ts, when checking if a partner can continue messaging after messaging is disabled, the code intentionally requires `senderPartnerId: null` (program-initiated messages) to prevent partners from continuing to send junk messages. Only conversations started by the program should continue after messaging is disabled, as a spam prevention mechanism.

Applied to files:

  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/lib/actions/partners/ban-partner.ts
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/partners/ban/route.ts (3)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/partners/throw-if-no-partnerid-tenantid.ts (1)
  • throwIfNoPartnerIdOrTenantId (5-14)
apps/web/lib/api/errors.ts (1)
  • DubApiError (58-75)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (20)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (4)

13-13: LGTM!

The imports are appropriate for the new bulk ban functionality and follow the existing patterns in the codebase.

Also applies to: 22-22, 60-60


494-502: LGTM!

The conditional rendering correctly hides the bulk ban option when viewing already-banned partners. The callback integration properly sets the pending partners and triggers the modal.


516-516: LGTM!

Modal rendering follows the established pattern alongside other modals in the component.


569-612: LGTM!

The BulkActionsMenu component is well-structured:

  • Uses the established Command/Popover pattern consistent with RowMenuButton
  • Properly extracts selected partners from the table model
  • Correctly closes the popover after action selection
  • Extensible design allows adding more bulk actions in the future
apps/web/app/(ee)/api/partners/ban/route.ts (2)

2-2: LGTM: Necessary imports added.

The DubApiError and parseRequestBody imports support improved error handling and request validation.

Also applies to: 4-4


15-15: LGTM: Improved request parsing.

Using parseRequestBody provides better error messages for malformed JSON requests.

apps/web/lib/actions/partners/ban-partner.ts (4)

16-18: LGTM: Queue initialization.

The Qstash queue instance for ban processing is correctly initialized at module level with a descriptive name.


25-25: LGTM: Reason parameter properly threaded.

The reason parameter is correctly extracted and passed to the ban logic.

Also applies to: 30-30


63-79: LGTM: Atomic ban state update.

The single transaction correctly marks the enrollment as banned and clears associated rewards and discounts, ensuring an immediate consistent state before async processing begins.


110-110: LGTM: Appropriate return value.

Returning the updated enrollment provides the caller with the complete banned state.

apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (10)

20-36: LGTM: Proper Qstash signature verification.

The endpoint correctly validates the Qstash signature before processing, ensuring only authorized queue jobs can trigger ban processing.


40-53: LGTM: Comprehensive data loading.

The enrollment query includes all necessary related data (partner, links, tags, discount codes) for complete post-ban processing.


125-144: LGTM: Parallel cleanup operations.

The parallel execution of cache expiration, Tinybird updates, and discount code queuing efficiently handles post-ban cleanup without unnecessary sequencing.


147-157: LGTM: Cross-program enrollment lookup.

The query correctly identifies other approved program enrollments for fraud detection purposes.


159-179: LGTM: Appropriate fraud event handling.

Resolving fraud events for the banned partner and creating cross-program fraud signals provides good fraud detection coverage. However, note the earlier concern about duplicate fraud events on retry.


182-216: LGTM: Email notification with error resilience.

The email notification correctly uses program and partner details, and the empty catch block (line 215) ensures email failures don't block ban processing. However, note the earlier concern about duplicate emails on retry.


218-220: LGTM: Appropriate success response.

The success message correctly identifies the completed ban processing.


221-228: LGTM: Proper error handling.

The error handling correctly uses a type guard for the error message and logs to the cron logger before returning an error response. The previous review comment about unknown type handling has been addressed.


120-123: Idempotency is ensured by design—cancelCommissions is safe for retry scenarios.

The function only processes commissions with status: "pending" (line 21). On the first execution, pending commissions are updated to "canceled". If the cron job is retried by Qstash, the function will query for pending commissions, find none (since they're now canceled), and exit gracefully without side effects. The query result on retry will be an empty array, causing the loop to break immediately (lines 32–34). No issue here.


103-111: Based on my investigation, I've found definitive evidence that the current behavior is intentional and correctly implemented:

Evidence:

  1. UI Documentation - Both ban modals explicitly state: "cancel all pending payouts" (not pending and processing)

    • apps/web/ui/modals/ban-partner-modal.tsx:116
    • apps/web/ui/modals/bulk-ban-partners-modal.tsx:150
  2. Payout Status Flow - The schema shows payouts have external payment provider references (stripeTransferId, paypalTransferId, stripePayoutId) that indicate commitment to external systems

  3. Business Logic - Processing payouts are already in-flight to external payment providers (Stripe/PayPal) and cannot be easily reverted mid-transfer

  4. Partner Protection Strategy - The ban still prevents future earnings by:

    • Disabling all links
    • Rejecting non-approved bounty submissions
    • Removing discount associations
    • Only allowing already-committed payouts to complete

Verify handling of "processing" payouts.

The code correctly implements the intended behavior: only "pending" payouts are canceled during partner bans. This matches the UI documentation which explicitly states "cancel all pending payouts." Processing payouts are deliberately left to complete because they are already committed to external payment providers (Stripe/PayPal) and cannot be reverted. The ban prevents future earnings while allowing committed payouts to fulfill.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
apps/web/lib/actions/partners/ban-partner.ts (1)

81-109: Consider logging failures from Promise.allSettled.

Using Promise.allSettled prevents async failures from blocking the main flow, but queue enqueue failures will be silently dropped. Consider adding .then() handling to log rejected promises for observability.

   waitUntil(
-    Promise.allSettled([
+    Promise.allSettled([
       recordAuditLog({
         ...
       }),

       queue.enqueueJSON({
         ...
       }),
-    ]),
+    ]).then((results) => {
+      results.forEach((result, index) => {
+        if (result.status === "rejected") {
+          console.error(`Ban partner async task ${index} failed:`, result.reason);
+        }
+      });
+    }),
   );
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)

45-48: Consider returning a count for UI feedback.

The early return is appropriate, but the action returns undefined in all cases. For better UI feedback, consider returning the count of banned partners.

     if (programEnrollments.length === 0) {
-      return;
+      return { bannedCount: 0 };
     }
+    
+    // ... after updateMany and waitUntil ...
+    
+    return { bannedCount: programEnrollments.length };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between de0bfd2 and 76456a1.

📒 Files selected for processing (2)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/bulk-ban-partners.ts (4 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-12T22:23:10.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 3098
File: apps/web/lib/actions/partners/message-program.ts:49-58
Timestamp: 2025-11-12T22:23:10.414Z
Learning: In apps/web/lib/actions/partners/message-program.ts, when checking if a partner can continue messaging after messaging is disabled, the code intentionally requires `senderPartnerId: null` (program-initiated messages) to prevent partners from continuing to send junk messages. Only conversations started by the program should continue after messaging is disabled, as a spam prevention mechanism.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-11-24T09:10:12.536Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/lib/api/fraud/fraud-rules-registry.ts:17-25
Timestamp: 2025-11-24T09:10:12.536Z
Learning: In apps/web/lib/api/fraud/fraud-rules-registry.ts, the fraud rules `partnerCrossProgramBan` and `partnerDuplicatePayoutMethod` intentionally have stub implementations that return `{ triggered: false }` because they are partner-scoped rules handled separately during partner application/onboarding flows (e.g., in detect-record-fraud-application.ts), rather than being evaluated per conversion event like other rules in the registry. The stubs exist only to satisfy the `Record<FraudRuleType, ...>` type constraint.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/lib/actions/partners/bulk-ban-partners.ts
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/ban-partner.ts (4)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (39-88)
apps/web/lib/zod/schemas/partners.ts (1)
  • banPartnerSchema (754-763)
apps/web/lib/openapi/partners/ban-partner.ts (1)
  • banPartner (6-34)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-73)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (7)
apps/web/lib/actions/partners/ban-partner.ts (3)

16-18: LGTM on queue initialization.

Module-level queue initialization is appropriate for reuse across invocations.


21-33: LGTM on action implementation.

The reason parameter is correctly extracted and forwarded to the banPartner function.


111-111: Perfect. I have all the information needed. The review comment is correct - there is a genuine API contract violation. Let me provide the rewritten review comment:


API response contract mismatch: returns full enrollment object instead of documented { partnerId } response.

The banPartner function returns the complete programEnrollmentUpdated object, but the API route at apps/web/app/(ee)/api/partners/ban/route.ts (line 52) directly returns this without transformation. However, the OpenAPI schema (apps/web/lib/openapi/partners/ban-partner.ts, lines 24-26) documents the response as { partnerId: string }. This breaks the API contract and will fail any client expecting the documented schema. Transform the response before returning:

return NextResponse.json({ partnerId: response.partnerId });
⛔ Skipped due to learnings
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/lib/api/fraud/fraud-rules-registry.ts:17-25
Timestamp: 2025-11-24T09:10:12.536Z
Learning: In apps/web/lib/api/fraud/fraud-rules-registry.ts, the fraud rules `partnerCrossProgramBan` and `partnerDuplicatePayoutMethod` intentionally have stub implementations that return `{ triggered: false }` because they are partner-scoped rules handled separately during partner application/onboarding flows (e.g., in detect-record-fraud-application.ts), rather than being evaluated per conversion event like other rules in the registry. The stubs exist only to satisfy the `Record<FraudRuleType, ...>` type constraint.
apps/web/lib/actions/partners/bulk-ban-partners.ts (4)

1-11: LGTM on imports.

Clean import structure with the new enqueueBatchJobs utility for batch processing.


50-65: LGTM on bulk update logic.

The update is consistent with the single-partner ban logic, properly clearing reward associations and setting the banned status.


69-84: LGTM on audit logging.

The audit log entries are correctly structured and consistent with the single-partner ban action.


86-97: I'll verify the review comment by checking the enqueueBatchJobs utility implementation and its error handling. Let me start by exploring the codebase.
<function_calls>


#!/bin/bash

Search for enqueueBatchJobs utility file

fd "enqueue" --type f --extension ts --extension js | head -20


</function_calls>
<function_calls>


#!/bin/bash

Look for imports in the bulk-ban-partners.ts file

rg "enqueueBatchJobs" --type ts -B2 -A2 | head -30


</function_calls>
<function_calls>


#!/bin/bash

Search for enqueueBatchJobs definition

rg "enqueueBatchJobs" --type ts -B5 -A10 | grep -E "(export|function|const)" | head -20


</function_calls>

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 26, 2025

✅ Actions performed

Full review triggered.

@steven-tey steven-tey merged commit 3673b73 into main Nov 26, 2025
7 of 8 checks passed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (1)

246-254: Filter out null partners before deduplication.

This issue was already flagged in a previous review. row.original.partner can be null (as evidenced by the null check on line 149). Mapping partners directly and then accessing p.id in the Map constructor will throw if any selected row has a null partner.

     selectionControls: (tableInstance) => {
       // Store table reference for resetting selection
       tableRef.current = tableInstance;

       const selectedRows = tableInstance.getSelectedRowModel().rows;
-      const partners = selectedRows.map((row) => row.original.partner);
+      const partners = selectedRows
+        .map((row) => row.original.partner)
+        .filter((p): p is NonNullable<typeof p> => p != null);

       // Remove duplicates by partner ID
       const uniquePartners = Array.from(
         new Map(partners.map((p) => [p.id, p])).values(),
       );

       if (uniquePartners.length === 0) return null;
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)

170-173: Missing onConfirm callback to reset row selection after bulk ban.

The useBulkBanPartnersModal hook supports an onConfirm callback (as shown in the relevant code snippets), but it's not being passed here. After a successful bulk ban, selected rows will remain visually selected even though the partners are now banned.

+  const tableRef = useRef<ReturnType<typeof useTable<EnrolledPartnerProps>>["table"] | null>(null);
+
   const { BulkBanPartnersModal, setShowBulkBanPartnersModal } =
     useBulkBanPartnersModal({
       partners: pendingBanPartners,
+      onConfirm: () => {
+        tableRef.current?.resetRowSelection();
+      },
     });

You'll also need to capture the table reference in selectionControls:

selectionControls: (table) => {
  tableRef.current = table;
  return (
    // ... existing JSX
  );
}
apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1)

170-178: Potential duplicate fraud events on QStash retry.

If this cron job is retried by QStash (e.g., due to timeout or transient failure), createFraudEvents will create duplicate partnerCrossProgramBan fraud events since there's no deduplication. The FraudEvent schema lacks a unique constraint on (programId, partnerId, type).

Consider one of these approaches:

  1. Add skipDuplicates: true to createMany in createFraudEvents
  2. Check for existing pending fraud events before creating
  3. Add a unique constraint at the database level
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/bulk-ban-partners.ts (1)

45-48: Silent early return may leave callers unaware of no-op.

The action returns undefined when no eligible partners are found. Consider returning an object with a count or status so the UI can provide appropriate feedback (e.g., "No partners eligible for banning").

     // Don't throw an error if no partners are found, just return
     if (programEnrollments.length === 0) {
-      return;
+      return { count: 0 };
     }

And at the end:

+    return { count: programEnrollments.length };
   });
apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1)

194-215: Silent email failure may hinder debugging.

The empty catch {} block swallows email errors without any logging. Consider adding at least a warning log to aid in troubleshooting delivery issues.

       try {
         await sendEmail({
           // ...
         });
-      } catch {}
+      } catch (emailError) {
+        console.warn(
+          `Failed to send ban email to partner ${partnerId}:`,
+          emailError instanceof Error ? emailError.message : emailError,
+        );
+      }
apps/web/ui/modals/bulk-ban-partners-modal.tsx (1)

42-52: Form state persists across modal open/close cycles.

The useForm hook initializes with default values, but these aren't reset when the modal is closed and reopened. If a user partially fills the form, closes without submitting, then reopens, the previous input may remain.

Consider adding a reset call when the modal opens or using key prop to force remount:

+  // Reset form when modal opens
+  useEffect(() => {
+    if (showBulkBanPartnersModal) {
+      reset({ reason: "tos_violation", confirm: "" });
+    }
+  }, [showBulkBanPartnersModal, reset]);

Or in the hook:

   <BulkBanPartnersModal
+    key={showBulkBanPartnersModal ? "open" : "closed"}
     showBulkBanPartnersModal={showBulkBanPartnersModal}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9fbcb94 and 76456a1.

📒 Files selected for processing (9)
  • apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (1 hunks)
  • apps/web/app/(ee)/api/partners/ban/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (5 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (7 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/bulk-ban-partners.ts (4 hunks)
  • apps/web/lib/cron/enqueue-batch-jobs.ts (1 hunks)
  • apps/web/ui/modals/bulk-ban-partners-modal.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (12)
📚 Learning: 2025-11-24T09:10:12.536Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/lib/api/fraud/fraud-rules-registry.ts:17-25
Timestamp: 2025-11-24T09:10:12.536Z
Learning: In apps/web/lib/api/fraud/fraud-rules-registry.ts, the fraud rules `partnerCrossProgramBan` and `partnerDuplicatePayoutMethod` intentionally have stub implementations that return `{ triggered: false }` because they are partner-scoped rules handled separately during partner application/onboarding flows (e.g., in detect-record-fraud-application.ts), rather than being evaluated per conversion event like other rules in the registry. The stubs exist only to satisfy the `Record<FraudRuleType, ...>` type constraint.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/ui/modals/bulk-ban-partners-modal.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-11-12T22:23:10.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 3098
File: apps/web/lib/actions/partners/message-program.ts:49-58
Timestamp: 2025-11-12T22:23:10.414Z
Learning: In apps/web/lib/actions/partners/message-program.ts, when checking if a partner can continue messaging after messaging is disabled, the code intentionally requires `senderPartnerId: null` (program-initiated messages) to prevent partners from continuing to send junk messages. Only conversations started by the program should continue after messaging is disabled, as a spam prevention mechanism.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-08-16T11:14:00.667Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2754
File: apps/web/lib/partnerstack/schemas.ts:47-52
Timestamp: 2025-08-16T11:14:00.667Z
Learning: The PartnerStack API always includes the `group` field in partner responses, so the schema should use `.nullable()` rather than `.nullish()` since the field is never omitted/undefined.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-08-14T05:57:35.546Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2735
File: apps/web/lib/actions/partners/update-discount.ts:60-66
Timestamp: 2025-08-14T05:57:35.546Z
Learning: In the partner groups system, discounts should always belong to a group. The partnerGroup relation should never be null when updating discounts, so optional chaining on partnerGroup?.id may be unnecessary defensive programming.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
📚 Learning: 2025-11-24T08:55:31.332Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3089
File: apps/web/app/(ee)/api/fraud-rules/route.ts:71-87
Timestamp: 2025-11-24T08:55:31.332Z
Learning: In apps/web/app/(ee)/api/fraud-rules/route.ts, fraud rules cannot be created in a disabled state. When using prisma.fraudRule.upsert, the create branch intentionally omits the disabledAt field (defaulting to null, meaning enabled), while the update branch allows toggling enabled/disabled state via the disabledAt field. This is a business logic constraint.

Applied to files:

  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
📚 Learning: 2025-10-06T15:48:14.205Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2935
File: apps/web/lib/actions/partners/invite-partner-from-network.ts:21-28
Timestamp: 2025-10-06T15:48:14.205Z
Learning: For the network invites limit check in apps/web/lib/actions/partners/invite-partner-from-network.ts, the team accepts that concurrent invites may bypass the limit due to race conditions. Perfect atomicity is not required for this feature.

Applied to files:

  • apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/lib/actions/partners/ban-partner.ts
  • apps/web/lib/actions/partners/bulk-ban-partners.ts
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/lib/actions/partners/bulk-ban-partners.ts
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/partners/ban/route.ts (4)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/partners/throw-if-no-partnerid-tenantid.ts (1)
  • throwIfNoPartnerIdOrTenantId (5-14)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/errors.ts (1)
  • DubApiError (58-75)
apps/web/app/(ee)/api/cron/partners/ban/process/cancel-commissions.ts (1)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (3)
apps/web/lib/types.ts (1)
  • fraudEventGroupProps (676-676)
packages/ui/src/table/table.tsx (1)
  • useTable (51-243)
apps/web/ui/modals/bulk-ban-partners-modal.tsx (1)
  • useBulkBanPartnersModal (222-255)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (3)
apps/web/lib/types.ts (1)
  • EnrolledPartnerProps (459-459)
apps/web/ui/modals/bulk-ban-partners-modal.tsx (1)
  • useBulkBanPartnersModal (222-255)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (43-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: api-tests
🔇 Additional comments (14)
apps/web/lib/cron/enqueue-batch-jobs.ts (1)

10-34: LGTM!

The batch enqueue utility is well-implemented with comprehensive error handling. The dual logging approach (console.error for immediate debugging + log() with mention: true for alerting) ensures failures are visible.

apps/web/lib/actions/partners/ban-partner.ts (2)

16-18: LGTM!

Good use of a dedicated queue with a descriptive name for the ban-partner workflow.


81-109: Good use of Promise.allSettled for non-blocking side effects.

This ensures both the audit log and queue enqueue are attempted independently, and neither failure blocks the other.

apps/web/app/(ee)/api/partners/ban/route.ts (1)

14-43: LGTM!

Good improvements:

  1. Using parseRequestBody provides consistent error handling for malformed JSON.
  2. The explicit findUnique + manual not-found check gives better control over error messages.
  3. Selecting only partnerId is efficient.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-event-groups-table.tsx (2)

85-100: LGTM!

Good state management and modal integration. The onConfirm callback properly resets row selection after a successful bulk ban.


256-269: LGTM!

The Ban button integration with the modal flow is clean. Setting pendingBanPartners before showing the modal ensures the modal has the correct partner list.

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

569-612: Well-structured bulk actions menu component.

The BulkActionsMenu component follows the existing patterns in this file (similar to RowMenuButton) and correctly extracts selected partners from the table's row model before invoking the callback.


494-502: LGTM!

Correctly hides the bulk ban action when viewing banned partners, preventing redundant operations.

apps/web/lib/actions/partners/bulk-ban-partners.ts (2)

86-97: Clean delegation to async processing.

The batch job enqueueing with proper deduplication IDs ensures that post-ban processing (emails, cache invalidation, commission cancellation) happens asynchronously without blocking the UI, while preventing duplicate processing.


50-65: LGTM!

The atomic updateMany correctly sets the banned status, timestamp, reason, and clears all reward/discount associations in a single database operation.

apps/web/app/(ee)/api/cron/partners/ban/process/route.ts (2)

66-112: LGTM!

The transactional batch update is well-structured. All four updateMany operations are idempotent, making this safe for potential retries.


55-59: Reasonable status guard, but doesn't prevent retry side-effects.

This check correctly validates the partner is still banned before processing. However, on QStash retry, since the status remains "banned", all operations will re-execute. The updateMany operations are idempotent, but createFraudEvents and sendEmail are not.

Verify whether duplicate emails could be sent on retry. If sendEmail is not idempotent, consider tracking email delivery or using a deduplication mechanism.

apps/web/ui/modals/bulk-ban-partners-modal.tsx (2)

222-255: LGTM!

The hook follows the established pattern used by other modal hooks in the codebase (e.g., useChangeGroupModal), with proper memoization of the callback component.


94-219: Well-designed modal UI with proper safety guards.

The implementation includes:

  • Clear partner preview with avatar stacking for bulk selection
  • Informative warning text about irreversible consequences
  • Required ban reason selection
  • Confirmation text input to prevent accidental bans

Comment on lines +11 to +62
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));
}
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants