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

@@ -8,8 +8,10 @@ import {
type PrismaClient,
} from '@prisma/client'
import {
sendMentorBulkAssignmentEmail,
sendMentorChangeRequestEmail,
sendMentorTeamAssignmentEmail,
sendTeamMentorIntroductionEmail,
} from '@/lib/email'
import {
getAIMentorSuggestions,
@@ -46,6 +48,83 @@ import {
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* Introduce the project team to ALL active mentors via email IF the project's
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
* whose assignment row has `teamIntroducedAt: null`. If the round is not yet
* active, this is a no-op — the activation step will fire the email instead.
* Never throws.
*/
async function introduceTeamToMentorsIfRoundOpen(
prisma: PrismaClient,
projectId: string,
): Promise<void> {
try {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
projectRoundStates: {
where: {
round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
},
select: { id: true },
take: 1,
},
mentorAssignments: {
where: { droppedAt: null, teamIntroducedAt: null },
select: {
id: true,
mentor: { select: { name: true, email: true } },
},
},
teamMembers: {
select: { user: { select: { name: true, email: true } } },
},
submittedByEmail: true,
submittedBy: { select: { name: true } },
},
})
if (!project) return
if (project.projectRoundStates.length === 0) return // round not active yet
const mentors = project.mentorAssignments
.filter((a) => a.mentor?.email)
.map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
if (mentors.length === 0) return
const recipients = new Map<string, { name: string | null }>()
for (const tm of project.teamMembers) {
if (tm.user?.email) {
recipients.set(tm.user.email, { name: tm.user.name })
}
}
if (
project.submittedByEmail &&
!recipients.has(project.submittedByEmail)
) {
recipients.set(project.submittedByEmail, {
name: project.submittedBy?.name ?? null,
})
}
for (const [email, { name }] of recipients) {
await sendTeamMentorIntroductionEmail(
email,
name,
project.title,
project.id,
mentors,
)
}
await prisma.mentorAssignment.updateMany({
where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
data: { teamIntroducedAt: new Date() },
})
} catch (e) {
console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
}
}
/**
* Throws TRPCError if the given user is neither the assigned mentor
* nor a team member of the project linked to the assignment.
@@ -414,6 +493,10 @@ export const mentorRouter = router({
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
// If the project's MENTORING round is already open, introduce the team
// to their mentor(s) by email now. Otherwise the activation hook fires it.
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
return assignment
}),
@@ -564,6 +647,160 @@ export const mentorRouter = router({
return assignment
}),
/**
* Bulk-assign ONE mentor to MANY projects in a single transaction. Skips
* projects where this mentor is already an active mentor. Sends a single
* coalesced email to the mentor listing all newly-assigned projects.
* In-app notifications are still per-project so each team is notified.
*/
bulkAssign: adminProcedure
.input(
z.object({
mentorId: z.string(),
projectIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const mentor = await ctx.prisma.user.findUnique({
where: { id: input.mentorId },
select: {
id: true,
name: true,
email: true,
roles: true,
status: true,
},
})
if (!mentor || !mentor.roles.includes('MENTOR')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Selected user is not a mentor',
})
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
select: {
id: true,
title: true,
mentorAssignments: {
where: { mentorId: mentor.id, droppedAt: null },
select: { id: true },
},
},
})
const newProjects: { id: string; title: string }[] = []
const skippedProjects: { id: string; title: string }[] = []
const createdAssignmentIds: string[] = []
for (const p of projects) {
if (p.mentorAssignments.length > 0) {
skippedProjects.push({ id: p.id, title: p.title })
continue
}
const created = await ctx.prisma.mentorAssignment.create({
data: {
projectId: p.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: ctx.user.id,
},
})
createdAssignmentIds.push(created.id)
newProjects.push({ id: p.id, title: p.title })
await createNotification({
userId: mentor.id,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${p.title}".`,
linkUrl: `/mentor/projects/${p.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: p.title },
})
await notifyProjectTeam(p.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${p.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: p.title, mentorName: mentor.name },
})
// Trigger MENTORING round IN_PROGRESS state transition (best-effort)
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: p.id,
round: {
roundType: 'MENTORING',
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
},
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(
p.id,
mentoringPrs.roundId,
ctx.user.id,
ctx.prisma,
)
}
} catch (e) {
console.error(
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
e,
)
}
}
// One coalesced email per mentor, with all NEW project assignments.
if (newProjects.length > 0 && mentor.email) {
await sendMentorBulkAssignmentEmail(mentor.email, mentor.name, newProjects)
// Stamp notificationSentAt on every row we just created so single-
// assignment retries don't re-notify.
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: createdAssignmentIds } },
data: { notificationSentAt: new Date() },
})
}
// For each newly-assigned project whose MENTORING round is already open,
// introduce the team to the mentor(s) by email.
for (const p of newProjects) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'User',
entityId: mentor.id,
detailsJson: {
mentorEmail: mentor.email,
assignedCount: newProjects.length,
skippedCount: skippedProjects.length,
newProjectIds: newProjects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
assignedCount: newProjects.length,
skippedCount: skippedProjects.length,
skippedProjects,
emailSent: newProjects.length > 0,
}
}),
/**
* Remove mentor assignment.
*
@@ -842,6 +1079,18 @@ export const mentorRouter = router({
let assigned = 0
let unassignable = 0
// Coalesce per-mentor so we send ONE email per mentor at the end of the
// batch, even when the algorithm assigns the same mentor to several teams.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
projects: { id: string; title: string }[]
}
>()
for (const { project } of projectStates) {
try {
let mentorId: string | null = null
@@ -883,7 +1132,7 @@ export const mentorRouter = router({
aiReasoning,
},
include: {
mentor: { select: { id: true, name: true } },
mentor: { select: { id: true, name: true, email: true } },
project: { select: { title: true } },
},
})
@@ -921,6 +1170,17 @@ export const mentorRouter = router({
},
})
// Accumulate for the coalesced email
const bucket = perMentor.get(mentorId) ?? {
email: assignment.mentor.email ?? null,
name: assignment.mentor.name ?? null,
assignmentIds: [],
projects: [],
}
bucket.assignmentIds.push(assignment.id)
bucket.projects.push({ id: project.id, title: assignment.project.title })
perMentor.set(mentorId, bucket)
assigned++
} catch (err) {
console.error(
@@ -932,6 +1192,46 @@ 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.
const roundStatus = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { status: true },
})
if (roundStatus?.status === 'ROUND_ACTIVE') {
const introducedProjects = new Set<string>()
for (const bucket of perMentor.values()) {
for (const p of bucket.projects) {
if (introducedProjects.has(p.id)) continue
introducedProjects.add(p.id)
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
}
}
}
const skipped = await ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,