diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx index bbeef8f9ede..2eaf573050d 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx @@ -5,6 +5,7 @@ import { getSubmissionPeriods, } from "@/lib/bounty/periods"; import { BOUNTY_SUBMISSION_STATUS_BADGES } from "@/lib/bounty/submission-status"; +import { resolveBountyDetails } from "@/lib/bounty/utils"; import { PartnerBountyProps, PartnerBountySubmission } from "@/lib/types"; import { useBountySubmissionDetailsSheet } from "@/ui/partners/bounties/bounty-submission-details-sheet"; import { useClaimBountySheet } from "@/ui/partners/bounties/claim-bounty-sheet"; @@ -140,15 +141,18 @@ export function BountySubmissionsTable({ size: 98, cell: ({ row: { original } }) => { const { status } = original; + const bountyInfo = resolveBountyDetails(bounty); const isExpired = bounty.endsAt !== null && new Date(bounty.endsAt) < new Date(); - const isActionable = status === "notSubmitted" || status === "draft"; + const isActionable = bountyInfo?.hasSocialMetrics + ? status === "notSubmitted" + : status === "notSubmitted" || status === "draft"; let buttonText = "Submit"; - if (status === "draft") { + if (status === "draft" && !bountyInfo?.hasSocialMetrics) { buttonText = "Continue"; - } else if (["submitted", "approved", "rejected"].includes(status)) { + } else if (status !== "notSubmitted") { buttonText = "View"; } diff --git a/apps/web/app/api/veriff/webhook/handle-decision-event.ts b/apps/web/app/api/veriff/webhook/handle-decision-event.ts index 2d01faaa687..1d89815b7b1 100644 --- a/apps/web/app/api/veriff/webhook/handle-decision-event.ts +++ b/apps/web/app/api/veriff/webhook/handle-decision-event.ts @@ -1,4 +1,5 @@ -import { veriffDecisionEventSchema } from "@/lib/veriff/schema"; +import { computeVeriffIdentityHash } from "@/lib/veriff/compute-veriff-identity-hash"; +import { VeriffDecisionEvent } from "@/lib/veriff/schema"; import { mergeVeriffMetadata, parseVeriffMetadata, @@ -9,9 +10,6 @@ import PartnerIdentityVerified from "@dub/email/templates/partner-identity-verif import { prisma } from "@dub/prisma"; import { IdentityVerificationStatus, Partner } from "@dub/prisma/client"; import { logAndRespond } from "app/(ee)/api/cron/utils"; -import * as z from "zod/v4"; - -type VeriffDecisionEvent = z.infer; const veriffStatusMap: Record< VeriffDecisionEvent["verification"]["status"], @@ -56,6 +54,7 @@ export const handleDecisionEvent = async ({ // since we're skipping verified partners, by default identityVerifiedAt is null let identityVerifiedAt: Date | null = null; + let veriffIdentityHash: string | null | undefined = undefined; let { sessionUrl, attemptCount, declineReason, sessionExpiresAt } = parseVeriffMetadata(partner.veriffMetadata); @@ -65,12 +64,22 @@ export const handleDecisionEvent = async ({ // If the verification was approved, check for country mismatch if (effectiveStatus === "approved") { + veriffIdentityHash = computeVeriffIdentityHash(verification); + const isDuplicate = await checkDuplicateIdentity({ + partner, + veriffIdentityHash, + }); + const isCountryMismatch = checkCountryMismatch({ partner, verification, }); - if (isCountryMismatch) { + if (isDuplicate) { + effectiveStatus = "declined"; + declineReason = + "This identity has already been verified on another account."; + } else if (isCountryMismatch) { effectiveStatus = "declined"; declineReason = `Your document country (${verification.document?.country}) does not match your account country (${partner.country})`; } else { @@ -100,6 +109,8 @@ export const handleDecisionEvent = async ({ data: { identityVerificationStatus: veriffStatusMap[effectiveStatus], identityVerifiedAt, + veriffIdentityHash: + effectiveStatus === "approved" ? veriffIdentityHash : null, veriffMetadata, }, select: { @@ -137,6 +148,32 @@ function checkCountryMismatch({ return partner.country.toUpperCase() !== veriffCountry; } +async function checkDuplicateIdentity({ + partner, + veriffIdentityHash, +}: { + partner: Pick; + veriffIdentityHash: string | null; +}): Promise { + if (!veriffIdentityHash) { + return false; + } + + const duplicatePartner = await prisma.partner.findFirst({ + where: { + veriffIdentityHash, + id: { + not: partner.id, + }, + }, + select: { + id: true, + }, + }); + + return !!duplicatePartner; +} + async function sendEmailNotification({ partner, attemptId, diff --git a/apps/web/app/api/veriff/webhook/handle-session-event.ts b/apps/web/app/api/veriff/webhook/handle-session-event.ts index b79b303c1a1..d289de39a5d 100644 --- a/apps/web/app/api/veriff/webhook/handle-session-event.ts +++ b/apps/web/app/api/veriff/webhook/handle-session-event.ts @@ -40,6 +40,7 @@ export const handleSessionEvent = async ({ }, data: { identityVerificationStatus: action, + veriffIdentityHash: null, veriffMetadata, }, }); diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index d9a5053f3ab..84031b2854a 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -224,6 +224,9 @@ export const withPartnerProfile = ( salesChannels: true, platforms: true, }, + omit: { + veriffIdentityHash: true, + }, }, }, }); diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index 94cf3b3acee..879f28c371a 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -231,19 +231,8 @@ export class BountySubmissionHandler { if (existingSubmission) { if ( - existingSubmission.reviewedAt || - existingSubmission.status === "approved" || - existingSubmission.status === "rejected" - ) { - throw new DubApiError({ - code: "conflict", - message: `You already have a ${existingSubmission.status} submission for this period.`, - }); - } - - if ( - existingSubmission.status !== "draft" && - !bountyInfo?.hasSocialMetrics + existingSubmission.status !== "draft" || + bountyInfo?.hasSocialMetrics ) { throw new DubApiError({ code: "conflict", diff --git a/apps/web/lib/email/email-templates-map.ts b/apps/web/lib/email/email-templates-map.ts index 8fd56a224b6..d8abaec80f1 100644 --- a/apps/web/lib/email/email-templates-map.ts +++ b/apps/web/lib/email/email-templates-map.ts @@ -1,5 +1,5 @@ import BountyApproved from "@dub/email/templates/bounty-approved"; -import DubProductUpdateMar26 from "@dub/email/templates/broadcasts/dub-product-update-mar26"; +import IdentityVerificationAnnouncement from "@dub/email/templates/broadcasts/identity-verification-announcement"; import ConnectPayoutReminder from "@dub/email/templates/connect-payout-reminder"; import ConnectPlatformsReminder from "@dub/email/templates/connect-platforms-reminder"; import PartnerBanned from "@dub/email/templates/partner-banned"; @@ -22,9 +22,10 @@ export const EMAIL_TEMPLATES_MAP = { UnresolvedFraudEventsSummary, PartnerGroupChanged, - // special promo emails + // special broadcast emails // DubPartnerRewind, - DubProductUpdateMar26, + // DubProductUpdateMar26, + IdentityVerificationAnnouncement, // PayoutAutoWithdrawals, // ProgramMarketplaceAnnouncement, // StablecoinPayoutsAnnouncement, diff --git a/apps/web/lib/veriff/compute-veriff-identity-hash.ts b/apps/web/lib/veriff/compute-veriff-identity-hash.ts new file mode 100644 index 00000000000..98895166ae0 --- /dev/null +++ b/apps/web/lib/veriff/compute-veriff-identity-hash.ts @@ -0,0 +1,36 @@ +import { createHash } from "crypto"; +import { VeriffDecisionEvent } from "./schema"; + +export function computeVeriffIdentityHash( + verification: VeriffDecisionEvent["verification"], +) { + const { person, document } = verification; + const documentNumber = document?.number?.trim(); + const documentCountry = document?.country?.trim(); + const firstName = person?.firstName?.trim(); + const lastName = person?.lastName?.trim(); + const dateOfBirth = person?.dateOfBirth?.trim(); + + // Prefer document number (passport/ID number) — strongest unique signal + if (documentNumber) { + const input = [ + "doc", + documentNumber.toLowerCase(), + ...(documentCountry ? [documentCountry.toUpperCase()] : []), + ].join("|"); + return createHash("sha256").update(input).digest("hex"); + } + + // Fall back to name + date of birth + if ((firstName || lastName) && dateOfBirth) { + const input = [ + "person", + ...(firstName ? [firstName.toLowerCase()] : []), + ...(lastName ? [lastName.toLowerCase()] : []), + dateOfBirth, + ].join("|"); + return createHash("sha256").update(input).digest("hex"); + } + + return null; +} diff --git a/apps/web/lib/veriff/schema.ts b/apps/web/lib/veriff/schema.ts index ec51e3a0ec6..03e1e4e50a4 100644 --- a/apps/web/lib/veriff/schema.ts +++ b/apps/web/lib/veriff/schema.ts @@ -67,6 +67,8 @@ export const veriffDecisionEventSchema = z.object({ }), }); +export type VeriffDecisionEvent = z.infer; + export const veriffEventSchema = z.union([ veriffSessionEventSchema, veriffDecisionEventSchema, diff --git a/apps/web/scripts/send-batch-emails.ts b/apps/web/scripts/send-batch-emails.ts index fe14ef770dd..7b4cb08b5de 100644 --- a/apps/web/scripts/send-batch-emails.ts +++ b/apps/web/scripts/send-batch-emails.ts @@ -1,23 +1,24 @@ -import DubProductUpdateMar26 from "@dub/email/templates/broadcasts/dub-product-update-mar26"; +import IdentityVerificationAnnouncement from "@dub/email/templates/broadcasts/identity-verification-announcement"; import { prisma } from "@dub/prisma"; import { chunk } from "@dub/utils"; import "dotenv-flow/config"; import { queueBatchEmail } from "../lib/email/queue-batch-email"; -import { generateUnsubscribeToken } from "../lib/email/unsubscribe-token"; async function main() { while (true) { const usersToNotify = await prisma.user.findMany({ where: { sentMail: false, - notificationPreferences: { - dubPartners: true, - }, - projects: { + partners: { some: { - project: { - plan: { - not: "free", + partner: { + payouts: { + some: { + status: "completed", + amount: { + gt: 10000, + }, + }, }, }, }, @@ -34,15 +35,13 @@ async function main() { } console.log(`Found ${usersToNotify.length} users to notify`); - const res = await queueBatchEmail( + const res = await queueBatchEmail( usersToNotify.map((user) => ({ to: user.email!, - subject: "Dub Partners Product Updates (Mar '26)", - variant: "marketing", - templateName: "DubProductUpdateMar26", + subject: "Action Required: Verify your identity on Dub", + templateName: "IdentityVerificationAnnouncement", templateProps: { email: user.email!, - unsubscribeUrl: `https://app.dub.co/unsubscribe/${generateUnsubscribeToken(user.email!)}`, }, })), ); diff --git a/packages/email/src/templates/broadcasts/identity-verification-announcement.tsx b/packages/email/src/templates/broadcasts/identity-verification-announcement.tsx index cdba00f2627..8fadc609c91 100644 --- a/packages/email/src/templates/broadcasts/identity-verification-announcement.tsx +++ b/packages/email/src/templates/broadcasts/identity-verification-announcement.tsx @@ -12,14 +12,12 @@ import { Tailwind, Text, } from "@react-email/components"; -import { Footer } from "src/components/footer"; +import { Footer } from "../../components/footer"; export default function IdentityVerificationAnnouncement({ email = "panic@thedis.co", - unsubscribeUrl = "https://partners.dub.co/account/settings", }: { email: string; - unsubscribeUrl: string; }) { return ( diff --git a/packages/prisma/schema/partner.prisma b/packages/prisma/schema/partner.prisma index 71203654800..cbad0c3ac31 100644 --- a/packages/prisma/schema/partner.prisma +++ b/packages/prisma/schema/partner.prisma @@ -48,6 +48,7 @@ model Partner { identityVerificationStatus IdentityVerificationStatus? identityVerifiedAt DateTime? veriffSessionId String? @unique + veriffIdentityHash String? @unique veriffMetadata Json? // see veriffMetadataSchema // payout fields