feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s

mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.

Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.

Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
This commit is contained in:
Matt
2026-05-26 14:25:41 +02:00
parent 195fc787a9
commit cb2a864b7f
2 changed files with 338 additions and 214 deletions

View File

@@ -648,33 +648,30 @@ export const mentorRouter = router({
}),
/**
* 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.
* Bulk-assign MANY mentors to MANY projects (cartesian product) in one
* call. Skips (mentor, project) pairs where the mentor is already an
* active mentor on that project. Each affected mentor receives ONE
* coalesced email listing only their newly-assigned projects. Each team
* whose project's MENTORING round is already open receives ONE intro
* email listing all their active mentors (including any pre-existing).
*/
bulkAssign: adminProcedure
.input(
z.object({
mentorId: z.string(),
mentorIds: z.array(z.string()).min(1),
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,
},
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: input.mentorIds } },
select: { id: true, name: true, email: true, roles: true },
})
if (!mentor || !mentor.roles.includes('MENTOR')) {
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
if (validMentors.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Selected user is not a mentor',
message: 'None of the selected users have the MENTOR role',
})
}
@@ -684,120 +681,169 @@ export const mentorRouter = router({
id: true,
title: true,
mentorAssignments: {
where: { mentorId: mentor.id, droppedAt: null },
select: { id: true },
where: {
mentorId: { in: validMentors.map((m) => m.id) },
droppedAt: null,
},
select: { mentorId: 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
// Track per-mentor (for emails) and per-project (for team intros) state.
const perMentor = new Map<
string,
{
email: string | null
name: string | null
assignmentIds: string[]
newProjects: { id: string; title: string }[]
skippedProjects: { id: string; title: string }[]
}
const created = await ctx.prisma.mentorAssignment.create({
data: {
projectId: p.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: ctx.user.id,
},
>()
for (const m of validMentors) {
perMentor.set(m.id, {
email: m.email ?? null,
name: m.name ?? null,
assignmentIds: [],
newProjects: [],
skippedProjects: [],
})
createdAssignmentIds.push(created.id)
newProjects.push({ id: p.id, title: p.title })
}
const touchedProjectIds = new Set<string>()
let totalAssigned = 0
let totalSkipped = 0
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',
for (const project of projects) {
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
for (const mentor of validMentors) {
const bucket = perMentor.get(mentor.id)!
if (alreadyOn.has(mentor.id)) {
bucket.skippedProjects.push({ id: project.id, title: project.title })
totalSkipped++
continue
}
const created = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: ctx.user.id,
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(
p.id,
mentoringPrs.roundId,
ctx.user.id,
ctx.prisma,
bucket.assignmentIds.push(created.id)
bucket.newProjects.push({ id: project.id, title: project.title })
touchedProjectIds.add(project.id)
totalAssigned++
await createNotification({
userId: mentor.id,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${project.title}".`,
linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title },
})
await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: 'high',
metadata: { projectName: project.title, mentorName: mentor.name },
})
}
// Best-effort: mark project IN_PROGRESS in the active MENTORING round
if (touchedProjectIds.has(project.id)) {
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: project.id,
round: {
roundType: 'MENTORING',
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
},
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(
project.id,
mentoringPrs.roundId,
ctx.user.id,
ctx.prisma,
)
}
} catch (e) {
console.error(
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
e,
)
}
} 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.
// One email per mentor, listing only their NEW projects.
for (const bucket of perMentor.values()) {
if (bucket.newProjects.length === 0 || !bucket.email) continue
await sendMentorBulkAssignmentEmail(
bucket.email,
bucket.name,
bucket.newProjects,
)
await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: createdAssignmentIds } },
where: { id: { in: bucket.assignmentIds } },
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)
// 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.
for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'User',
entityId: mentor.id,
entityType: 'BulkAssign',
entityId: 'multi',
detailsJson: {
mentorEmail: mentor.email,
assignedCount: newProjects.length,
skippedCount: skippedProjects.length,
newProjectIds: newProjects.map((p) => p.id),
mentorIds: validMentors.map((m) => m.id),
projectIds: input.projectIds,
totalAssigned,
totalSkipped,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
assignedCount: newProjects.length,
skippedCount: skippedProjects.length,
skippedProjects,
emailSent: newProjects.length > 0,
totalAssigned,
totalSkipped,
touchedProjectCount: touchedProjectIds.size,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
mentorName: b.name,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
emailsSent: Array.from(perMentor.values()).filter(
(b) => b.newProjects.length > 0 && b.email,
).length,
}
}),