feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
This commit is contained in:
@@ -16,6 +16,7 @@ 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'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -211,6 +212,86 @@ export async function activateRound(
|
||||
} catch (mentoringError) {
|
||||
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
|
||||
}
|
||||
|
||||
// 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
|
||||
// mentor was assigned (and notified) before the round opened.
|
||||
try {
|
||||
const projectsToIntroduce = await prisma.project.findMany({
|
||||
where: {
|
||||
projectRoundStates: { some: { roundId } },
|
||||
mentorAssignments: {
|
||||
some: { droppedAt: null, teamIntroducedAt: null },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
mentorAssignments: {
|
||||
where: { droppedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
teamIntroducedAt: true,
|
||||
mentor: { select: { name: true, email: true } },
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
select: { user: { select: { name: true, email: true } } },
|
||||
},
|
||||
submittedByEmail: true,
|
||||
submittedBy: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
for (const p of projectsToIntroduce) {
|
||||
const mentors = p.mentorAssignments
|
||||
.filter((a) => a.mentor?.email)
|
||||
.map((a) => ({
|
||||
name: a.mentor.name,
|
||||
email: a.mentor.email,
|
||||
}))
|
||||
if (mentors.length === 0) continue
|
||||
|
||||
// Build a unique recipient set: team-member users with emails,
|
||||
// plus the original submitter (in case they're not on the team yet).
|
||||
const recipients = new Map<string, { name: string | null }>()
|
||||
for (const tm of p.teamMembers) {
|
||||
if (tm.user?.email) {
|
||||
recipients.set(tm.user.email, { name: tm.user.name })
|
||||
}
|
||||
}
|
||||
if (
|
||||
p.submittedByEmail &&
|
||||
!recipients.has(p.submittedByEmail)
|
||||
) {
|
||||
recipients.set(p.submittedByEmail, {
|
||||
name: p.submittedBy?.name ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [email, { name }] of recipients) {
|
||||
await sendTeamMentorIntroductionEmail(email, name, p.title, p.id, mentors)
|
||||
}
|
||||
|
||||
// Stamp every mentor-assignment row so re-activation doesn't re-send.
|
||||
const idsToStamp = p.mentorAssignments
|
||||
.filter((a) => a.teamIntroducedAt == null)
|
||||
.map((a) => a.id)
|
||||
if (idsToStamp.length > 0) {
|
||||
await prisma.mentorAssignment.updateMany({
|
||||
where: { id: { in: idsToStamp } },
|
||||
data: { teamIntroducedAt: new Date() },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (projectsToIntroduce.length > 0) {
|
||||
console.log(
|
||||
`[RoundEngine] MENTORING round open: introduced mentors for ${projectsToIntroduce.length} project(s)`,
|
||||
)
|
||||
}
|
||||
} catch (introError) {
|
||||
console.error('[RoundEngine] Team-mentor introduction failed (non-fatal):', introError)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user