diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/page.tsx new file mode 100644 index 00000000000..3ff39b6e128 --- /dev/null +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/page.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { fraudAlertSchema } from "@/lib/zod/schemas/fraud"; +import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { FraudAlertStatus } from "@dub/prisma/client"; +import { + Filter, + StatusBadge, + Table, + Tooltip, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { CircleDotted, GridIcon } from "@dub/ui/icons"; +import { fetcher, formatDateTime, OG_AVATAR_URL } from "@dub/utils"; +import { Suspense, useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; +import * as z from "zod/v4"; +import { ReviewFraudAlertSheet } from "./review-fraud-alert-sheet"; + +type FraudAlert = z.infer; + +const FRAUD_ALERT_STATUS_BADGES: Record< + FraudAlertStatus, + { label: string; variant: "pending" | "error" | "neutral" } +> = { + pending: { label: "Pending", variant: "pending" }, + confirmed: { label: "Confirmed", variant: "error" }, + dismissed: { label: "Dismissed", variant: "neutral" }, +}; + +export default function FraudAlertsPage() { + return ( + + + + ); +} + +function FraudAlertsPageClient() { + const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); + const { status, programId } = searchParamsObj; + + const [selectedAlert, setSelectedAlert] = useState(null); + + const { + data: { fraudAlerts, total } = {}, + isLoading, + mutate, + } = useSWR<{ + fraudAlerts: FraudAlert[]; + total: number; + }>(`/api/admin/fraud-alerts${getQueryString()}`, fetcher, { + keepPreviousData: true, + }); + + // Extract unique programs from fraud alerts for filter options + const programs = useMemo(() => { + if (!fraudAlerts) return []; + const programMap = new Map(); + + fraudAlerts.forEach((alert) => { + if (!programMap.has(alert.program.id)) { + programMap.set(alert.program.id, alert.program); + } + }); + + return Array.from(programMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + }, [fraudAlerts]); + + const filters = useMemo( + () => [ + { + key: "programId", + icon: GridIcon, + label: "Program", + options: + programs.map((program) => ({ + value: program.id, + label: program.name, + icon: ( + {`${program.name} + ), + })) ?? null, + }, + { + key: "status", + icon: CircleDotted, + label: "Status", + options: Object.entries(FRAUD_ALERT_STATUS_BADGES).map( + ([value, { label }]) => ({ + value, + label, + }), + ), + }, + ], + [programs], + ); + + const activeFilters = useMemo(() => { + return [ + ...(programId ? [{ key: "programId", value: programId }] : []), + ...(status ? [{ key: "status", value: status }] : []), + ]; + }, [programId, status]); + + const onSelect = useCallback( + (key: string, value: any) => + queryParams({ + set: { [key]: value }, + del: "page", + }), + [queryParams], + ); + + const onRemove = useCallback( + (key: string) => + queryParams({ + del: [key, "page"], + }), + [queryParams], + ); + + const onRemoveAll = useCallback( + () => + queryParams({ + del: ["status", "programId", "page"], + }), + [queryParams], + ); + + const { pagination, setPagination } = usePagination(50); + + const { table, ...tableProps } = useTable({ + data: fraudAlerts ?? [], + columns: [ + { + id: "partner", + header: "Partner", + cell: ({ row }) => ( +
+ +
+ + {row.original.partner.name} + + + {row.original.partner.email} + +
+
+ ), + }, + { + id: "program", + header: "Program", + cell: ({ row }) => ( +
+ {row.original.program.name} + {row.original.program.name} +
+ ), + }, + { + id: "reason", + header: "Fraud Reason", + cell: ({ row }) => ( + + + {row.original.reason} + + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const badge = FRAUD_ALERT_STATUS_BADGES[row.original.status]; + return ( + {badge.label} + ); + }, + }, + { + id: "createdAt", + header: "Flagged", + cell: ({ row }) => formatDateTime(row.original.createdAt), + }, + { + id: "reviewNote", + header: "Review Note", + cell: ({ row }) => { + const { reviewNote, reviewedBy } = row.original; + if (!reviewNote) return "-"; + + return ( + +

{reviewNote}

+ {reviewedBy && ( +

+ Reviewed by {reviewedBy.name} +

+ )} + + } + > + + {reviewNote} + +
+ ); + }, + }, + ], + pagination, + onPaginationChange: setPagination, + resourceName: (plural) => `fraud alert${plural ? "s" : ""}`, + rowCount: total ?? 0, + loading: isLoading, + }); + + return ( +
+

Fraud Alerts

+ +
+ +
+ + {activeFilters.length > 0 && ( +
+ +
+ )} + + { + setSelectedAlert(row.original); + }} + /> + + { + if (!open) setSelectedAlert(null); + }} + onReviewed={async () => { + await mutate(); + }} + /> + + ); +} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/review-fraud-alert-sheet.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/review-fraud-alert-sheet.tsx new file mode 100644 index 00000000000..bdcc4ffa855 --- /dev/null +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/fraud-alerts/review-fraud-alert-sheet.tsx @@ -0,0 +1,656 @@ +"use client"; + +import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; +import { PartnerPlatformProps } from "@/lib/types"; +import { CommissionSchema } from "@/lib/zod/schemas/commissions"; +import { fraudAlertSchema } from "@/lib/zod/schemas/fraud"; +import { + MAX_FRAUD_REASON_LENGTH, + PartnerSchema, +} from "@/lib/zod/schemas/partners"; +import { PayoutSchema } from "@/lib/zod/schemas/payouts"; +import { + ProgramEnrollmentSchema, + ProgramSchema, +} from "@/lib/zod/schemas/programs"; +import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; +import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; +import { FraudAlertStatus } from "@dub/prisma/client"; +import { + Button, + LoadingSpinner, + Sheet, + StatusBadge, + Table, + Tooltip, + useTable, +} from "@dub/ui"; +import { BadgeCheck2Fill, Xmark } from "@dub/ui/icons"; +import { + COUNTRIES, + currencyFormatter, + formatDateTime, + formatDateTimeSmart, + OG_AVATAR_URL, +} from "@dub/utils"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; +import * as z from "zod/v4"; + +type FraudAlert = z.infer; + +type ProgramInfo = Pick, "id" | "name" | "logo">; + +type PartnerDetail = z.infer & { + platforms: PartnerPlatformProps[]; + programEnrollments: (Pick< + z.infer, + "programId" | "status" | "bannedAt" | "bannedReason" + > & { + program: ProgramInfo; + })[]; + fraudAlerts: FraudAlert[]; + payouts: (z.infer & { + program: ProgramInfo; + })[]; + commissions: (Pick< + z.infer, + "id" | "earnings" | "currency" | "status" | "createdAt" + > & { + program: ProgramInfo; + })[]; +}; + +const FRAUD_ALERT_STATUS_BADGES: Record< + FraudAlertStatus, + { label: string; variant: "pending" | "error" | "neutral" } +> = { + pending: { label: "Pending", variant: "pending" }, + confirmed: { label: "Confirmed", variant: "error" }, + dismissed: { label: "Dismissed", variant: "neutral" }, +}; + +const BANNED_REASON_LABELS: Record = { + tos_violation: "TOS Violation", + inappropriate_content: "Inappropriate Content", + fake_traffic: "Fake Traffic", + fraud: "Fraud", + spam: "Spam", + brand_abuse: "Brand Abuse", +}; + +export function ReviewFraudAlertSheet({ + alert, + isOpen, + setIsOpen, + onReviewed, +}: { + alert: FraudAlert | null; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onReviewed: () => Promise; +}) { + return ( + + {alert && ( + + )} + + ); +} + +function SheetContent({ + fraudAlert, + setIsOpen, + onReviewed, +}: { + fraudAlert: FraudAlert; + setIsOpen: (open: boolean) => void; + onReviewed: () => Promise; +}) { + const [reviewNote, setReviewNote] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: partner, isLoading } = useSWR( + `/api/admin/partners/${fraudAlert.partner.id}`, + (url: string) => fetch(url).then((r) => r.json()), + ); + + const handleReview = async (action: "confirmed" | "dismissed") => { + if ( + !window.confirm( + `Are you sure you want to ${action.replace("ed", "")} this fraud alert?`, + ) + ) { + return; + } + setIsSubmitting(true); + try { + const response = await fetch(`/api/admin/fraud-alerts/${fraudAlert.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: action, + reviewNote, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text); + } + + setReviewNote(""); + setIsOpen(false); + await onReviewed(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Something went wrong", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Header */} +
+ Review Fraud Alert + +
+ + {/* Scrollable body */} +
+
+ {/* Current alert context */} +
+
+ {fraudAlert.program.name} + + {fraudAlert.program.name} + + + {formatDateTime(fraudAlert.createdAt)} + +
+

{fraudAlert.reason}

+
+ + {/* Partner info */} +
+ {isLoading ? ( + + ) : partner ? ( +
+
+
+ + {partner.country && ( + +
+ +
+
+ )} +
+
+ + {fraudAlert.partner.name} + + + {fraudAlert.partner.email} + +
+
+ +
+ Joined + + {formatDateTimeSmart(partner.createdAt)} + +
+ + {partner.platforms.length > 0 && ( +
+ {PARTNER_PLATFORM_FIELDS.map( + ({ label, icon: Icon, data: getPlatformData }) => { + const { value, href, verified } = getPlatformData( + partner.platforms, + ); + if (!value) return null; + return ( + + + {value} + {verified && ( + + )} + + } + > + + + {label} + {verified && ( + + )} + + + ); + }, + )} +
+ )} +
+ ) : null} +
+ + {/* Payouts table */} +
+ {isLoading ? ( + + ) : partner ? ( + + ) : null} +
+ + {/* Commissions table */} +
+ {isLoading ? ( + + ) : partner ? ( + + ) : null} +
+ + {/* Ban history table */} +
+ {isLoading ? ( + + ) : partner ? ( + + ) : null} +
+ + {/* Previous fraud alerts table */} +
+ {isLoading ? ( + + ) : partner ? ( + + ) : null} +
+
+
+ + {/* Sticky footer with review actions */} + {fraudAlert.status === "pending" && ( +
+
+ +