diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index df4f1c8..612b4b3 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -15,6 +15,7 @@ import { import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' +import { Checkbox } from '@/components/ui/checkbox' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Progress } from '@/components/ui/progress' import { Input } from '@/components/ui/input' @@ -74,6 +75,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { assignmentId: string mentorName: string } | null>(null) + const [selectedCandidateIds, setSelectedCandidateIds] = useState>( + new Set(), + ) 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.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId }) + utils.mentor.getMentorPool.invalidate() setPendingMentorId(null) }, 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({ onSuccess: () => { toast.success('Mentor removed') @@ -383,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { className="pl-9" /> + {selectedCandidateIds.size > 0 && ( +
+
+ {selectedCandidateIds.size}{' '} + + mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected + +
+
+ + +
+
+ )} {candidatesLoading ? (
{[1, 2, 3].map((i) => ( @@ -400,6 +464,28 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { + + 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" + /> + Mentor Expertise Country @@ -410,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { {filteredCandidates.map((c) => ( - + + + + 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}`} + /> +
{c.name ?? 'Unnamed'}
{c.email}
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 6df3148..3af18b8 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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 { + 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() + 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() 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: { diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index e8990cb..2bb0a7d 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -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