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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6fd2b9b
WIP email template
devkiran Nov 25, 2025
d79d4ed
Implement daily fraud events summary email
devkiran Nov 25, 2025
ca329e6
Update the empty state
devkiran Nov 25, 2025
1d8a849
Updated logic to reflect pending fraud status in the AmountRowItem co…
devkiran Nov 25, 2025
6b3972b
Add e2e tests
devkiran Nov 25, 2025
ba5f9ef
Fraud event detection by deduplicating pending events
devkiran Nov 25, 2025
8d55624
Add script to remove duplicate pending fraud events
devkiran Nov 25, 2025
21205ed
Update vercel.json
devkiran Nov 25, 2025
a0ad9bd
Update partner-application-risk-summary.tsx
devkiran Nov 25, 2025
f8ff466
Add upsell state
devkiran Nov 25, 2025
7303686
Update partner-application-risk-summary.tsx
devkiran Nov 25, 2025
a3ba601
Merge branch 'main' into fraud-more-changes
devkiran Nov 25, 2025
a64799b
Update partner-application-risk-summary.tsx
devkiran Nov 25, 2025
fb2551d
Merge branch 'fraud-more-changes' of https://github.com/dubinc/dub in…
devkiran Nov 25, 2025
b72bb76
Update vercel.json
devkiran Nov 25, 2025
d463e1d
Update get-highest-severity.ts
devkiran Nov 25, 2025
ca44378
Update partner-application-risk-summary.tsx
devkiran Nov 25, 2025
976af89
Update partner-application-risk-summary.tsx
devkiran Nov 25, 2025
89d4e5b
add composite index
steven-tey Nov 25, 2025
fd1cc83
fix tests (added test-hostname-for-referral-source-banned-do-not-dele…
steven-tey Nov 25, 2025
9633e45
fix tests again
steven-tey Nov 25, 2025
49b97e9
increase wait duration to reduce flakiness
steven-tey Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions apps/web/app/(ee)/api/cron/fraud/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { FRAUD_RULES_BY_TYPE } from "@/lib/api/fraud/constants";
import { getGroupedFraudEvents } from "@/lib/api/fraud/get-grouped-fraud-events";
import { getWorkspaceUsers } from "@/lib/api/get-workspace-users";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
import { queueBatchEmail } from "@/lib/email/queue-batch-email";
import type UnresolvedFraudEventsSummary from "@dub/email/templates/unresolved-fraud-events-summary";
import { prisma } from "@dub/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { format, startOfDay } from "date-fns";
import { z } from "zod";
import { logAndRespond } from "../../utils";

export const dynamic = "force-dynamic";

const PROGRAMS_BATCH_SIZE = 5;

const schema = z.object({
startingAfter: z.string().optional(),
});

async function handler(req: Request) {
try {
let { startingAfter } = schema.parse({});

if (req.method === "GET") {
await verifyVercelSignature(req);
} else if (req.method === "POST") {
const rawBody = await req.text();
await verifyQstashSignature({
req,
rawBody,
});

({ startingAfter } = schema.parse(JSON.parse(rawBody)));
}

// Get batch of programs with unresolved fraud events
const programs = await prisma.program.findMany({
where: {
fraudEvents: {
some: {
status: "pending",
// Only include programs with fraud events created today so daily summaries
createdAt: {
gte: startOfDay(new Date()),
},
},
},
},
select: {
id: true,
name: true,
slug: true,
workspace: {
select: {
slug: true,
},
},
},
take: PROGRAMS_BATCH_SIZE,
...(startingAfter && {
skip: 1,
cursor: {
id: startingAfter,
},
}),
orderBy: {
id: "asc",
},
});

if (programs.length === 0) {
return logAndRespond(
"No more programs found to send fraud events summary.",
);
}

const batchDate = format(new Date(), "yyyy-MM-dd");

for (const program of programs) {
try {
const fraudEvents = await getGroupedFraudEvents({
programId: program.id,
status: "pending",
page: 1,
pageSize: 6,
sortBy: "createdAt",
sortOrder: "asc",
});

if (fraudEvents.length === 0) {
continue;
}

const transformedFraudEvents = fraudEvents.map(
({ type, count, groupKey, partner }) => ({
name: FRAUD_RULES_BY_TYPE[type].name,
count,
groupKey,
partner,
}),
);

// Get workspace users to send the email to
const { users } = await getWorkspaceUsers({
role: "owner",
programId: program.id,
notificationPreference: "fraudEventsSummary",
});

if (users.length === 0) {
continue;
}

await queueBatchEmail<typeof UnresolvedFraudEventsSummary>(
users.map((user) => ({
to: user.email,
subject: `Fraud events pending review for ${program.name}`,
variant: "notifications",
templateName: "UnresolvedFraudEventsSummary",
templateProps: {
email: user.email,
workspace: program.workspace,
program,
fraudEvents: transformedFraudEvents,
},
})),
{
idempotencyKey: `fraud-events-summary/${program.id}/${batchDate}`,
},
);
} catch (error) {
console.error(
`Error collecting email payloads for program ${program.id}: ${error.message}`,
);
continue;
}
}

if (programs.length === PROGRAMS_BATCH_SIZE) {
startingAfter = programs[programs.length - 1].id;

await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/fraud/summary`,
method: "POST",
body: {
startingAfter,
},
});

return logAndRespond(
`Scheduled next batch for fraud events summary (startingAfter: ${startingAfter})`,
);
}

return logAndRespond("Finished sending fraud events summary.");
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}

// GET/POST /api/cron/fraud/summary
export { handler as GET, handler as POST };
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FRAUD_RULES } from "@/lib/api/fraud/constants";
import { getPartnerHighRiskSignals } from "@/lib/api/fraud/get-partner-high-risk-signals";
import { checkPartnerEmailDomainMismatch } from "@/lib/api/fraud/rules/check-partner-email-domain-mismatch";
import { checkPartnerEmailMasked } from "@/lib/api/fraud/rules/check-partner-email-masked";
Expand All @@ -6,41 +7,52 @@ import { checkPartnerNoVerifiedSocialLinks } from "@/lib/api/fraud/rules/check-p
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { withWorkspace } from "@/lib/auth";
import { getHighestSeverity } from "@/lib/get-highest-severity";
import { getPlanCapabilities } from "@/lib/plan-capabilities";
import { ExtendedFraudRuleType } from "@/lib/types";
import { NextResponse } from "next/server";

// GET /api/partners/:partnerId/application-risks - get application risks for a partner
export const GET = withWorkspace(
async ({ workspace, params }) => {
const { partnerId } = params;
const programId = getDefaultProgramIdOrThrow(workspace);

const { partner } = await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {
partner: true,
},
export const GET = withWorkspace(async ({ workspace, params }) => {
const { partnerId } = params;
const programId = getDefaultProgramIdOrThrow(workspace);

const { partner } = await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {
partner: true,
},
});

const { hasCrossProgramBan, hasDuplicatePayoutMethod } =
await getPartnerHighRiskSignals({
program: { id: programId },
partner,
});

const { hasCrossProgramBan, hasDuplicatePayoutMethod } =
await getPartnerHighRiskSignals({
program: { id: programId },
partner,
});

const risks: Partial<Record<ExtendedFraudRuleType, boolean>> = {
partnerCrossProgramBan: hasCrossProgramBan,
partnerDuplicatePayoutMethod: hasDuplicatePayoutMethod,
partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner),
partnerEmailMasked: checkPartnerEmailMasked(partner),
partnerNoSocialLinks: checkPartnerNoSocialLinks(partner),
partnerNoVerifiedSocialLinks: checkPartnerNoVerifiedSocialLinks(partner),
};

return NextResponse.json(risks);
},
{
requiredPlan: ["advanced", "enterprise"],
},
);
const risksDetected: Partial<Record<ExtendedFraudRuleType, boolean>> = {
partnerCrossProgramBan: hasCrossProgramBan,
partnerDuplicatePayoutMethod: hasDuplicatePayoutMethod,
partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner),
partnerEmailMasked: checkPartnerEmailMasked(partner),
partnerNoSocialLinks: checkPartnerNoSocialLinks(partner),
partnerNoVerifiedSocialLinks: checkPartnerNoVerifiedSocialLinks(partner),
};

const detectedRisksInfo = FRAUD_RULES.filter((rule) => {
return risksDetected[rule.type] === true;
});

const { canManageFraudEvents } = getPlanCapabilities(workspace.plan);

// Calculate the highest severity level from all detected risks
const riskSeverity = getHighestSeverity(detectedRisksInfo);

// Return the response with risksDetected only if the workspace has fraud management capabilities,
// otherwise return an empty object to hide sensitive fraud detection details
return NextResponse.json({
risksDetected: canManageFraudEvents ? risksDetected : {},
riskSeverity,
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export function CommissionTable() {
? groups?.find((g) => g.id === row.original.partner.groupId)
: undefined,
commission: row.original,
partner: row.original.partner,
})}
>
{badge.label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fraudEventGroupProps } from "@/lib/types";
import { useBanPartnerModal } from "@/ui/modals/ban-partner-modal";
import { FraudReviewSheet } from "@/ui/partners/fraud-risks/fraud-review-sheet";
import { PartnerRowItem } from "@/ui/partners/partner-row-item";
import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state";
import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row";
import {
AnimatedSizeContainer,
Expand All @@ -22,12 +23,11 @@ import {
useRouterStuff,
useTable,
} from "@dub/ui";
import { Dots, UserDelete } from "@dub/ui/icons";
import { Dots, ShieldAlert, UserDelete } from "@dub/ui/icons";
import { cn, formatDateTimeSmart } from "@dub/utils";
import { Row } from "@tanstack/react-table";
import { Command } from "cmdk";
import { useEffect, useMemo, useState } from "react";
import { FraudEventsEmptyState } from "./fraud-events-empty-state";
import { useFraudEventsFilters } from "./use-fraud-events-filters";

export function FraudEventGroupsTable() {
Expand Down Expand Up @@ -303,7 +303,19 @@ export function FraudEventGroupsTable() {
{fraudEvents?.length !== 0 ? (
<Table {...tableProps} table={table} />
) : (
<FraudEventsEmptyState />
<AnimatedEmptyState
title="No pending fraud to review"
description="There aren't any unresolved fraud events waiting for action right now."
cardContent={() => (
<>
<ShieldAlert className="size-4 text-neutral-700" />
<div className="h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200" />
</>
)}
learnMoreHref="https://dub.co/help/article/fraud-detection"
learnMoreTarget="_blank"
learnMoreText="Learn more"
/>
)}
</div>
);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function FraudReferralSourceSettings({
<div key={index} className="group relative w-full">
<input
type="text"
placeholder="https://www.reddit.com"
placeholder="reddit.com"
value={domain}
disabled={isDisabled}
onChange={(e) => updateDomain(index, e.target.value)}
Expand Down
Loading
Loading