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

@@ -16,7 +16,10 @@ import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
import { sendTeamMentorIntroductionEmail } from '@/lib/email'
import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -213,6 +216,70 @@ export async function activateRound(
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
}
// Mentor-side coalesced emails on round open. Picks up every assignment
// for projects in this round whose notificationSentAt is null (i.e.
// assignments made while the round was still in draft), groups by
// mentor, and sends a single combined email per mentor listing all
// their projects in this round.
try {
const pendingAssignments = await prisma.mentorAssignment.findMany({
where: {
droppedAt: null,
notificationSentAt: null,
project: { projectRoundStates: { some: { roundId } } },
},
select: {
id: true,
mentorId: true,
mentor: { select: { name: true, email: true } },
project: { select: { id: true, title: true } },
},
})
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
}
>()
for (const a of pendingAssignments) {
if (!a.mentor?.email) continue
const bucket = perMentor.get(a.mentorId) ?? {
email: a.mentor.email,
name: a.mentor.name,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(a.id)
bucket.projects.push({ id: a.project.id, title: a.project.title })
perMentor.set(a.mentorId, bucket)
}
for (const bucket of perMentor.values()) {
if (bucket.projects.length === 0 || !bucket.email) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.projects,
)
await prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() },
})
}
if (perMentor.size > 0) {
console.log(
`[RoundEngine] MENTORING round open: notified ${perMentor.size} mentor(s) about their assignments`,
)
}
} catch (mentorEmailError) {
console.error(
'[RoundEngine] Mentor-side coalesced notification failed (non-fatal):',
mentorEmailError,
)
}
// Introduce teams to their mentors via email when the round opens.
// Idempotent via MentorAssignment.teamIntroducedAt — separate from the
// mentor-side notificationSentAt so the team email fires even when the