From 6fd2b9b89a6245fc9d0c778fcd18474c5fc80d45 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 25 Nov 2025 11:31:11 +0530 Subject: [PATCH 01/20] WIP email template --- .../app/(ee)/api/cron/fraud/summary/route.ts | 131 ++++++++++++ apps/web/lib/zod/schemas/workspaces.ts | 1 + apps/web/vercel.json | 4 + .../unresolved-fraud-events-summary.tsx | 198 ++++++++++++++++++ packages/prisma/schema/notification.prisma | 1 + 5 files changed, 335 insertions(+) create mode 100644 apps/web/app/(ee)/api/cron/fraud/summary/route.ts create mode 100644 packages/email/src/templates/unresolved-fraud-events-summary.tsx diff --git a/apps/web/app/(ee)/api/cron/fraud/summary/route.ts b/apps/web/app/(ee)/api/cron/fraud/summary/route.ts new file mode 100644 index 00000000000..b77aeccd95b --- /dev/null +++ b/apps/web/app/(ee)/api/cron/fraud/summary/route.ts @@ -0,0 +1,131 @@ +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 { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; +import { fraudEventGroupProps, FraudRuleInfo, ProgramProps } from "@/lib/types"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +type FraudEventPayload = Pick< + fraudEventGroupProps, + "count" | "groupKey" | "partner" +> & { + typeInfo: Pick; +}; + +interface EmailPayload { + program: Pick; + fraudEvents: FraudEventPayload[]; +} + +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", + }, + }, + }, + select: { + id: true, + name: true, + 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.", + ); + } + + // Collect email payloads for this batch of programs + const emailPayloads: EmailPayload[] = []; + + 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; + } + + emailPayloads.push({ + program, + fraudEvents: fraudEvents.map( + ({ type, count, groupKey, partner }) => ({ + count, + groupKey, + partner, + typeInfo: FRAUD_RULES_BY_TYPE[type], + }), + ), + }); + + // Get workspace users to send the email to + const { users } = await getWorkspaceUsers({ + role: "owner", + programId: program.id, + notificationPreference: "fraudEventsSummary", + }); + } catch (error) { + console.error( + `Error collecting email payloads for program ${program.id}: ${error.message}`, + ); + continue; + } + } + + 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 }; diff --git a/apps/web/lib/zod/schemas/workspaces.ts b/apps/web/lib/zod/schemas/workspaces.ts index fdc45aba071..b75d894e0be 100644 --- a/apps/web/lib/zod/schemas/workspaces.ts +++ b/apps/web/lib/zod/schemas/workspaces.ts @@ -162,6 +162,7 @@ export const notificationTypes = z.enum([ "newPartnerApplication", "newBountySubmitted", "newMessageFromPartner", + "fraudEventsSummary" ]); export const WorkspaceSchemaExtended = WorkspaceSchema.extend({ diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 2bb76d16ccc..9dc94c61ad9 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -79,6 +79,10 @@ { "path": "/api/cron/calculate-program-similarities", "schedule": "0 */12 * * *" + }, + { + "path": "/api/cron/fraud-events/summary", + "schedule": "0 12 * * *" } ], "functions": { diff --git a/packages/email/src/templates/unresolved-fraud-events-summary.tsx b/packages/email/src/templates/unresolved-fraud-events-summary.tsx new file mode 100644 index 00000000000..17b8741898a --- /dev/null +++ b/packages/email/src/templates/unresolved-fraud-events-summary.tsx @@ -0,0 +1,198 @@ +import { DUB_WORDMARK, formatDate, nFormatter, OG_AVATAR_URL } from "@dub/utils"; +import { + Body, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +const MAX_DISPLAYED_EVENTS = 5; + +export default function UnresolvedFraudEventsSummary({ + workspace = { + slug: "acme", + }, + program = { + name: "Acme", + }, + fraudEvents = [ + { + name: "Customer email match", + count: 2, + groupKey: "QwRUVRbcTfa8zzrhDjGwdN9L", + partner: { + name: "Lauren Anderson", + image: "https://assets.dub.co/logo.png", + }, + }, + { + name: "Referral source", + count: 1, + groupKey: "3kkPeQ8vzIr1Zl5Zsn_A4l06", + partner: { + name: "Charlie Anderson", + image: "https://assets.dub.co/logo.png", + }, + }, + ], + email = "panic@thedis.co", +}: { + workspace: { + slug: string; + }; + program: { + name: string; + }; + fraudEvents: { + name: string; + count: number; + groupKey: string; + partner: { + name: string; + image: string | null; + } | null; + }[]; + email: string; +}) { + const totalCount = fraudEvents.reduce((acc, event) => acc + event.count, 0); + const totalCountText = nFormatter(totalCount, { full: true }); + + const formattedDate = formatDate(new Date(), { + month: "short", + day: "numeric", + year: "numeric", + }); + + const displayedEvents = fraudEvents.slice(0, MAX_DISPLAYED_EVENTS); + const remainingCount = fraudEvents.length - MAX_DISPLAYED_EVENTS; + + return ( + + + Fraud detection events + + + +
+ Dub +
+ + + Fraud detection events + + + + Here are your detected fraud and risk events reported for{" "} + {formattedDate} for the program {program.name}. + + +
+ {/* Table Header */} + + + + Event + + + + + Partner + + + + + {/* Table Rows */} + {displayedEvents.map((event, idx) => ( + + + + {event.name} + {event.count > 1 && ( + + {event.count} + + )} + + + + {event.partner ? ( + + + {event.partner.name} + + + + {event.partner.name} + + + + + View + + + + ) : ( + + No partner + + )} + + + ))} + + {/* Footer with "Plus X more" */} + {remainingCount > 0 && ( + + + + Plus {remainingCount} more + + + + )} +
+ +
+ + Review events + +
+ +