Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
}

Expand Down
47 changes: 42 additions & 5 deletions apps/web/app/api/veriff/webhook/handle-decision-event.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof veriffDecisionEventSchema>;

const veriffStatusMap: Record<
VeriffDecisionEvent["verification"]["status"],
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -100,6 +109,8 @@ export const handleDecisionEvent = async ({
data: {
identityVerificationStatus: veriffStatusMap[effectiveStatus],
identityVerifiedAt,
veriffIdentityHash:
effectiveStatus === "approved" ? veriffIdentityHash : null,
veriffMetadata,
},
select: {
Expand Down Expand Up @@ -137,6 +148,32 @@ function checkCountryMismatch({
return partner.country.toUpperCase() !== veriffCountry;
}

async function checkDuplicateIdentity({
partner,
veriffIdentityHash,
}: {
partner: Pick<Partner, "id">;
veriffIdentityHash: string | null;
}): Promise<boolean> {
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,
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/veriff/webhook/handle-session-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const handleSessionEvent = async ({
},
data: {
identityVerificationStatus: action,
veriffIdentityHash: null,
veriffMetadata,
},
});
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/auth/partner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ export const withPartnerProfile = (
salesChannels: true,
platforms: true,
},
omit: {
veriffIdentityHash: true,
},
},
},
});
Expand Down
15 changes: 2 additions & 13 deletions apps/web/lib/bounty/api/create-bounty-submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions apps/web/lib/email/email-templates-map.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,9 +22,10 @@ export const EMAIL_TEMPLATES_MAP = {
UnresolvedFraudEventsSummary,
PartnerGroupChanged,

// special promo emails
// special broadcast emails
// DubPartnerRewind,
DubProductUpdateMar26,
// DubProductUpdateMar26,
IdentityVerificationAnnouncement,
// PayoutAutoWithdrawals,
// ProgramMarketplaceAnnouncement,
// StablecoinPayoutsAnnouncement,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/lib/veriff/compute-veriff-identity-hash.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions apps/web/lib/veriff/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const veriffDecisionEventSchema = z.object({
}),
});

export type VeriffDecisionEvent = z.infer<typeof veriffDecisionEventSchema>;

export const veriffEventSchema = z.union([
veriffSessionEventSchema,
veriffDecisionEventSchema,
Expand Down
27 changes: 13 additions & 14 deletions apps/web/scripts/send-batch-emails.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
},
},
},
Expand All @@ -34,15 +35,13 @@ async function main() {
}
console.log(`Found ${usersToNotify.length} users to notify`);

const res = await queueBatchEmail<typeof DubProductUpdateMar26>(
const res = await queueBatchEmail<typeof IdentityVerificationAnnouncement>(
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!)}`,
},
})),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Html>
Expand Down
1 change: 1 addition & 0 deletions packages/prisma/schema/partner.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ model Partner {
identityVerificationStatus IdentityVerificationStatus?
identityVerifiedAt DateTime?
veriffSessionId String? @unique
veriffIdentityHash String? @unique
veriffMetadata Json? // see veriffMetadataSchema

// payout fields
Expand Down
Loading