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 } } },
|
||||
})
|
||||
|
||||
// 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<string>()
|
||||
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<string>()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user