fix(mentor): defer in-app-notification emails when mentoring round is draft
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Mentor-assignment flows (mentor.assign, autoAssign, bulkAssign, bulkAutoAssign, autoAssignBulkForRound) call createNotification and notifyProjectTeam for MENTEE_ASSIGNED / MENTOR_ASSIGNED. Both notification types have NotificationEmailSetting.sendEmail = true, so the notification system fires its own styled email in addition to the explicit mentor-team / coalesced emails on the same code path. The earlier defer-emails-until-round-open fix only gated the explicit sendMentorBulkAssignmentEmail / sendMentorTeamAssignmentEmail calls; this parallel email path kept firing immediately at every assignment. Result on prod 2026-05-26: Camille Lopez (assigned to 9 projects via two bulk_assigns) received 7 emails at 15:04 + 1 at 15:32 from the notification-system path during draft, plus 1 coalesced email at the 18:20 round activation = 9 sends instead of 1. Every PEARL team member (and equivalents on other teams) received 3 emails for the same reason. Fix - Add `skipEmail?: boolean` to CreateNotificationParams, createNotification, createBulkNotifications, and (via spread) notifyProjectTeam. When true the in-app notification row still fires but the parallel email send is suppressed; the coalesced mentor email and team intro at activateRound time remain the single source of email truth. - Wire it up in every mentor-assignment site: compute the existing shouldDeferEmailsForProject gate once before the createNotification / notifyProjectTeam calls and pass `skipEmail: deferThisEmail`. bulkAssign precomputes draftProjectIds for the whole batch. autoAssignBulkForRound uses the round's status directly. - New regression suite (mentor-email-deferral.test.ts, 3 cases): vi.mocks @/lib/email, asserts zero outbound sends when round is ROUND_DRAFT, confirms in-app notification rows still get written, and re-verifies the ACTIVE-round path still emails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -465,6 +465,15 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor of new mentee
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: input.mentorId,
|
userId: input.mentorId,
|
||||||
@@ -479,6 +488,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team of mentor assignment
|
// Notify project team of mentor assignment
|
||||||
@@ -493,15 +503,9 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
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 (
|
if (
|
||||||
!deferThisEmail &&
|
!deferThisEmail &&
|
||||||
assignment.notificationSentAt == null &&
|
assignment.notificationSentAt == null &&
|
||||||
@@ -661,6 +665,13 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor of new mentee
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: mentorId,
|
userId: mentorId,
|
||||||
@@ -675,6 +686,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team of mentor assignment
|
// Notify project team of mentor assignment
|
||||||
@@ -689,6 +701,7 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
return assignment
|
return assignment
|
||||||
@@ -777,8 +790,22 @@ export const mentorRouter = router({
|
|||||||
let totalAssigned = 0
|
let totalAssigned = 0
|
||||||
let totalSkipped = 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<string>()
|
||||||
|
for (const project of projects) {
|
||||||
|
if (await shouldDeferEmailsForProject(ctx.prisma, project.id)) {
|
||||||
|
draftProjectIds.add(project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
|
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
|
||||||
|
const deferForThis = draftProjectIds.has(project.id)
|
||||||
for (const mentor of validMentors) {
|
for (const mentor of validMentors) {
|
||||||
const bucket = perMentor.get(mentor.id)!
|
const bucket = perMentor.get(mentor.id)!
|
||||||
if (alreadyOn.has(mentor.id)) {
|
if (alreadyOn.has(mentor.id)) {
|
||||||
@@ -808,6 +835,7 @@ export const mentorRouter = router({
|
|||||||
linkLabel: 'View Project',
|
linkLabel: 'View Project',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
metadata: { projectName: project.title },
|
metadata: { projectName: project.title },
|
||||||
|
skipEmail: deferForThis,
|
||||||
})
|
})
|
||||||
|
|
||||||
await notifyProjectTeam(project.id, {
|
await notifyProjectTeam(project.id, {
|
||||||
@@ -818,6 +846,7 @@ export const mentorRouter = router({
|
|||||||
linkLabel: 'View Project',
|
linkLabel: 'View Project',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
metadata: { projectName: project.title, mentorName: mentor.name },
|
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
|
// `draftProjectIds` was computed before the assignment loop above.
|
||||||
// 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<string>()
|
|
||||||
for (const projectId of touchedProjectIds) {
|
|
||||||
if (await shouldDeferEmailsForProject(ctx.prisma, projectId)) {
|
|
||||||
draftProjectIds.add(projectId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One email per mentor, listing only their NEW projects whose mentoring
|
// One email per mentor, listing only their NEW projects whose mentoring
|
||||||
// round is NOT in draft. If every new project is deferred, no email.
|
// round is NOT in draft. If every new project is deferred, no email.
|
||||||
for (const bucket of perMentor.values()) {
|
for (const bucket of perMentor.values()) {
|
||||||
@@ -1083,6 +1103,12 @@ export const mentorRouter = router({
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
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
|
// Notify mentor
|
||||||
await createNotification({
|
await createNotification({
|
||||||
userId: mentorId,
|
userId: mentorId,
|
||||||
@@ -1097,6 +1123,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify project team
|
// Notify project team
|
||||||
@@ -1111,6 +1138,7 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferThisEmail,
|
||||||
})
|
})
|
||||||
|
|
||||||
assigned++
|
assigned++
|
||||||
@@ -1164,7 +1192,7 @@ export const mentorRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { id: true, roundType: true, configJson: true },
|
select: { id: true, roundType: true, configJson: true, status: true },
|
||||||
})
|
})
|
||||||
if (round.roundType !== 'MENTORING') {
|
if (round.roundType !== 'MENTORING') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1211,6 +1239,11 @@ export const mentorRouter = router({
|
|||||||
let assigned = 0
|
let assigned = 0
|
||||||
let unassignable = 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
|
// 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.
|
// batch, even when the algorithm assigns the same mentor to several teams.
|
||||||
const perMentor = new Map<
|
const perMentor = new Map<
|
||||||
@@ -1287,6 +1320,7 @@ export const mentorRouter = router({
|
|||||||
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
teamLeadEmail: teamLead?.user?.email,
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferEmailsForRound,
|
||||||
})
|
})
|
||||||
|
|
||||||
await notifyProjectTeam(project.id, {
|
await notifyProjectTeam(project.id, {
|
||||||
@@ -1300,6 +1334,7 @@ export const mentorRouter = router({
|
|||||||
projectName: assignment.project.title,
|
projectName: assignment.project.title,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
},
|
},
|
||||||
|
skipEmail: deferEmailsForRound,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Accumulate for the coalesced email
|
// Accumulate for the coalesced email
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ interface CreateNotificationParams {
|
|||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
groupKey?: string
|
groupKey?: string
|
||||||
expiresAt?: Date
|
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,
|
metadata,
|
||||||
groupKey,
|
groupKey,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
skipEmail,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
// Determine icon and priority if not provided
|
// Determine icon and priority if not provided
|
||||||
@@ -241,8 +250,11 @@ export async function createNotification(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if we should also send an email
|
// 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)
|
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +270,8 @@ export async function createBulkNotifications(params: {
|
|||||||
icon?: string
|
icon?: string
|
||||||
priority?: NotificationPriority
|
priority?: NotificationPriority
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
/** See {@link CreateNotificationParams.skipEmail}. */
|
||||||
|
skipEmail?: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
userIds,
|
userIds,
|
||||||
@@ -269,6 +283,7 @@ export async function createBulkNotifications(params: {
|
|||||||
icon,
|
icon,
|
||||||
priority,
|
priority,
|
||||||
metadata,
|
metadata,
|
||||||
|
skipEmail,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
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
|
// Check email settings once, then send emails only if enabled
|
||||||
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||||
where: { notificationType: type },
|
where: { notificationType: type },
|
||||||
|
|||||||
228
tests/unit/mentor-email-deferral.test.ts
Normal file
228
tests/unit/mentor-email-deferral.test.ts
Normal file
@@ -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<typeof import('@/lib/email')>('@/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<typeof vi.fn>
|
||||||
|
const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType<typeof vi.fn>
|
||||||
|
const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user