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

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:
Matt
2026-05-26 14:04:32 +02:00
parent 921019aaa4
commit 195fc787a9
7 changed files with 1025 additions and 7 deletions

View File

@@ -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 {