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

@@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -74,6 +75,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
assignmentId: string assignmentId: string
mentorName: string mentorName: string
} | null>(null) } | null>(null)
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
new Set(),
)
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId }) const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
@@ -111,6 +115,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
utils.project.get.invalidate({ id: projectId }) utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setPendingMentorId(null) setPendingMentorId(null)
}, },
onError: (err) => { onError: (err) => {
@@ -119,6 +124,30 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
}, },
}) })
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.totalAssigned === 0) {
toast.info('No new assignments — every chosen mentor was already on this team.')
} else {
toast.success(
`Added ${result.totalAssigned} mentor${
result.totalAssigned === 1 ? '' : 's'
} to this team${
result.emailsSent > 0
? ` · ${result.emailsSent} email${result.emailsSent === 1 ? '' : 's'} sent`
: ' · emails will go out when the mentoring round opens'
}`,
)
}
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
utils.mentor.getMentorPool.invalidate()
setSelectedCandidateIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.mentor.unassign.useMutation({ const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Mentor removed') toast.success('Mentor removed')
@@ -383,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
className="pl-9" className="pl-9"
/> />
</div> </div>
{selectedCandidateIds.size > 0 && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-medium">{selectedCandidateIds.size}</span>{' '}
<span className="text-muted-foreground">
mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() =>
bulkAssignMutation.mutate({
mentorIds: Array.from(selectedCandidateIds),
projectIds: [projectId],
})
}
disabled={bulkAssignMutation.isPending}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Add {selectedCandidateIds.size} mentor
{selectedCandidateIds.size === 1 ? '' : 's'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedCandidateIds(new Set())}
>
Clear
</Button>
</div>
</div>
)}
{candidatesLoading ? ( {candidatesLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
@@ -400,6 +464,28 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-10">
<Checkbox
checked={
filteredCandidates.length > 0 &&
filteredCandidates.every((c) =>
selectedCandidateIds.has(c.id),
)
}
onCheckedChange={(checked) => {
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) {
filteredCandidates.forEach((c) => next.add(c.id))
} else {
filteredCandidates.forEach((c) => next.delete(c.id))
}
return next
})
}}
aria-label="Select all visible mentors"
/>
</TableHead>
<TableHead>Mentor</TableHead> <TableHead>Mentor</TableHead>
<TableHead>Expertise</TableHead> <TableHead>Expertise</TableHead>
<TableHead>Country</TableHead> <TableHead>Country</TableHead>
@@ -410,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredCandidates.map((c) => ( {filteredCandidates.map((c) => (
<TableRow key={c.id}> <TableRow
key={c.id}
data-state={
selectedCandidateIds.has(c.id) ? 'selected' : undefined
}
>
<TableCell>
<Checkbox
checked={selectedCandidateIds.has(c.id)}
onCheckedChange={(checked) =>
setSelectedCandidateIds((prev) => {
const next = new Set(prev)
if (checked) next.add(c.id)
else next.delete(c.id)
return next
})
}
aria-label={`Select ${c.name ?? c.email}`}
/>
</TableCell>
<TableCell> <TableCell>
<div className="font-medium">{c.name ?? 'Unnamed'}</div> <div className="font-medium">{c.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{c.email}</div> <div className="text-muted-foreground text-xs">{c.email}</div>

View File

@@ -48,6 +48,27 @@ import {
verifyMentorUploadToken, verifyMentorUploadToken,
} from '@/lib/mentor-upload-token' } 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 * Introduce the project team to ALL active mentors via email IF the project's
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors * 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 // Defer the mentor-side email if the project's MENTORING round is still
// is enforced via MentorAssignment.notificationSentAt — a fresh row has // ROUND_DRAFT — `activateRound` will coalesce and send when the admin
// it null. If the same mentor is later dropped and re-assigned (new row, // opens the round. Otherwise fire the per-assignment email immediately.
// fresh id), a new email is sent — intentional. const deferThisEmail = await shouldDeferEmailsForProject(
if (assignment.notificationSentAt == null && assignment.mentor.email) { ctx.prisma,
input.projectId,
)
if (
!deferThisEmail &&
assignment.notificationSentAt == null &&
assignment.mentor.email
) {
await sendMentorTeamAssignmentEmail( await sendMentorTeamAssignmentEmail(
assignment.mentor.email, assignment.mentor.email,
assignment.mentor.name, 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()) { 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( await sendMentorBulkAssignmentEmail(
bucket.email, bucket.email,
bucket.name, 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({ await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: bucket.assignmentIds } }, where: {
id: { in: bucket.assignmentIds },
projectId: { in: Array.from(sendableProjectIds) },
},
data: { notificationSentAt: new Date() }, data: { notificationSentAt: new Date() },
}) })
} }
// One team-intro email per touched project (only if MENTORING round // Team-intro email per touched project (only fires if the round is
// is currently ROUND_ACTIVE). The helper lists ALL active mentors on // already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
// the project, including any pre-existing co-mentors. // projects are naturally deferred to activateRound's intro pass).
for (const projectId of touchedProjectIds) { for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId) await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
} }
@@ -1238,8 +1288,17 @@ export const mentorRouter = router({
} }
} }
// Send one coalesced email per mentor, then stamp notificationSentAt so // Defer all emails when the round is still ROUND_DRAFT — activateRound
// re-running the bulk doesn't double-notify. // 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 },
})
const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'
if (isRoundLive) {
for (const bucket of perMentor.values()) { for (const bucket of perMentor.values()) {
if (!bucket.email || bucket.projects.length === 0) continue if (!bucket.email || bucket.projects.length === 0) continue
await sendMentorBulkAssignmentEmail( await sendMentorBulkAssignmentEmail(
@@ -1260,14 +1319,6 @@ export const mentorRouter = router({
} }
} }
// 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.
const roundStatus = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { status: true },
})
if (roundStatus?.status === 'ROUND_ACTIVE') {
const introducedProjects = new Set<string>() const introducedProjects = new Set<string>()
for (const bucket of perMentor.values()) { for (const bucket of perMentor.values()) {
for (const p of bucket.projects) { 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({ const skipped = await ctx.prisma.projectRoundState.count({
where: { where: {

View File

@@ -16,7 +16,10 @@ import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs' import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent' import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization' import { processRoundClose } from './round-finalization'
import { sendTeamMentorIntroductionEmail } from '@/lib/email' import {
sendMentorBulkAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -213,6 +216,70 @@ export async function activateRound(
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError) 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. // Introduce teams to their mentors via email when the round opens.
// Idempotent via MentorAssignment.teamIntroducedAt — separate from the // Idempotent via MentorAssignment.teamIntroducedAt — separate from the
// mentor-side notificationSentAt so the team email fires even when the // mentor-side notificationSentAt so the team email fires even when the