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

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:
Matt
2026-05-27 13:12:41 +02:00
parent 61dfc608cd
commit 03526fca97
3 changed files with 300 additions and 20 deletions

View File

@@ -169,6 +169,14 @@ interface CreateNotificationParams {
metadata?: Record<string, unknown>
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<string, unknown>
/** See {@link CreateNotificationParams.skipEmail}. */
skipEmail?: boolean
}): Promise<void> {
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 },