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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user