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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Dec 1, 2025

Summary by CodeRabbit

  • New Features

    • Fraud is now grouped into “fraud groups” with per-group counts, details, and bulk-resolve workflows.
    • New modals: bulk-resolve fraud groups, bulk-reject partner applications, and per-partner reject modal.
    • Automatic duplicate-payout detection with email notifications to affected partners.
  • Improvements

    • Dashboard tables, banners and payout indicators show group-based fraud data and accurate pending counts.
    • Cleaner deduplication and grouping for clearer alerts, migration tooling, and improved resolution flows.

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

…event types across multiple files, enhancing consistency and maintainability. Update Prisma schema to include relationships for fraud events and groups.
@vercel
Copy link
Contributor

vercel bot commented Dec 1, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Dec 5, 2025 5:25am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 1, 2025

Walkthrough

Migrate fraud from event-level to group-level: introduce FraudEventGroup and per-event FraudEvent, implement hash-based dedupe/grouping, replace event resolution with group resolution, update Prisma schema, APIs, detectors, webhooks, actions, UI, migrations, hooks, and add modals/scripts.

Changes

Cohort / File(s) Summary
Prisma schema
packages/prisma/schema/fraud.prisma, packages/prisma/schema/program.prisma, packages/prisma/schema/partner.prisma, packages/prisma/schema/schema.prisma, packages/prisma/schema/commission.prisma
Add FraudEventGroup model, split FraudEvent into group-aware shape, replace fraudEvents relations with fraudEventGroups, add indexes and new fields, remove fraudEvents from Commission.
Core fraud API & utils
apps/web/lib/api/fraud/create-fraud-events.ts, apps/web/lib/api/fraud/resolve-fraud-groups.ts, apps/web/lib/api/fraud/get-grouped-fraud-events.ts (removed), apps/web/lib/api/fraud/get-fraud-events-count.ts (removed), apps/web/lib/api/fraud/utils.ts, apps/web/lib/api/create-id.ts
New hash-based dedupe/group creation in createFraudEvents, added resolveFraudGroups (returns count), removed legacy grouped SQL/count helpers, added hashing/identity/composite helpers, and added "frg_" id prefix.
Detectors & creation callsites
apps/web/lib/api/fraud/detect-record-fraud-event.ts, apps/web/lib/api/fraud/detect-record-fraud-application.ts, apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts
Callsites now use createFraudEvents, include richer metadata, use FraudRuleType enums, and emit per-duplicate events (new duplicate-payout detector).
Stripe webhook & email
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts, packages/email/src/templates/unresolved-fraud-events-summary.tsx
Duplicate-payout detection integrated; email templates updated to accept fraudGroups (id/name/count/partner); success/duplicate notification flows added.
API routes (group-centric)
apps/web/app/(ee)/api/fraud/groups/route.ts, apps/web/app/(ee)/api/fraud/groups/count/route.ts, apps/web/app/(ee)/api/fraud/events/route.ts, apps/web/app/(ee)/api/cron/fraud/summary/route.ts, apps/web/app/(ee)/api/cron/partners/ban/process/route.ts
New /api/fraud/groups list/count endpoints; /api/fraud/events updated for groupId and special-case handling; cron/ban routes and queries now target fraudEventGroups and use Prisma enums.
Removed event-level routes
apps/web/app/(ee)/api/fraud/events/count/route.ts (removed), apps/web/app/(ee)/api/fraud/events/raw/route.ts (removed)
Deleted raw and count event endpoints replaced by group APIs.
Resolve actions & partner flows
apps/web/lib/actions/fraud/*, apps/web/lib/actions/partners/*
Replace resolveFraudEvents with resolveFraudGroups; add bulkResolveFraudGroupsAction, bulkRejectPartnerApplicationsAction; remove old bulkRejectPartnersAction; add guards, comments side-effects, enum usage updates.
Schemas & types
apps/web/lib/zod/schemas/fraud.ts, apps/web/lib/zod/schemas/partners.ts, apps/web/lib/types.ts
Rename grouped schema → fraudGroupSchema, add fraudEventQuerySchema, fraudGroupCountSchema, resolve/bulk-resolve schemas, CreateFraudEventInput; add reportFraud to bulkReject schema; rename count/types to group variants.
SWR hooks & UI wiring
apps/web/lib/swr/* (use-fraud-groups.ts, use-fraud-groups-count.ts, use-fraud-events.ts), many UI files under apps/web/app/app.dub.co/..., apps/web/ui/*
Hooks renamed to group-centric variants and endpoints; widespread UI prop/type renames (fraudEventGroupfraudGroup, groupKeyid/groupId, counteventCount, lastOccurrenceAtlastEventAt); tables, sheets, navigation and SWR keys updated.
Modals & UX
apps/web/ui/modals/* (new/modified)
Add BulkResolveFraudGroupsModal, BulkRejectPartnersModal, RejectPartnerApplicationModal and hooks; replace inline reject flows with modal-driven workflows; update BulkBan modal to partition pending vs non-pending.
Migrations & scripts
apps/web/scripts/migrations/*.ts, apps/web/scripts/seed-fraud-events.ts
Add migration scripts to create groups from existing events, migrate duplicate-payout groups, backfill hashes, cleanup duplicates, and a seed script for duplicate-payout detection.
Tests
apps/web/tests/fraud/index.test.ts
Tests updated to new fraudEvent / fraudGroup shapes and schemas; waitForFraudEvent signature and assertions adjusted.
Utilities
packages/utils/src/functions/pretty-print.ts, packages/utils/src/functions/index.ts
Add prettyPrint util and re-export.
Misc small changes
apps/web/app/(ee)/api/cron/fraud/summary/route.ts, apps/web/app/(ee)/api/cron/partners/ban/process/route.ts, apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts, various UI files
Enum usage, programId selects, email payload wiring, mutatePrefix updates, and small adjustments to align with group model and new APIs.

Sequence Diagram(s)

sequenceDiagram
  participant Stripe as Stripe (Webhook)
  participant Server as App Server
  participant DB as Prisma
  participant Email as Email Service
  Note over Stripe,Server: Duplicate payout detection & group creation
  Stripe->>Server: account.updated (payout method)
  Server->>DB: find partners by payoutMethodHash (incl. enrollments)
  DB-->>Server: matching partners + program context
  Server->>Server: build per-partner per-program events (hash, metadata)
  Server->>DB: create/reuse FraudEventGroup(s) and create FraudEvent(s)
  DB-->>Server: created/updated groups/events
  alt no duplicates & first connect
    Server->>Email: send "connected payout method" email
  else duplicates detected
    Server->>Email: send DuplicatePayoutMethod batch emails
  end
  Email-->>Server: ack
  Server-->>Stripe: 200 OK
Loading
sequenceDiagram
  participant UI as Browser (Admin)
  participant Server as App Server
  participant DB as Prisma
  Note over UI,Server: Bulk resolve fraud groups
  UI->>Server: POST /action bulkResolveFraudGroups (groupIds, reason)
  Server->>DB: update FraudEventGroup where ids -> status="resolved", set resolvedAt, userId, reason
  DB-->>Server: affected count
  Server->>DB: create comments per unique partner (if reason supplied)
  DB-->>Server: ack
  Server-->>UI: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Files/areas needing extra attention:
    • apps/web/lib/api/fraud/create-fraud-events.ts — hash logic, dedupe, group reuse, id generation, migration compatibility, concurrency.
    • apps/web/lib/api/fraud/resolve-fraud-groups.ts and all callers — count return semantics vs prior returns, side-effects (comments/audits).
    • Prisma schema changes + migration scripts — transactional correctness, batch updates, legacy-field handling.
    • Stripe webhook/account-updated path — duplicate detection correctness, email batching, and side-effects.
    • Widespread UI/hook/type renames — SWR keys, route/query parameter consistency (groupId vs groupKey) and component prop updates.

Possibly related PRs

Poem

🐰
I hopped and found the noisy trail, then stitched each event in a tail.
Hashes hummed and groups took shape, migrations danced — no frantic scrape.
Modals chimed and emails flew, grouped and tidy, all anew.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.71% 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 'Refactor Fraud management' clearly and concisely describes the primary change—a comprehensive refactoring of fraud management systems. It directly summarizes the main objective without unnecessary detail, emojis, or vague terms.
✨ 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 fraud-event-v2

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.

… count API and introducing new endpoints for fraud event groups
…eprecated endpoints and enhancing error handling. Update related schemas and hooks for consistency.
…ming consistency. Introduce new FraudEventGroupTable and ResolvedFraudEventGroupTable components, replacing deprecated ones. Update imports and schemas for fraud group counts across multiple files.
…s with resolveFraudEventGroups across multiple files. Update related schemas, actions, and components to support group-based resolution of fraud events, enhancing consistency and maintainability.
… related schemas, components, and email templates for improved clarity and functionality.
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 (1)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)

274-295: Clarify the “no other partners share this payout method” check and verify handling of source-partner fraud groups

The automatic resolution of partnerDuplicatePayoutMethod fraud groups after a merge is a good fit for the partner-scoped rule and helps keep fraud groups in sync with the underlying data. To make the uniqueness condition more explicit and future‑proof, consider counting only other partners that share the same payoutMethodHash and checking for zero:

-    if (targetAccount.payoutMethodHash) {
-      const duplicatePartners = await prisma.partner.count({
-        where: {
-          payoutMethodHash: targetAccount.payoutMethodHash,
-        },
-      });
-
-      if (duplicatePartners <= 1) {
+    if (targetAccount.payoutMethodHash) {
+      const otherPartnersWithSamePayoutMethod = await prisma.partner.count({
+        where: {
+          payoutMethodHash: targetAccount.payoutMethodHash,
+          id: { not: targetPartnerId },
+        },
+      });
+
+      if (otherPartnersWithSamePayoutMethod === 0) {
         await resolveFraudGroups({
           where: {
             partnerId: targetPartnerId,
             type: "partnerDuplicatePayoutMethod",
           },
           resolutionReason:
             "Automatically resolved because partners with duplicate payout methods were merged. No other partners share this payout method.",
         });
       }
     }

Also, please double‑check that any fraudEventGroup rows tied to the deleted sourcePartnerId are either cascaded away or handled elsewhere so we don’t leave stale pending groups pointing at a non‑existent partner.

Based on learnings, this is consistent with the partner-scoped partnerDuplicatePayoutMethod rule behavior.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 17fe0a3 and 0894732.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (3 hunks)
  • apps/web/lib/api/fraud/resolve-fraud-groups.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/api/fraud/resolve-fraud-groups.ts
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.
📚 Learning: 2025-12-03T09:19:48.164Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.

Applied to files:

  • apps/web/app/(ee)/api/cron/merge-partner-accounts/route.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/app/(ee)/api/cron/merge-partner-accounts/route.ts
📚 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/merge-partner-accounts/route.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/app/(ee)/api/cron/merge-partner-accounts/route.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/app/(ee)/api/cron/merge-partner-accounts/route.ts
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (2)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/fraud/resolve-fraud-groups.ts (1)
  • resolveFraudGroups (4-27)
⏰ 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 (2)
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (2)

2-2: Import of resolveFraudGroups is appropriate and consistent with the new fraud group model

The added import is used in the post-merge cleanup and aligns with the group-based fraud resolution utilities introduced in this PR.


63-63: Selecting payoutMethodHash is necessary for downstream fraud resolution

Including payoutMethodHash in the partner selection is appropriate so the later uniqueness check can decide whether to auto-resolve duplicate-payout-method fraud groups; no issues here.

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 (1)
apps/web/lib/api/fraud/detect-record-fraud-application.ts (1)

49-79: Clarify duplicate payout-method semantics and tighten query / naming

The move to a program-scoped duplicate check via programs: { some: { programId: program.id } } and emitting one partnerDuplicatePayoutMethod rule per duplicatePartnerId is a good fit for the new grouped-event flow, but there are two small points worth tightening:

  1. Possible self-inclusion in duplicatePartners
    Depending on when this runs relative to enrollment creation, the current partner could satisfy the programs: { some: { programId } } condition and end up in duplicatePartners. That would cause a “duplicate payout method” signal even when only a single account exists for this payout method in the program. If the intent is “same payout method shared across different partners in this program”, consider explicitly excluding the current partner from the query:

    const duplicatePartners = await prisma.partner.findMany({
      where: {
        payoutMethodHash: partner.payoutMethodHash,
        id: { not: partner.id },
        programs: {
          some: { programId: program.id },
        },
      },
      select: {
        id: true,
      },
    });

    The rest of the logic (duplicatePartnerIds including partner.id) can stay unchanged.

  2. Minor readability nit on variable shadowing
    In duplicatePartners.map((partner) => partner.id), the parameter name shadows the outer partner. Renaming the inner variable (e.g. p or duplicatePartner) would reduce cognitive load without behavior change.

Together these keep the semantics closer to “cross-account” duplicates and make the code slightly easier to reason about. Based on learnings, this matters since this file is the canonical implementation point for these partner-scoped fraud rules.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0894732 and 56df7ea.

📒 Files selected for processing (1)
  • apps/web/lib/api/fraud/detect-record-fraud-application.ts (3 hunks)
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.
📚 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/api/fraud/detect-record-fraud-application.ts
📚 Learning: 2025-12-03T09:19:48.164Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.

Applied to files:

  • apps/web/lib/api/fraud/detect-record-fraud-application.ts
📚 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/lib/api/fraud/detect-record-fraud-application.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/lib/api/fraud/detect-record-fraud-application.ts
🧬 Code graph analysis (1)
apps/web/lib/api/fraud/detect-record-fraud-application.ts (2)
packages/prisma/client.ts (1)
  • FraudRuleType (17-17)
apps/web/lib/api/fraud/create-fraud-events.ts (1)
  • createFraudEvents (12-154)
⏰ 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 (1)
apps/web/lib/api/fraud/detect-record-fraud-application.ts (1)

3-3: Type-safe rule typing and metadata propagation into createFraudEvents look solid

Wiring triggeredRules as Pick<FraudEvent, "type" | "metadata">[], using FraudRuleType.* enums, and then spreading ...rule into createFraudEvents with explicit programId/partnerId keeps this path aligned with the Prisma schema and the new grouped fraud-event pipeline. The cross-program ban rule carrying metadata: null is also consistent with a JSON/nullable metadata field and the metadata ?? undefined handling in createFraudEvents. Based on learnings, this keeps the partner-scoped implementations for partnerCrossProgramBan and partnerDuplicatePayoutMethod correctly localized here while the registry stubs remain type-satisfying only.

Also applies to: 26-26, 42-44, 83-88

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (1)

264-270: Fix null partner handling before Map creation.

The partners array may contain null values (as evidenced by the NonNullable wrapper on line 89 and the null check on line 437), but the Map creation on line 269 accesses p.id without filtering nulls first. This will throw a runtime error if any selected row has a null partner.

Apply this diff to filter null partners before deduplication:

       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<FraudGroupProps["partner"]> => p !== null,
+        );
       const selectedFraudGroups = selectedRows.map((row) => row.original);
 
       // Remove duplicates by partner ID
       const uniquePartners = Array.from(
         new Map(partners.map((p) => [p.id, p])).values(),
       );

Note: Lines 294-300 already apply this filtering correctly in the "Ban partners" button's onClick handler.

🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (1)

294-304: Consider extracting duplicate partner extraction logic.

The partner extraction and deduplication logic is duplicated between lines 264-270 and 294-304. Once the null filtering is added to lines 264-270, consider storing the filtered uniquePartners earlier and reusing it here to reduce code duplication.

Example refactor:

const partners = selectedRows
  .map((row) => row.original.partner)
  .filter((p): p is NonNullable<FraudGroupProps["partner"]> => p !== null);
const uniquePartners = Array.from(
  new Map(partners.map((p) => [p.id, p])).values(),
);

// ... later in Ban Partners button onClick:
onClick={() => {
  setPendingBanPartners(uniquePartners);
  setShowBulkBanPartnersModal(true);
}}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7a74164 and 65b3a64.

📒 Files selected for processing (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (10 hunks)
🧰 Additional context used
🧠 Learnings (11)
📓 Common learnings
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.
📚 Learning: 2025-12-03T09:19:48.164Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3175
File: apps/web/lib/actions/partners/bulk-reject-partner-applications.ts:14-21
Timestamp: 2025-12-03T09:19:48.164Z
Learning: In apps/web/lib/actions/partners/bulk-reject-partner-applications.ts, the bulkRejectPartnerApplicationsAction does not need explicit plan capability checks for fraud operations (when reportFraud is true) because the authorization is handled automatically by the underlying fraud operation functions (resolveFraudGroups, createFraudEvents) or through other automated mechanisms in the system.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-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-group-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-group-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-group-table.tsx
📚 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-group-table.tsx
📚 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-group-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-group-table.tsx
📚 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-group-table.tsx
📚 Learning: 2025-11-17T05:19:11.972Z
Learnt from: devkiran
Repo: dubinc/dub PR: 3113
File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:65-75
Timestamp: 2025-11-17T05:19:11.972Z
Learning: In the Dub codebase, `sendBatchEmail` (implemented in packages/email/src/send-via-resend.ts) handles filtering of emails with invalid `to` addresses internally. Call sites can safely use non-null assertions on email addresses because the email sending layer will filter out any entries with null/undefined `to` values before sending. This centralized validation pattern is intentional and removes the need for filtering at individual call sites.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-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-group-table.tsx
⏰ 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 (8)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (8)

5-11: LGTM: Import updates align with the fraud group refactor.

The renamed hooks (useFraudGroups, useFraudGroupCount) and new modal imports (useBulkResolveFraudGroupsModal, useRejectPartnerApplicationModal) correctly reflect the shift from event-based to group-based fraud management.


37-86: LGTM: Core refactor properly migrates from event groups to groups.

The component rename, hook updates (useFraudGroupFilters, useFraudGroups, useFraudGroupCount), and state management shift from groupKey to groupId are consistent and correct. The exclude: ["groupId"] parameter on line 60 appropriately prevents groupId filtering in the main table query while allowing detail-level fetches to use it (line 546).


88-116: LGTM: Modal wiring updated for group-based operations.

The new BulkResolveFraudGroupsModal follows the same pattern as the ban modal, and both correctly mutate /api/fraud/groups on confirmation. Type updates to FraudGroupProps are consistent throughout.


118-313: Table configuration correctly migrated to group-based model.

The table column definitions, data bindings, and row actions are properly updated to use FraudGroupProps with renamed fields (eventCount, lastEventAt, id). The resource naming and pagination are correct.


315-329: LGTM: Navigation logic correctly uses id-based indexing.

The previous/next navigation properly migrates from groupKey to id for finding the current index and navigating between fraud groups.


331-411: LGTM: Render section properly integrated with group-based data.

Modal rendering, details sheet wiring, and empty state checks correctly use the renamed data sources (fraudGroups, fraudGroupCount, groupId) and props (fraudGroup). Navigation callbacks properly reference groupId for previous/next actions.


414-488: LGTM: Row menu properly handles partner status and null cases.

The RowMenuButton correctly integrates the new RejectPartnerApplicationModal for pending partners and maintains the ban flow for active partners. The null check on line 437 appropriately guards against rows without partners or non-pending statuses.


530-558: LGTM: Detail fetch hook efficiently handles loaded vs. missing groups.

The useCurrentFraudGroup hook correctly reuses fraud groups from the main list when available and falls back to a targeted fetch (by groupId) only when the selected group isn't loaded. The id-based lookups are consistent with the broader refactor.

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 5, 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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (1)

264-272: Fix null pointer crash in partner deduplication.

Line 264 maps partners without filtering nulls, then line 268-270 attempts to access .id on potentially null values. This will throw a runtime error if any selected fraud group has partner: null.

The correct pattern is already implemented in the "Ban partners" button (lines 295-300) and should be applied here.

Apply this diff to filter null partners before deduplication:

       const selectedRows = tableInstance.getSelectedRowModel().rows;
-      const partners = selectedRows.map((row) => row.original.partner);
       const selectedFraudGroups = selectedRows.map((row) => row.original);

+      const partners = selectedRows
+        .map((row) => row.original.partner)
+        .filter(
+          (p): p is NonNullable<FraudGroupProps["partner"]> => p !== null,
+        );
+
       // Remove duplicates by partner ID
       const uniquePartners = Array.from(
         new Map(partners.map((p) => [p.id, p])).values(),
       );
♻️ Duplicate comments (10)
packages/utils/src/functions/pretty-print.ts (2)

1-15: Previous circular reference concern remains unaddressed.

The earlier review flagged that JSON.stringify throws TypeError on circular references. This issue persists and should be resolved to prevent runtime crashes when debugging complex objects in fraud management workflows.


9-10: Previous stack trace exposure concern remains unaddressed.

The earlier review noted that unconditionally including stack in Error serialization may leak sensitive information in production logs. Given the fraud management context, this security concern should be addressed.

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

637-642: Align ban flow cache refresh behavior with bulk ban modal

BanPartnerModal’s onConfirm is async but doesn’t await mutatePrefix("/api/partners"), whereas BulkBanPartnersModal does. This keeps the inconsistency previously flagged and can cause the single-ban flow to close the modal before the list refresh completes, unlike bulk ban.

Consider restoring await mutatePrefix("/api/partners") here (or making both flows consistently fire-and-forget if that’s the desired UX):

  const { BanPartnerModal, setShowBanPartnerModal } = useBanPartnerModal({
    partner: row.original,
    onConfirm: async () => {
-      mutatePrefix("/api/partners");
+      await mutatePrefix("/api/partners");
    },
  });
apps/web/scripts/migrations/migrate-duplicate-payout-fraud-events.ts (2)

59-77: Per-partner FraudEventGroup metadata still derived from all events

Each FraudEventGroup for a partner is currently built from fraudEvents[0], fraudEvents[fraudEvents.length - 1], and fraudEvents.length, i.e. from the entire set of events for the groupKey, not just that partner’s events. This can give every partner’s group the same eventCount, timestamps, status, and resolution metadata, which is misleading if different partners have different numbers/timing/status of events.

Consider deriving these fields from partner-specific events instead:

for (const partnerId of uniquePartnerIds) {
  const partnerEvents = fraudEvents.filter((e) => e.partnerId === partnerId);
  const firstFraudEvent = partnerEvents[0];
  const lastFraudEvent = partnerEvents[partnerEvents.length - 1];

  fraudEventGroups.push({
    id: createId({ prefix: "frg_" }),
    programId: firstFraudEvent.programId,
    partnerId,
    type: "partnerDuplicatePayoutMethod",
    lastEventAt: lastFraudEvent.createdAt,
    eventCount: partnerEvents.length,
    userId: firstFraudEvent.userId,
    resolutionReason: firstFraudEvent.resolutionReason,
    resolvedAt: firstFraudEvent.resolvedAt,
    status: firstFraudEvent.status,
    createdAt: firstFraudEvent.createdAt,
    updatedAt: lastFraudEvent.createdAt,
  });
}

79-116: Group linking logic risks incorrect associations and lacks atomicity

Two remaining concerns in the linking section:

  1. First-partner updateMany is too broad

    The first branch:

    await prisma.fraudEvent.updateMany({
      where: {
        groupKey: fraudGroup.groupKey,
        fraudEventGroupId: null,
        type: "partnerDuplicatePayoutMethod",
      },
      data: {
        fraudEventGroupId: fraudEventGroups.find(
          (group) => group.partnerId === partnerId,
        )?.id,
      },
    });

    Updates all events for the groupKey to the first partner’s group, including events belonging to other partners. If the intent is that each partner’s group only “owns” its own events, you should filter by partnerId here.

  2. No transaction around createMany + update/createMany

    fraudEventGroup.createMany and the subsequent fraudEvent.updateMany / fraudEvent.createMany calls are not wrapped in a transaction. A failure after creating groups but before linking events (or mid-way through linking) can leave orphaned groups or partially-migrated data.

    Consider wrapping the per-groupKey work in a transaction:

    await prisma.$transaction(async (tx) => {
      // createMany on tx.fraudEventGroup
      // updateMany / createMany on tx.fraudEvent
    });
apps/web/app/(ee)/api/fraud/groups/count/route.ts (1)

28-71: Normalize Prisma groupBy results before Zod parsing (still mismatched).

As noted in the earlier review on this file, prisma.fraudEventGroup.groupBy({ _count: true, ... }) returns rows where _count is an object (e.g. { _all: number, ... }), but fraudGroupCountSchema expects _count to be a plain number. Pushing { _count: 0, type } into fraudGroups also mixes shapes.

Please normalize the results to the schema shape before validation, e.g.:

  • For groupBy === "type": map grouped rows to { type, _count: grouped.find(...)?._count._all ?? 0 } for every FraudRuleType.
  • For groupBy === "partnerId": map to { partnerId, _count: _count._all }.

This will make z.array(fraudGroupCountSchema).parse(...) succeed consistently.

apps/web/ui/modals/reject-partner-application-modal.tsx (1)

37-52: Potential duplicate toast notifications.

The onSuccess callback shows a toast (line 41-43) and then invokes onConfirm (line 46). If consuming components also show toasts in their onConfirm callbacks, users will see duplicate success messages.

Consider documenting that onConfirm should not show toasts since the modal handles feedback, or remove the toast from the modal and let callers handle it consistently.

apps/web/lib/api/fraud/create-fraud-events.ts (1)

137-153: lastEventAt is updated for groups with no new events; also O(G×N) counting.

The update loop runs over all finalGroups and always sets lastEventAt: new Date(), even when a group received zero new events (increment is 0). This incorrectly makes untouched groups look "recent" and performs unnecessary database writes. Additionally, the filter per group creates O(groups × events) complexity.

Fix both correctness and efficiency by aggregating counts by fraudEventGroupId and only updating affected groups:

- await Promise.allSettled(
-   finalGroups.map((group) =>
-     prisma.fraudEventGroup.update({
-       where: {
-         id: group.id,
-       },
-       data: {
-         lastEventAt: new Date(),
-         eventCount: {
-           increment: newEventsWithGroup.filter(
-             (e) => e.fraudEventGroupId === group.id,
-           ).length,
-         },
-       },
-     }),
-   ),
- );
+ const eventCountsByGroupId = newEventsWithGroup.reduce(
+   (map, event) => {
+     const groupId = event.fraudEventGroupId;
+     if (!groupId) return map;
+     map.set(groupId, (map.get(groupId) ?? 0) + 1);
+     return map;
+   },
+   new Map<string, number>(),
+ );
+
+ await Promise.allSettled(
+   Array.from(eventCountsByGroupId.entries()).map(([groupId, count]) =>
+     prisma.fraudEventGroup.update({
+       where: { id: groupId },
+       data: {
+         lastEventAt: new Date(),
+         eventCount: { increment: count },
+       },
+     }),
+   ),
+ );

This ensures only groups that actually received new events get their lastEventAt bumped.

apps/web/lib/api/fraud/utils.ts (2)

86-115: Inconsistent error handling for missing identity fields.

For customerEmailMatch, referralSourceBanned, etc., missing customerId throws an error. However, for partnerDuplicatePayoutMethod, missing duplicatePartnerId silently returns undefined in the record, which could produce inconsistent hashes.

Consider adding validation:

     case "partnerDuplicatePayoutMethod":
+      if (!eventMetadata?.duplicatePartnerId) {
+        throw new Error(`duplicatePartnerId is required for ${type} fraud rule.`);
+      }
       return {
-        duplicatePartnerId: eventMetadata?.duplicatePartnerId,
+        duplicatePartnerId: eventMetadata.duplicatePartnerId,
       };

117-131: Unsafe cast and mutation of metadata persists.

This was flagged in a past review. The function casts Prisma.JsonValue to Record<string, any> without validation and mutates the original object via delete.

Apply this fix to validate and avoid mutation:

 export function sanitizeFraudEventMetadata(
   metadata: Prisma.JsonValue | undefined,
 ) {
   if (!metadata) {
     return undefined;
   }

+  // Prisma.JsonValue can be primitive, array, or object - only handle objects
+  if (typeof metadata !== "object" || Array.isArray(metadata)) {
+    return metadata;
+  }
+
-  const sanitized = metadata as Record<string, any>;
+  const sanitized = { ...metadata } as Record<string, unknown>;

   delete sanitized.duplicatePartnerId;
   delete sanitized.payoutMethodHash;

   return Object.keys(sanitized).length > 0 ? sanitized : undefined;
 }
🧹 Nitpick comments (18)
packages/utils/src/functions/pretty-print.ts (1)

1-15: Consider optional improvements for production readiness.

A few refinements would improve this utility:

  1. Explicit return type: Add : string to the function signature for clarity.
  2. BigInt handling: JSON.stringify throws on BigInt values—consider adding a replacer case: if (typeof val === "bigint") return val.toString();
  3. JSDoc documentation: Since this is a public utility, document parameters and behavior for consumers.

Example enhancement:

+/**
+ * Pretty-print a value to JSON with support for special types.
+ * @param value - The value to serialize
+ * @param indent - Number of spaces for indentation (default: 2)
+ * @returns JSON string representation
+ */
-export function prettyPrint(value: any, indent = 2) {
+export function prettyPrint(value: any, indent = 2): string {
   return JSON.stringify(
     value,
     (_key, val) => {
+      if (typeof val === "bigint") return val.toString();
       if (val instanceof Set) return { __type: "Set", values: [...val] };
       // ... rest of replacer
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)

13-20: LGTM! Field name updated correctly.

The filter logic correctly migrates from fraudEvents to fraudEventGroups while preserving the filtering behavior (ensuring all fraud groups are resolved before payout eligibility).

Minor: Consider updating the comment on Line 13 to say "fraud groups" instead of "fraud events" for consistency with the new terminology.

apps/web/lib/api/fraud/resolve-fraud-groups.ts (1)

4-27: LGTM! Clean implementation of group-based resolution.

The function correctly:

  • Filters for status: "pending" to prevent double-resolution
  • Sets appropriate timestamps and resolution metadata
  • Uses updateMany for efficient bulk updates
  • Returns count for operation feedback

Optional consideration: In high-concurrency scenarios, multiple simultaneous resolution calls with overlapping where clauses could create race conditions (though Prisma's transaction isolation should handle this). If this becomes an issue, consider adding optimistic locking or transaction-level isolation controls.

apps/web/scripts/migrations/backfill-fraud-event-hashes.ts (1)

22-35: Improve error handling and progress logging.

The batch processing logic has several areas for improvement:

  1. No error handling: If any single update fails, the entire batch fails and the script aborts. This could leave the migration incomplete.
  2. No progress logging: Users won't know how far the migration has progressed for large datasets.
  3. Promise.all fails fast: One failure aborts the entire batch.

Apply this diff to add error handling and progress logging:

+  let processedCount = 0;
+  let errorCount = 0;
+
   for (const batch of chunks) {
-    await Promise.all(
+    const results = await Promise.allSettled(
       batch.map((event) =>
         prisma.fraudEvent.update({
           where: {
             id: event.id,
           },
           data: {
             hash: createFraudEventHash(event),
           },
         }),
       ),
     );
+
+    results.forEach((result, index) => {
+      if (result.status === 'fulfilled') {
+        processedCount++;
+      } else {
+        errorCount++;
+        console.error(`Failed to update event ${batch[index].id}:`, result.reason);
+      }
+    });
+
+    console.log(`Progress: ${processedCount}/${fraudEvents.length} processed, ${errorCount} errors`);
   }
 
-  console.log("Hash backfill completed.");
+  console.log(`Hash backfill completed. Successfully updated: ${processedCount}, Errors: ${errorCount}`);
apps/web/app/(ee)/api/cron/merge-partner-accounts/route.ts (1)

274-294: Good automation of fraud resolution post-merge.

The logic correctly auto-resolves partnerDuplicatePayoutMethod fraud groups when merging eliminates the duplicate payout method condition. The <= 1 check properly accounts for the target partner itself.

Consider using FraudRuleType.partnerDuplicatePayoutMethod instead of the string literal "partnerDuplicatePayoutMethod" on line 288 for consistency with other files in this PR (e.g., route.ts in the ban/process endpoint).

+import { FraudRuleType } from "@dub/prisma/client";
 import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups";
         await resolveFraudGroups({
           where: {
             partnerId: targetPartnerId,
-            type: "partnerDuplicatePayoutMethod",
+            type: FraudRuleType.partnerDuplicatePayoutMethod,
           },
apps/web/scripts/migrations/cleanup-fraud-events.ts (2)

36-43: Add type filter to deleteMany for defensive safety.

While the groupBy query filters by type: "partnerDuplicatePayoutMethod", the deleteMany only filters by groupKey. If groupKey semantics change or if there's data inconsistency, this could inadvertently delete unrelated fraud events.

Adding the type constraint makes the deletion more explicit and safer:

   for (const batch of chunks) {
-    await prisma.fraudEvent.deleteMany({
+    const result = await prisma.fraudEvent.deleteMany({
       where: {
         groupKey: {
           in: batch.map(({ groupKey }) => groupKey),
         },
+        type: "partnerDuplicatePayoutMethod",
       },
     });
+
+    console.log(`Deleted ${result.count} fraud events.`);
   }

106-114: Same issue: Add type filter to deleteMany in cleanupSamePartnerGroups.

Apply the same defensive fix here to ensure only partnerDuplicatePayoutMethod events are deleted:

   for (const batch of chunks) {
-    await prisma.fraudEvent.deleteMany({
+    const result = await prisma.fraudEvent.deleteMany({
       where: {
         groupKey: {
           in: batch.map(({ groupKey }) => groupKey),
         },
+        type: "partnerDuplicatePayoutMethod",
       },
     });
+
+    console.log(`Deleted ${result.count} fraud events.`);
   }
apps/web/scripts/migrations/migrate-fraud-events.ts (1)

68-81: Consider adding fraudEventGroupId: null filter to updateMany for extra safety.

The initial findMany correctly filters for fraudEventGroupId: null, ensuring idempotency. However, adding the same filter to updateMany provides an extra safeguard against edge cases where events might be updated by a concurrent process:

       batch.map(([groupKey, groupId]) =>
         prisma.fraudEvent.updateMany({
           where: {
             groupKey,
+            fraudEventGroupId: null,
           },
           data: {
             fraudEventGroupId: groupId,
           },
         }),
       ),
apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-paid-traffic-detected-table.tsx (1)

47-127: Paid-traffic source rendering and tooltip behavior look robust.

The migration to useFraudEvents with fraudEventSchemas["paidTrafficDetected"], the defensive metadata checks, and the new DynamicTooltipWrapper + HighlightedUrl flow all look correct and resilient to missing/invalid data. Nicely balances UX detail with safe fallbacks.

apps/web/lib/actions/fraud/bulk-resolve-fraud-groups.ts (1)

63-77: Consider atomicity for comment creation with resolution.

The comment creation is not atomic with resolveFraudGroups. If prisma.partnerComment.createMany fails after resolution succeeds, the state could be inconsistent (groups resolved but no comments).

This is likely acceptable given the nature of comments as supplementary data, but worth noting.

apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts (1)

42-76: Avoid generating “self-duplicate” payout method fraud events

In the nested loops, you create events for every enrolled partner, including the current partner itself:

for (const enrolledPartner of enrolledPartners) {
  fraudEvents.push({
    programId,
    partnerId: partner.id,
    type: FraudRuleType.partnerDuplicatePayoutMethod,
    metadata: {
      payoutMethodHash,
      duplicatePartnerId: enrolledPartner,
    },
  });
}

This produces events where metadata.duplicatePartnerId === partner.id, which doesn’t represent a true “duplicate” and unnecessarily inflates event volume. If the intent is to flag only other accounts sharing the payout method, filter out the self‑case before pushing:

for (const enrolledPartner of enrolledPartners) {
  if (enrolledPartner === partner.id) continue;

  fraudEvents.push({
    programId,
    partnerId: partner.id,
    type: FraudRuleType.partnerDuplicatePayoutMethod,
    metadata: {
      payoutMethodHash,
      duplicatePartnerId: enrolledPartner,
    },
  });
}

This keeps semantics clearer while still allowing symmetric A↔B events if desired.

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

131-137: Consider memoizing fraud counts by partnerId to avoid repeated scans

fraudGroupCount?.find(({ partnerId }) => partnerId === row.original.partner.id) is executed for every row, scanning the entire fraudGroupCount array each time. For large tables and many fraud groups this becomes O(rows × groups).

You can turn this into O(rows) with a small memoized map:

const fraudByPartner = useMemo(
  () =>
    new Map(
      (fraudGroupCount ?? []).map(({ partnerId, _count }) => [
        partnerId,
        _count,
      ]),
    ),
  [fraudGroupCount],
);

// ...

const partnerHasPendingFraud = fraudByPartner.get(
  row.original.partner.id,
);

Not required for correctness, but it keeps rendering cost predictable as datasets grow.

Also applies to: 272-283, 318-319

apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx (2)

85-96: Confirmation text could be more specific.

The confirmationText always says "confirm resolve events" (plural form when count > 1), but for a single event it would show "confirm resolve event" which is good. However, consider including the actual count in the confirmation text for added safety:

- const confirmationText = `confirm resolve ${eventWord}`;
+ const confirmationText = `confirm resolve ${totalEventCount} ${eventWord}`;

This prevents accidental bulk operations by making the user acknowledge the exact count.


205-219: Prefer useMemo over useCallback for memoizing JSX.

useCallback is semantically intended for memoizing function references. For JSX elements, useMemo is more appropriate and consistent with the pattern used in useResolveFraudGroupModal in the other modal file.

- const BulkResolveFraudGroupsModalCallback = useCallback(() => {
-   return (
+ const BulkResolveFraudGroupsModalCallback = useMemo(
+   () => (
      <BulkResolveFraudGroupsModal
        showBulkResolveFraudGroupsModal={showBulkResolveFraudGroupsModal}
        setShowBulkResolveFraudGroupsModal={setShowBulkResolveFraudGroupsModal}
        fraudGroups={fraudGroups}
        onConfirm={onConfirm}
      />
-   );
- }, [
+   ),
+   [
    showBulkResolveFraudGroupsModal,
-   setShowBulkResolveFraudGroupsModal,
    fraudGroups,
    onConfirm,
- ]);
+   ],
+ );

Also, setShowBulkResolveFraudGroupsModal is stable and doesn't need to be in the dependency array.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/resolve-fraud-group-modal.tsx (1)

57-62: Minor: groupId in defaultValues is redundant.

The groupId is set in defaultValues on line 60 but isn't used from form data—it's always explicitly passed from fraudGroup.id in onSubmit (line 72). Consider removing it from defaultValues to avoid confusion.

  } = useForm<FormData>({
    defaultValues: {
      resolutionReason: "",
-     groupId: fraudGroup.id,
    },
  });
packages/prisma/schema/fraud.prisma (1)

55-76: Document the migration timeline for deprecated fields.

The deprecated fields block has TODO comments indicating these should be removed after migration. Consider adding a more specific timeline or tracking issue reference to ensure cleanup happens.

-  // DEPRECATED FIELDS: TODO – remove after migration
+  // DEPRECATED FIELDS: TODO – remove after migration (tracked in issue #XXXX)
+  // Target removal: v2.x after backfill completes
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx (2)

427-437: Consider conditional modal instantiation for efficiency.

The RejectPartnerApplicationModal is instantiated even when partner might be null (line 431), though line 437 prevents rendering. While this works correctly, you could optimize by conditionally instantiating the modal only when needed:

const conditionalModal = partner?.status === "pending" 
  ? useRejectPartnerApplicationModal({ partner, onConfirm: async () => { ... } })
  : null;

However, the current implementation is safe and functional.


544-545: Minor: Rename variable for consistency with refactor.

The variable fetchedFraudEventGroups still contains "Event" from the old naming convention. For consistency with the group-based refactor, consider renaming to fetchedFraudGroups.

- const { fraudGroups: fetchedFraudEventGroups, loading: isLoading } =
+ const { fraudGroups: fetchedFraudGroups, loading: isLoading } =
     useFraudGroups({
       query: { groupId: groupId ?? undefined },
       enabled: Boolean(shouldFetch),
     });

- if (!currentFraudGroup && fetchedFraudEventGroups?.[0]?.id === groupId) {
-   currentFraudGroup = fetchedFraudEventGroups[0];
+ if (!currentFraudGroup && fetchedFraudGroups?.[0]?.id === groupId) {
+   currentFraudGroup = fetchedFraudGroups[0];
   }

@steven-tey steven-tey merged commit bed2391 into main Dec 5, 2025
8 of 9 checks passed
@steven-tey steven-tey deleted the fraud-event-v2 branch December 5, 2025 05:31
This was referenced Dec 5, 2025
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