feat(mentor): defer all assignment emails until round opens + per-project bulk UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s

Email policy
- mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress
  outbound email entirely when the project's MENTORING round is still
  ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications
  still fire), but notificationSentAt and teamIntroducedAt remain null so
  activateRound can pick them up later.
- activateRound, when activating a MENTORING round, now does a coalesced
  mentor-side email pass in addition to the existing team-side intro pass.
  Every (mentorId) bucket of pending assignments in this round gets exactly
  one combined email; the row stamps prevent duplicates on re-activation.
- The "send immediately" path is preserved for assignments made while the
  round is already ROUND_ACTIVE — mentors and teams stay in the loop in
  real time, but staging during draft is silent.

Per-project bulk UI
- The /admin/projects/[id]/mentor manual picker now has a checkbox column,
  header select-all, and a primary-tinted action toolbar that appears when
  one or more candidates are selected. Submitting calls mentor.bulkAssign
  with the single projectId so the cartesian server path handles dedup,
  coalesced emails, and team intros uniformly with the round-page bulk.
This commit is contained in:
Matt
2026-05-26 14:48:38 +02:00
parent cb2a864b7f
commit c4f7216bc1
3 changed files with 265 additions and 40 deletions

View File

@@ -48,6 +48,27 @@ import {
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* True if the project is enrolled in a MENTORING round that is still
* ROUND_DRAFT. Used to defer mentor- and team-side emails until the round
* opens, so admins can stage assignments without sending notifications.
* If the project isn't in a MENTORING round at all, returns false
* (i.e. send emails normally — there's no round-open event to wait for).
*/
async function shouldDeferEmailsForProject(
prisma: PrismaClient,
projectId: string,
): Promise<boolean> {
const draftRoundEnrollment = await prisma.projectRoundState.findFirst({
where: {
projectId,
round: { roundType: 'MENTORING', status: 'ROUND_DRAFT' },
},
select: { id: true },
})
return draftRoundEnrollment !== null
}
/**
* Introduce the project team to ALL active mentors via email IF the project's
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
@@ -455,11 +476,18 @@ export const mentorRouter = router({
},
})
// Send per-team email notification once per assignment row. Idempotency
// is enforced via MentorAssignment.notificationSentAt — a fresh row has
// it null. If the same mentor is later dropped and re-assigned (new row,
// fresh id), a new email is sent — intentional.
if (assignment.notificationSentAt == null && assignment.mentor.email) {
// 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 &&
assignment.mentor.email
) {
await sendMentorTeamAssignmentEmail(
assignment.mentor.email,
assignment.mentor.name,
@@ -789,23 +817,45 @@ export const mentorRouter = router({
}
}
// One email per mentor, listing only their NEW projects.
// 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)
}
}
// 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()) {
if (bucket.newProjects.length === 0 || !bucket.email) continue
if (!bucket.email) continue
const sendableProjects = bucket.newProjects.filter(
(p) => !draftProjectIds.has(p.id),
)
if (sendableProjects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.newProjects,
sendableProjects,
)
// Only stamp notificationSentAt for the assignments that correspond
// to projects we actually emailed about. Draft-deferred ones stay
// unstamped so activateRound picks them up.
const sendableProjectIds = new Set(sendableProjects.map((p) => p.id))
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
where: {
id: { in: bucket.assignmentIds },
projectId: { in: Array.from(sendableProjectIds) },
},
data: { notificationSentAt: new Date() },
})
}
// One team-intro email per touched project (only if MENTORING round
// is currently ROUND_ACTIVE). The helper lists ALL active mentors on
// the project, including any pre-existing co-mentors.
// Team-intro email per touched project (only fires if the round is
// already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
// projects are naturally deferred to activateRound's intro pass).
for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
}
@@ -1238,36 +1288,37 @@ export const mentorRouter = router({
}
}
// Send one coalesced email per mentor, then stamp notificationSentAt so
// re-running the bulk doesn't double-notify.
for (const bucket of perMentor.values()) {
if (!bucket.email || bucket.projects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
try {
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error(
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
e,
)
}
}
// If the mentoring round is already open at the time of bulk auto-fill,
// introduce each team to their new mentor(s). If the round is still
// DRAFT, the activation hook will email later.
// Defer all emails when the round is still ROUND_DRAFT — activateRound
// will coalesce and send them when the admin opens the round. Stamp
// notificationSentAt only for assignments we actually email about, so
// activateRound's `notificationSentAt IS NULL` filter catches the rest.
const roundStatus = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { status: true },
})
if (roundStatus?.status === 'ROUND_ACTIVE') {
const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'
if (isRoundLive) {
for (const bucket of perMentor.values()) {
if (!bucket.email || bucket.projects.length === 0) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
try {
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
} catch (e) {
console.error(
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
e,
)
}
}
const introducedProjects = new Set<string>()
for (const bucket of perMentor.values()) {
for (const p of bucket.projects) {
@@ -1277,6 +1328,8 @@ export const mentorRouter = router({
}
}
}
// If the round is still ROUND_DRAFT, no emails fire here — the assignments
// remain unstamped and activateRound will batch-send when the round opens.
const skipped = await ctx.prisma.projectRoundState.count({
where: {