From 4396886a65d700c4ecbfc744d11f6c9fe3fe46fe Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 11:03:57 -0700 Subject: [PATCH 1/6] Persist Bounties emails to Message + NotificationEmail --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 1 + apps/web/app/(ee)/api/bounties/route.ts | 1 + .../cron/bounties/notify-partners/route.ts | 48 +++++++++++++++++-- packages/prisma/schema/notification.prisma | 2 + 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 16b471d2398..25ec7c09959 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -224,6 +224,7 @@ export const PATCH = withWorkspace( url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: updatedBounty.id, + userId: session?.user.id, }, notBefore: Math.floor(updatedBounty.startsAt.getTime() / 1000), }), diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 738476bbdf8..ce8bd7e262c 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -253,6 +253,7 @@ export const POST = withWorkspace( url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: bounty.id, + userId: session?.user.id, }, notBefore: Math.floor(bounty.startsAt.getTime() / 1000), }), diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index ac5124f2310..34bf7bf0a33 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -1,3 +1,4 @@ +import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; @@ -5,6 +6,7 @@ import { sendBatchEmail } from "@dub/email"; import NewBountyAvailable from "@dub/email/templates/new-bounty-available"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; +import { NotificationEmailType } from "@prisma/client"; import { differenceInMinutes } from "date-fns"; import { z } from "zod"; import { logAndRespond } from "../../utils"; @@ -13,6 +15,7 @@ export const dynamic = "force-dynamic"; const schema = z.object({ bountyId: z.string(), + userId: z.string().nullish(), page: z.number().optional().default(0), }); @@ -29,7 +32,7 @@ export async function POST(req: Request) { rawBody, }); - const { bountyId, page } = schema.parse(JSON.parse(rawBody)); + const { bountyId, userId, page } = schema.parse(JSON.parse(rawBody)); // Find bounty const bounty = await prisma.bounty.findUnique({ @@ -38,7 +41,23 @@ export async function POST(req: Request) { }, include: { groups: true, - program: true, + program: { + include: { + workspace: { + select: { + users: { + select: { + userId: true, + }, + where: { + role: "owner", + }, + take: 1, + }, + }, + }, + }, + }, }, }); @@ -104,7 +123,7 @@ export async function POST(req: Request) { console.log( `Sending emails to ${programEnrollments.length} partners: ${programEnrollments.map(({ partner }) => partner.email).join(", ")}`, ); - await sendBatchEmail( + const { data } = await sendBatchEmail( programEnrollments.map(({ partner }) => ({ variant: "notifications", to: partner.email!, // coerce the type here because we've already filtered out partners with no email in the prisma query @@ -128,6 +147,29 @@ export async function POST(req: Request) { })), ); + if (data) { + await prisma.message.createMany({ + data: programEnrollments.flatMap(({ partner }, idx) => { + const messageId = createId({ prefix: "msg_" }); + return { + id: messageId, + programId: bounty.programId, + partnerId: partner.id, + senderUserId: userId ?? bounty.program.workspace.users[0].userId, + text: `New bounty available for ${bounty.program.name}`, + emails: { + create: { + type: NotificationEmailType.Bounty, + emailId: data.data[idx].id, + messageId, + bountyId: bounty.id, + }, + }, + }; + }), + }); + } + if (programEnrollments.length === MAX_PAGE_SIZE) { const res = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, diff --git a/packages/prisma/schema/notification.prisma b/packages/prisma/schema/notification.prisma index bc8d534a0a5..dc1e5170805 100644 --- a/packages/prisma/schema/notification.prisma +++ b/packages/prisma/schema/notification.prisma @@ -1,11 +1,13 @@ enum NotificationEmailType { Message + Bounty } model NotificationEmail { id String @id @default(cuid()) emailId String // Resend email id messageId String? + bountyId String? type NotificationEmailType openedAt DateTime? createdAt DateTime @default(now()) From 521e073440e7c4c76673bd131cc816bfd1d6c406 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 11:06:03 -0700 Subject: [PATCH 2/6] missed a spot --- apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 34bf7bf0a33..066a71961b6 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -175,6 +175,7 @@ export async function POST(req: Request) { url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId, + userId, page: page + 1, }, }); From 3a971e8be5caefc6b9df591b6649c804eebd2a15 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 12:35:00 -0700 Subject: [PATCH 3/6] simplify to use NotificationEmail approach --- .../cron/bounties/notify-partners/route.ts | 37 +++++++++---------- .../api/cron/messages/notify-partner/route.ts | 27 ++++++++------ .../api/cron/messages/notify-program/route.ts | 27 ++++++++------ apps/web/lib/api/create-id.ts | 1 + packages/prisma/schema/notification.prisma | 7 +++- 5 files changed, 54 insertions(+), 45 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 066a71961b6..60508de41dd 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -105,7 +105,13 @@ export async function POST(req: Request) { }, }, include: { - partner: true, + partner: { + include: { + users: { + take: 1, // TODO: update this to use partnerUsersToNotify approach + }, + }, + }, }, orderBy: { createdAt: "asc", @@ -148,25 +154,16 @@ export async function POST(req: Request) { ); if (data) { - await prisma.message.createMany({ - data: programEnrollments.flatMap(({ partner }, idx) => { - const messageId = createId({ prefix: "msg_" }); - return { - id: messageId, - programId: bounty.programId, - partnerId: partner.id, - senderUserId: userId ?? bounty.program.workspace.users[0].userId, - text: `New bounty available for ${bounty.program.name}`, - emails: { - create: { - type: NotificationEmailType.Bounty, - emailId: data.data[idx].id, - messageId, - bountyId: bounty.id, - }, - }, - }; - }), + await prisma.notificationEmail.createMany({ + data: programEnrollments.map(({ partner }, idx) => ({ + id: createId({ prefix: "em_" }), + type: NotificationEmailType.Bounty, + emailId: data.data[idx].id, + bountyId: bounty.id, + programId: bounty.programId, + partnerId: partner.id, + recipientUserId: partner.users[0].userId, // TODO: update this to use partnerUsersToNotify approach + })), }); } diff --git a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts index 4bdca4bb8cf..60d97fea853 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts @@ -1,3 +1,4 @@ +import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendBatchEmail } from "@dub/email"; @@ -90,11 +91,11 @@ export async function POST(req: Request) { `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); - const partnerEmailsToNotify = programEnrollment.partner.users - .map(({ user }) => user.email) - .filter(Boolean) as string[]; + const partnerUsersToNotify = programEnrollment.partner.users + .map(({ user }) => user) + .filter(Boolean) as { email: string; id: string }[]; - if (partnerEmailsToNotify.length === 0) + if (partnerUsersToNotify.length === 0) return logAndRespond( `No partner emails to notify for partner ${partnerId}. Skipping...`, ); @@ -102,7 +103,7 @@ export async function POST(req: Request) { const program = programEnrollment.program; const { data, error } = await sendBatchEmail( - partnerEmailsToNotify.map((email) => ({ + partnerUsersToNotify.map(({ email }) => ({ subject: `${program.name} sent ${unreadMessages.length === 1 ? "a message" : `${unreadMessages.length} messages`}`, variant: "notifications", to: email, @@ -142,13 +143,15 @@ export async function POST(req: Request) { ); await prisma.notificationEmail.createMany({ - data: unreadMessages.flatMap((message) => - data.data.map(({ id }) => ({ - type: NotificationEmailType.Message, - emailId: id, - messageId: message.id, - })), - ), + data: partnerUsersToNotify.map(({ id: userId }, idx) => ({ + id: createId({ prefix: "em_" }), + type: NotificationEmailType.Message, + emailId: data.data[idx].id, + messageId: lastMessageId, + programId, + partnerId, + recipientUserId: userId, + })), }); return logAndRespond( diff --git a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts index 34900673d5e..562e504a412 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts @@ -1,3 +1,4 @@ +import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendBatchEmail } from "@dub/email"; @@ -99,11 +100,11 @@ export async function POST(req: Request) { `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); - const userEmailsToNotify = programEnrollment.program.workspace.users - .map(({ user }) => user.email) - .filter(Boolean) as string[]; + const usersToNotify = programEnrollment.program.workspace.users + .map(({ user }) => user) + .filter(Boolean) as { email: string; id: string }[]; - if (userEmailsToNotify.length === 0) + if (usersToNotify.length === 0) return logAndRespond( `No program user emails to notify from partner ${partnerId}. Skipping...`, ); @@ -111,7 +112,7 @@ export async function POST(req: Request) { const { program, partner } = programEnrollment; const { data, error } = await sendBatchEmail( - userEmailsToNotify.map((email) => ({ + usersToNotify.map(({ email }) => ({ subject: `${unreadMessages.length === 1 ? "New message from" : `${unreadMessages.length} new messages from`} ${partner.name}`, variant: "notifications", to: email, @@ -143,13 +144,15 @@ export async function POST(req: Request) { ); await prisma.notificationEmail.createMany({ - data: unreadMessages.flatMap((message) => - data.data.map(({ id }) => ({ - type: NotificationEmailType.Message, - emailId: id, - messageId: message.id, - })), - ), + data: usersToNotify.map(({ id: userId }, idx) => ({ + id: createId({ prefix: "em_" }), + type: NotificationEmailType.Message, + emailId: data.data[idx].id, + messageId: lastMessageId, + programId, + partnerId, + recipientUserId: userId, + })), }); return logAndRespond( diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index 6ac120f1339..4f43e7b88f1 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -34,6 +34,7 @@ const prefixes = [ "bnty_sub_", // bounty submission "wf_", // workflow "msg_", // message + "em_", // notification email ] as const; // ULID uses base32 encoding diff --git a/packages/prisma/schema/notification.prisma b/packages/prisma/schema/notification.prisma index dc1e5170805..6122117c4f3 100644 --- a/packages/prisma/schema/notification.prisma +++ b/packages/prisma/schema/notification.prisma @@ -6,9 +6,14 @@ enum NotificationEmailType { model NotificationEmail { id String @id @default(cuid()) emailId String // Resend email id + type NotificationEmailType messageId String? bountyId String? - type NotificationEmailType + + programId String? + partnerId String? + recipientUserId String? + openedAt DateTime? createdAt DateTime @default(now()) From abcbebe5d10f666380b08862a19f5fc42650a7fb Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 16:55:26 -0700 Subject: [PATCH 4/6] finalize changes --- .../api/cron/messages/notify-partner/route.ts | 14 +++---- .../api/cron/messages/notify-program/route.ts | 14 +++---- .../app/api/resend/webhook/email-opened.ts | 40 ++++++++++++++----- .../partners/mark-partner-messages-read.ts | 1 + .../partners/mark-program-messages-read.ts | 1 + 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts index 60d97fea853..dbc5d20949b 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts @@ -54,9 +54,9 @@ export async function POST(req: Request) { }, readInApp: null, // Unread readInEmail: null, // Unread - emails: { - none: {}, // No emails sent yet - }, + }, + orderBy: { + createdAt: "desc", }, include: { senderUser: true, @@ -77,16 +77,16 @@ export async function POST(req: Request) { }, }); - const unreadMessages = programEnrollment.partner.messages.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - ); + // unread messages are already sorted by latest message first + const unreadMessages = programEnrollment.partner.messages; if (unreadMessages.length === 0) return logAndRespond( `No unread messages found for partner ${partnerId} in program ${programId}. Skipping...`, ); - if (unreadMessages[unreadMessages.length - 1].id !== lastMessageId) + // if the latest unread message is not the last message id, skip + if (unreadMessages[0].id !== lastMessageId) return logAndRespond( `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); diff --git a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts index 562e504a412..84502eb7aa7 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts @@ -73,9 +73,9 @@ export async function POST(req: Request) { }, readInApp: null, // Unread readInEmail: null, // Unread - emails: { - none: {}, // No emails sent yet - }, + }, + orderBy: { + createdAt: "desc", }, include: { senderPartner: true, @@ -86,16 +86,16 @@ export async function POST(req: Request) { }, }); - const unreadMessages = programEnrollment.partner.messages.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - ); + const unreadMessages = programEnrollment.partner.messages; + // unread messages are already sorted by latest message first if (unreadMessages.length === 0) return logAndRespond( `No unread messages found from partner ${partnerId} in program ${programId}. Skipping...`, ); - if (unreadMessages[unreadMessages.length - 1].id !== lastMessageId) + // if the latest unread message is not the last message id, skip + if (unreadMessages[0].id !== lastMessageId) return logAndRespond( `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); diff --git a/apps/web/app/api/resend/webhook/email-opened.ts b/apps/web/app/api/resend/webhook/email-opened.ts index 9f2a8138c93..3a7824b5fc3 100644 --- a/apps/web/app/api/resend/webhook/email-opened.ts +++ b/apps/web/app/api/resend/webhook/email-opened.ts @@ -16,8 +16,9 @@ export async function emailOpened({ `Updating notification email read statuses for email ${emailId}...`, ); - await prisma.$transaction([ - prisma.notificationEmail.updateMany({ + const res = await prisma.$transaction(async (tx) => { + // TODO: refactor this to use findUnique once we add `emailId` as a unique index + await tx.notificationEmail.updateMany({ where: { emailId, openedAt: null, @@ -25,20 +26,39 @@ export async function emailOpened({ data: { openedAt: new Date(), }, - }), + }); + const notificationEmail = await tx.notificationEmail.findFirst({ + where: { + emailId, + }, + }); - prisma.message.updateMany({ + console.log( + `Found notification email: ${JSON.stringify(notificationEmail)}`, + ); + + if ( + !notificationEmail || + !notificationEmail.programId || + !notificationEmail.partnerId + ) { + return; + } + + return await tx.message.updateMany({ where: { + programId: notificationEmail.programId, + partnerId: notificationEmail.partnerId, readInEmail: null, - emails: { - some: { - emailId, - }, + createdAt: { + lte: notificationEmail.createdAt, }, }, data: { readInEmail: new Date(), }, - }), - ]); + }); + }); + + console.log(res); } diff --git a/apps/web/lib/actions/partners/mark-partner-messages-read.ts b/apps/web/lib/actions/partners/mark-partner-messages-read.ts index d0bcbcaff06..7caebec6f85 100644 --- a/apps/web/lib/actions/partners/mark-partner-messages-read.ts +++ b/apps/web/lib/actions/partners/mark-partner-messages-read.ts @@ -23,6 +23,7 @@ export const markPartnerMessagesReadAction = authActionClient where: { partnerId, programId, + readInApp: null, senderPartnerId: { not: null, }, diff --git a/apps/web/lib/actions/partners/mark-program-messages-read.ts b/apps/web/lib/actions/partners/mark-program-messages-read.ts index 2548fe1fcd2..38d542996ea 100644 --- a/apps/web/lib/actions/partners/mark-program-messages-read.ts +++ b/apps/web/lib/actions/partners/mark-program-messages-read.ts @@ -25,6 +25,7 @@ export const markProgramMessagesReadAction = authPartnerActionClient where: { partnerId, programId, + readInApp: null, senderPartnerId: null, }, data: { From a7c02fe31a7af8f6dcf09c68648120f8fb9b54ed Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 17:20:22 -0700 Subject: [PATCH 5/6] remove redundant userId from cron/bounties/notify-partners --- apps/web/app/(ee)/api/bounties/[bountyId]/route.ts | 1 - apps/web/app/(ee)/api/bounties/route.ts | 1 - apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts | 4 +--- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 25ec7c09959..16b471d2398 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -224,7 +224,6 @@ export const PATCH = withWorkspace( url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: updatedBounty.id, - userId: session?.user.id, }, notBefore: Math.floor(updatedBounty.startsAt.getTime() / 1000), }), diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index ce8bd7e262c..738476bbdf8 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -253,7 +253,6 @@ export const POST = withWorkspace( url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: bounty.id, - userId: session?.user.id, }, notBefore: Math.floor(bounty.startsAt.getTime() / 1000), }), diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 60508de41dd..6615e4d2d02 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -15,7 +15,6 @@ export const dynamic = "force-dynamic"; const schema = z.object({ bountyId: z.string(), - userId: z.string().nullish(), page: z.number().optional().default(0), }); @@ -32,7 +31,7 @@ export async function POST(req: Request) { rawBody, }); - const { bountyId, userId, page } = schema.parse(JSON.parse(rawBody)); + const { bountyId, page } = schema.parse(JSON.parse(rawBody)); // Find bounty const bounty = await prisma.bounty.findUnique({ @@ -172,7 +171,6 @@ export async function POST(req: Request) { url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId, - userId, page: page + 1, }, }); From b5a94659bbbebfa4dc90461be3178c1c077bbfe0 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 24 Sep 2025 17:37:56 -0700 Subject: [PATCH 6/6] remove redundant complex logic --- .../api/cron/bounties/notify-partners/route.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 6615e4d2d02..8e570b7137d 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -40,23 +40,7 @@ export async function POST(req: Request) { }, include: { groups: true, - program: { - include: { - workspace: { - select: { - users: { - select: { - userId: true, - }, - where: { - role: "owner", - }, - take: 1, - }, - }, - }, - }, - }, + program: true, }, });