diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index bc09876..080896e 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -465,6 +465,15 @@ export const mentorRouter = router({ include: { user: { select: { name: true, email: true } } }, }) + // Defer emails (mentor-side and team-side) while the project's MENTORING + // round is still ROUND_DRAFT — `activateRound` coalesces and fires them + // when the admin opens the round. In-app notifications still fire so the + // staged assignment is visible immediately. + const deferThisEmail = await shouldDeferEmailsForProject( + ctx.prisma, + input.projectId, + ) + // Notify mentor of new mentee await createNotification({ userId: input.mentorId, @@ -479,6 +488,7 @@ export const mentorRouter = router({ teamLeadName: teamLead?.user?.name || 'Team Lead', teamLeadEmail: teamLead?.user?.email, }, + skipEmail: deferThisEmail, }) // Notify project team of mentor assignment @@ -493,15 +503,9 @@ export const mentorRouter = router({ projectName: assignment.project.title, mentorName: assignment.mentor.name, }, + skipEmail: deferThisEmail, }) - // Defer the mentor-side email if the project's MENTORING round is still - // ROUND_DRAFT — `activateRound` will coalesce and send when the admin - // opens the round. Otherwise fire the per-assignment email immediately. - const deferThisEmail = await shouldDeferEmailsForProject( - ctx.prisma, - input.projectId, - ) if ( !deferThisEmail && assignment.notificationSentAt == null && @@ -661,6 +665,13 @@ export const mentorRouter = router({ include: { user: { select: { name: true, email: true } } }, }) + // Defer email notifications if the project's MENTORING round is still + // in draft — activateRound will fire coalesced emails at round-open. + const deferThisEmail = await shouldDeferEmailsForProject( + ctx.prisma, + input.projectId, + ) + // Notify mentor of new mentee await createNotification({ userId: mentorId, @@ -675,6 +686,7 @@ export const mentorRouter = router({ teamLeadName: teamLead?.user?.name || 'Team Lead', teamLeadEmail: teamLead?.user?.email, }, + skipEmail: deferThisEmail, }) // Notify project team of mentor assignment @@ -689,6 +701,7 @@ export const mentorRouter = router({ projectName: assignment.project.title, mentorName: assignment.mentor.name, }, + skipEmail: deferThisEmail, }) return assignment @@ -777,8 +790,22 @@ export const mentorRouter = router({ let totalAssigned = 0 let totalSkipped = 0 + // Pre-compute which projects must defer outbound email because their + // MENTORING round is still in draft. The in-app notification still + // fires; only the parallel notification-system email is suppressed, + // exactly like the coalesced mentor email path below. `activateRound` + // sends one combined email per mentor + one team intro per project + // when the admin opens the round. + const draftProjectIds = new Set() + for (const project of projects) { + if (await shouldDeferEmailsForProject(ctx.prisma, project.id)) { + draftProjectIds.add(project.id) + } + } + for (const project of projects) { const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId)) + const deferForThis = draftProjectIds.has(project.id) for (const mentor of validMentors) { const bucket = perMentor.get(mentor.id)! if (alreadyOn.has(mentor.id)) { @@ -808,6 +835,7 @@ export const mentorRouter = router({ linkLabel: 'View Project', priority: 'high', metadata: { projectName: project.title }, + skipEmail: deferForThis, }) await notifyProjectTeam(project.id, { @@ -818,6 +846,7 @@ export const mentorRouter = router({ linkLabel: 'View Project', priority: 'high', metadata: { projectName: project.title, mentorName: mentor.name }, + skipEmail: deferForThis, }) } @@ -852,16 +881,7 @@ export const mentorRouter = router({ } } - // Decide per-project whether to defer email until round-open: projects - // whose MENTORING round is still ROUND_DRAFT skip email and stamp now; - // `activateRound` will coalesce and send when the admin opens the round. - const draftProjectIds = new Set() - for (const projectId of touchedProjectIds) { - if (await shouldDeferEmailsForProject(ctx.prisma, projectId)) { - draftProjectIds.add(projectId) - } - } - + // `draftProjectIds` was computed before the assignment loop above. // One email per mentor, listing only their NEW projects whose mentoring // round is NOT in draft. If every new project is deferred, no email. for (const bucket of perMentor.values()) { @@ -1083,6 +1103,12 @@ export const mentorRouter = router({ include: { user: { select: { name: true, email: true } } }, }) + // Defer emails when the project's MENTORING round is still in draft. + const deferThisEmail = await shouldDeferEmailsForProject( + ctx.prisma, + project.id, + ) + // Notify mentor await createNotification({ userId: mentorId, @@ -1097,6 +1123,7 @@ export const mentorRouter = router({ teamLeadName: teamLead?.user?.name || 'Team Lead', teamLeadEmail: teamLead?.user?.email, }, + skipEmail: deferThisEmail, }) // Notify project team @@ -1111,6 +1138,7 @@ export const mentorRouter = router({ projectName: assignment.project.title, mentorName: assignment.mentor.name, }, + skipEmail: deferThisEmail, }) assigned++ @@ -1164,7 +1192,7 @@ export const mentorRouter = router({ .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, - select: { id: true, roundType: true, configJson: true }, + select: { id: true, roundType: true, configJson: true, status: true }, }) if (round.roundType !== 'MENTORING') { throw new TRPCError({ @@ -1211,6 +1239,11 @@ export const mentorRouter = router({ let assigned = 0 let unassignable = 0 + // Defer outbound emails when the round is still in draft — same gate + // used by mentor.assign/bulkAssign. In-app notifications still fire so + // the staged assignment is visible to the mentor + team immediately. + const deferEmailsForRound = round.status === 'ROUND_DRAFT' + // Coalesce per-mentor so we send ONE email per mentor at the end of the // batch, even when the algorithm assigns the same mentor to several teams. const perMentor = new Map< @@ -1287,6 +1320,7 @@ export const mentorRouter = router({ teamLeadName: teamLead?.user?.name || 'Team Lead', teamLeadEmail: teamLead?.user?.email, }, + skipEmail: deferEmailsForRound, }) await notifyProjectTeam(project.id, { @@ -1300,6 +1334,7 @@ export const mentorRouter = router({ projectName: assignment.project.title, mentorName: assignment.mentor.name, }, + skipEmail: deferEmailsForRound, }) // Accumulate for the coalesced email diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts index 80725a6..adc7a26 100644 --- a/src/server/services/in-app-notification.ts +++ b/src/server/services/in-app-notification.ts @@ -169,6 +169,14 @@ interface CreateNotificationParams { metadata?: Record groupKey?: string expiresAt?: Date + /** + * When true, the in-app notification still fires but the parallel email + * send (via NotificationEmailSetting) is suppressed. Callers use this when + * the email belongs to a coalesced/deferred flow that will fire later + * (e.g. mentor assignments staged while a MENTORING round is ROUND_DRAFT — + * the round-open hook sends a single combined email instead). + */ + skipEmail?: boolean } /** @@ -189,6 +197,7 @@ export async function createNotification( metadata, groupKey, expiresAt, + skipEmail, } = params // Determine icon and priority if not provided @@ -241,8 +250,11 @@ export async function createNotification( }, }) - // Check if we should also send an email - await maybeSendEmail(userId, type, title, message, linkUrl, metadata) + // Check if we should also send an email (suppressed when the caller is + // deferring the email to a coalesced flow). + if (!skipEmail) { + await maybeSendEmail(userId, type, title, message, linkUrl, metadata) + } } /** @@ -258,6 +270,8 @@ export async function createBulkNotifications(params: { icon?: string priority?: NotificationPriority metadata?: Record + /** See {@link CreateNotificationParams.skipEmail}. */ + skipEmail?: boolean }): Promise { const { userIds, @@ -269,6 +283,7 @@ export async function createBulkNotifications(params: { icon, priority, metadata, + skipEmail, } = params const finalIcon = icon || NotificationIcons[type] || 'Bell' @@ -289,6 +304,8 @@ export async function createBulkNotifications(params: { })), }) + if (skipEmail) return + // Check email settings once, then send emails only if enabled const emailSetting = await prisma.notificationEmailSetting.findUnique({ where: { notificationType: type }, diff --git a/tests/unit/mentor-email-deferral.test.ts b/tests/unit/mentor-email-deferral.test.ts new file mode 100644 index 0000000..998ea21 --- /dev/null +++ b/tests/unit/mentor-email-deferral.test.ts @@ -0,0 +1,228 @@ +/** + * Regression: mentor-assignment emails must be deferred while the + * project's MENTORING round is still ROUND_DRAFT. The earlier fix only + * deferred the explicit `sendMentorBulkAssignmentEmail` path; the parallel + * in-app-notification → email path (MENTEE_ASSIGNED, MENTOR_ASSIGNED) kept + * firing immediately, causing duplicate sends both at assign-time AND + * again when activateRound coalesced the same assignments. Verified + * against prod incident 2026-05-26 (Camille Lopez received 9 emails). + * + * These tests assert that: + * - in DRAFT: in-app notifications still create rows, but the styled + * notification email is NOT sent; + * - in ACTIVE: the styled notification email IS sent (legacy behaviour + * preserved when the round is open). + */ + +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' +import type { UserRole } from '@prisma/client' + +vi.mock('@/lib/email', async () => { + const actual = await vi.importActual('@/lib/email') + return { + ...actual, + sendStyledNotificationEmail: vi.fn(async () => undefined), + sendMentorTeamAssignmentEmail: vi.fn(async () => undefined), + sendMentorBulkAssignmentEmail: vi.fn(async () => undefined), + sendTeamMentorIntroductionEmail: vi.fn(async () => undefined), + } +}) + +const email = await import('@/lib/email') +const sendStyledMock = email.sendStyledNotificationEmail as ReturnType +const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType +const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType + +async function makeMentor(): Promise<{ id: string; email: string }> { + const id = uid('mentor') + const u = await prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Mentor ${id}`, + role: 'MENTOR' as UserRole, + roles: ['MENTOR'] as UserRole[], + status: 'ACTIVE', + // Email path requires the user to opt into emails. Default for new test + // users is EMAIL so styled-email sends fire when the gate is open. + notificationPreference: 'EMAIL', + }, + }) + return { id: u.id, email: u.email } +} + +async function makeTeamMember(projectId: string): Promise { + const id = uid('teamuser') + const u = await prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Team ${id}`, + role: 'APPLICANT' as UserRole, + roles: ['APPLICANT'] as UserRole[], + status: 'ACTIVE', + notificationPreference: 'EMAIL', + }, + }) + await prisma.teamMember.create({ + data: { projectId, userId: u.id, role: 'MEMBER' }, + }) + return u.id +} + +async function attachToMentoringRound( + programId: string, + projectId: string, + status: 'ROUND_DRAFT' | 'ROUND_ACTIVE', +): Promise { + const slug = uid() + const competition = await prisma.competition.create({ + data: { + name: `Comp ${slug}`, + slug: `comp-${slug}`, + programId, + status: 'ACTIVE', + }, + }) + const round = await prisma.round.create({ + data: { + name: `Mentoring ${slug}`, + slug: `mentoring-${slug}`, + roundType: 'MENTORING', + sortOrder: 1, + status, + competitionId: competition.id, + }, + }) + await prisma.projectRoundState.create({ + data: { roundId: round.id, projectId }, + }) + return round.id +} + +describe('mentor-assignment email deferral (regression for 2026-05-26 duplicate-email incident)', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + beforeEach(() => { + sendStyledMock.mockClear() + sendMentorBulkMock.mockClear() + sendTeamIntroMock.mockClear() + }) + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await prisma.teamMember.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('mentor.assign in DRAFT round creates in-app notif rows but sends ZERO emails', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `defer-draft-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Draft Project' }) + await attachToMentoringRound(program.id, project.id, 'ROUND_DRAFT') + + const mentor = await makeMentor() + userIds.push(mentor.id) + const teamUser = await makeTeamMember(project.id) + userIds.push(teamUser) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.assign({ projectId: project.id, mentorId: mentor.id }) + + expect(sendStyledMock).not.toHaveBeenCalled() + expect(sendMentorBulkMock).not.toHaveBeenCalled() + expect(sendTeamIntroMock).not.toHaveBeenCalled() + + // In-app notification rows still fire so admin + mentor see staged state. + const mentorNotifs = await prisma.inAppNotification.findMany({ + where: { userId: mentor.id, type: 'MENTEE_ASSIGNED' }, + }) + expect(mentorNotifs.length).toBe(1) + const teamNotifs = await prisma.inAppNotification.findMany({ + where: { userId: teamUser, type: 'MENTOR_ASSIGNED' }, + }) + expect(teamNotifs.length).toBe(1) + }) + + it('mentor.assign in ACTIVE round still sends the per-assignment emails (legacy behaviour preserved)', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `defer-active-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Active Project' }) + await attachToMentoringRound(program.id, project.id, 'ROUND_ACTIVE') + + const mentor = await makeMentor() + userIds.push(mentor.id) + const teamUser = await makeTeamMember(project.id) + userIds.push(teamUser) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.assign({ projectId: project.id, mentorId: mentor.id }) + + // Either styled notif email OR the explicit team-intro email is allowed + // to fire here — point is: at least one outbound email happens when the + // round is open. The DRAFT test above is the one that must stay at zero. + const sentCount = + sendStyledMock.mock.calls.length + sendTeamIntroMock.mock.calls.length + expect(sentCount).toBeGreaterThan(0) + }) + + it('mentor.bulkAssign in DRAFT round sends ZERO emails across multiple projects', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `defer-bulk-${uid()}` }) + programIds.push(program.id) + const p1 = await createTestProject(program.id, { title: 'BulkDraft 1' }) + const p2 = await createTestProject(program.id, { title: 'BulkDraft 2' }) + const p3 = await createTestProject(program.id, { title: 'BulkDraft 3' }) + await attachToMentoringRound(program.id, p1.id, 'ROUND_DRAFT') + await attachToMentoringRound(program.id, p2.id, 'ROUND_DRAFT') + await attachToMentoringRound(program.id, p3.id, 'ROUND_DRAFT') + + const mentor = await makeMentor() + userIds.push(mentor.id) + userIds.push(await makeTeamMember(p1.id)) + userIds.push(await makeTeamMember(p2.id)) + userIds.push(await makeTeamMember(p3.id)) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.bulkAssign({ + mentorIds: [mentor.id], + projectIds: [p1.id, p2.id, p3.id], + }) + + expect(sendStyledMock).not.toHaveBeenCalled() + expect(sendMentorBulkMock).not.toHaveBeenCalled() + expect(sendTeamIntroMock).not.toHaveBeenCalled() + }) +})