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

@@ -43,7 +43,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
const [filter, setFilter] = useState<Filter>('all') const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false) const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorId, setChosenMentorId] = useState<string>('') const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('') const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
@@ -63,17 +63,28 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({ const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
if (result.assignedCount === 0 && result.skippedCount > 0) { if (result.totalAssigned === 0 && result.totalSkipped > 0) {
toast.info( toast.info(
`No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`, `No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
) )
} else { } else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success( toast.success(
`Assigned mentor to ${result.assignedCount} project${ `Created ${result.totalAssigned} assignment${
result.assignedCount === 1 ? '' : 's' result.totalAssigned === 1 ? '' : 's'
}${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${ } across ${result.touchedProjectCount} project${
result.emailSent ? ' · email sent' : '' result.touchedProjectCount === 1 ? '' : 's'
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
result.emailsSent > 0
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
: ''
}`, }`,
{
description:
mentorCount > 1
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
: undefined,
},
) )
} }
utils.round.listMentoringProjects.invalidate({ roundId }) utils.round.listMentoringProjects.invalidate({ roundId })
@@ -83,7 +94,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
utils.mentor.getRoundStats.invalidate({ roundId }) utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate() utils.project.list.invalidate()
setSelected(new Set()) setSelected(new Set())
setChosenMentorId('') setChosenMentorIds(new Set())
setMentorSearch('') setMentorSearch('')
setBulkOpen(false) setBulkOpen(false)
}, },
@@ -442,7 +453,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) { if (!next) {
setBulkOpen(false) setBulkOpen(false)
setChosenMentorId('') setChosenMentorIds(new Set())
setMentorSearch('') setMentorSearch('')
} }
}} }}
@@ -450,17 +461,55 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Assign mentor to {selected.size} project Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'} {selected.size === 1 ? '' : 's'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Choose one mentor they'll receive a single email listing every Tick any number of mentors. Each chosen mentor will be added to
new assignment. Projects where they're already an active mentor every selected project they aren&apos;t already on. Each mentor
will be skipped. receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
{(() => {
const allMentors = mentorPool?.mentors ?? []
const chosenMentors = allMentors.filter((m) =>
chosenMentorIds.has(m.id),
)
const upperBound = chosenMentorIds.size * selected.size
return (
<>
{chosenMentors.length > 0 && (
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
{chosenMentors.map((m) => (
<Badge
key={m.id}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
{m.name ?? m.email}
<button
type="button"
aria-label={`Remove ${m.name ?? m.email}`}
className="rounded-full p-0.5 hover:bg-foreground/10"
onClick={() =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
next.delete(m.id)
return next
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@@ -470,12 +519,12 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
className="pl-8" className="pl-8"
/> />
</div> </div>
<div className="max-h-72 overflow-y-auto rounded-md border"> <div className="max-h-72 overflow-y-auto rounded-md border">
{(() => { {(() => {
const mentors = mentorPool?.mentors ?? []
const q = mentorSearch.trim().toLowerCase() const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q const filteredMentors = q
? mentors.filter((m) => ? allMentors.filter((m) =>
[ [
m.name ?? '', m.name ?? '',
m.email, m.email,
@@ -486,8 +535,8 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
.toLowerCase() .toLowerCase()
.includes(q), .includes(q),
) )
: mentors : allMentors
if (mentors.length === 0) { if (allMentors.length === 0) {
return ( return (
<p className="p-4 text-center text-sm text-muted-foreground"> <p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '} No mentors in the pool yet.{' '}
@@ -509,7 +558,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
) )
} }
return filteredMentors.map((m) => { return filteredMentors.map((m) => {
const isChosen = chosenMentorId === m.id const isChosen = chosenMentorIds.has(m.id)
return ( return (
<label <label
key={m.id} key={m.id}
@@ -517,12 +566,18 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
isChosen ? 'bg-accent' : 'hover:bg-muted/50' isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`} }`}
> >
<input <Checkbox
type="radio"
name="bulk-mentor"
className="mt-1" className="mt-1"
checked={isChosen} checked={isChosen}
onChange={() => setChosenMentorId(m.id)} onCheckedChange={(checked) =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
if (checked) next.add(m.id)
else next.delete(m.id)
return next
})
}
aria-label={`Toggle ${m.name ?? m.email}`}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium"> <div className="font-medium">
@@ -544,7 +599,10 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
</Badge> </Badge>
))} ))}
{m.expertiseTags.length > 4 && ( {m.expertiseTags.length > 4 && (
<Badge variant="outline" className="text-[10px]"> <Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4} +{m.expertiseTags.length - 4}
</Badge> </Badge>
)} )}
@@ -561,6 +619,23 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
}) })
})()} })()}
</div> </div>
{chosenMentorIds.size > 0 && (
<p className="text-xs text-muted-foreground">
Will create up to{' '}
<span className="font-medium tabular-nums text-foreground">
{upperBound}
</span>{' '}
assignment{upperBound === 1 ? '' : 's'} (
{chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
project{selected.size === 1 ? '' : 's'}). Pairs that
already exist are skipped.
</p>
)}
</>
)
})()}
</div> </div>
<DialogFooter> <DialogFooter>
@@ -568,7 +643,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setBulkOpen(false) setBulkOpen(false)
setChosenMentorId('') setChosenMentorIds(new Set())
setMentorSearch('') setMentorSearch('')
}} }}
> >
@@ -577,16 +652,19 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<Button <Button
onClick={() => onClick={() =>
bulkAssignMutation.mutate({ bulkAssignMutation.mutate({
mentorId: chosenMentorId, mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected), projectIds: Array.from(selected),
}) })
} }
disabled={!chosenMentorId || bulkAssignMutation.isPending} disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
> >
{bulkAssignMutation.isPending && ( {bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)} )}
Assign to {selected.size} project Assign {chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
{selected.size === 1 ? '' : 's'} {selected.size === 1 ? '' : 's'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -648,33 +648,30 @@ export const mentorRouter = router({
}), }),
/** /**
* Bulk-assign ONE mentor to MANY projects in a single transaction. Skips * Bulk-assign MANY mentors to MANY projects (cartesian product) in one
* projects where this mentor is already an active mentor. Sends a single * call. Skips (mentor, project) pairs where the mentor is already an
* coalesced email to the mentor listing all newly-assigned projects. * active mentor on that project. Each affected mentor receives ONE
* In-app notifications are still per-project so each team is notified. * 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 bulkAssign: adminProcedure
.input( .input(
z.object({ z.object({
mentorId: z.string(), mentorIds: z.array(z.string()).min(1),
projectIds: z.array(z.string()).min(1), projectIds: z.array(z.string()).min(1),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const mentor = await ctx.prisma.user.findUnique({ const mentors = await ctx.prisma.user.findMany({
where: { id: input.mentorId }, where: { id: { in: input.mentorIds } },
select: { select: { id: true, name: true, email: true, roles: true },
id: true,
name: true,
email: true,
roles: true,
status: true,
},
}) })
if (!mentor || !mentor.roles.includes('MENTOR')) { const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
if (validMentors.length === 0) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Selected user is not a mentor', message: 'None of the selected users have the MENTOR role',
}) })
} }
@@ -684,58 +681,89 @@ export const mentorRouter = router({
id: true, id: true,
title: true, title: true,
mentorAssignments: { mentorAssignments: {
where: { mentorId: mentor.id, droppedAt: null }, where: {
select: { id: true }, mentorId: { in: validMentors.map((m) => m.id) },
droppedAt: null,
},
select: { mentorId: true },
}, },
}, },
}) })
const newProjects: { id: string; title: string }[] = [] // Track per-mentor (for emails) and per-project (for team intros) state.
const skippedProjects: { id: string; title: string }[] = [] const perMentor = new Map<
const createdAssignmentIds: string[] = [] string,
{
email: string | null
name: string | null
assignmentIds: string[]
newProjects: { id: string; title: string }[]
skippedProjects: { id: string; title: string }[]
}
>()
for (const m of validMentors) {
perMentor.set(m.id, {
email: m.email ?? null,
name: m.name ?? null,
assignmentIds: [],
newProjects: [],
skippedProjects: [],
})
}
const touchedProjectIds = new Set<string>()
let totalAssigned = 0
let totalSkipped = 0
for (const p of projects) { for (const project of projects) {
if (p.mentorAssignments.length > 0) { const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
skippedProjects.push({ id: p.id, title: p.title }) 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 continue
} }
const created = await ctx.prisma.mentorAssignment.create({ const created = await ctx.prisma.mentorAssignment.create({
data: { data: {
projectId: p.id, projectId: project.id,
mentorId: mentor.id, mentorId: mentor.id,
method: 'MANUAL', method: 'MANUAL',
assignedBy: ctx.user.id, assignedBy: ctx.user.id,
}, },
}) })
createdAssignmentIds.push(created.id) bucket.assignmentIds.push(created.id)
newProjects.push({ id: p.id, title: p.title }) bucket.newProjects.push({ id: project.id, title: project.title })
touchedProjectIds.add(project.id)
totalAssigned++
await createNotification({ await createNotification({
userId: mentor.id, userId: mentor.id,
type: NotificationTypes.MENTEE_ASSIGNED, type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned', title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${p.title}".`, message: `You have been assigned to mentor "${project.title}".`,
linkUrl: `/mentor/projects/${p.id}`, linkUrl: `/mentor/projects/${project.id}`,
linkLabel: 'View Project', linkLabel: 'View Project',
priority: 'high', priority: 'high',
metadata: { projectName: p.title }, metadata: { projectName: project.title },
}) })
await notifyProjectTeam(p.id, { await notifyProjectTeam(project.id, {
type: NotificationTypes.MENTOR_ASSIGNED, type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned', title: 'Mentor Assigned',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`, message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${p.id}`, linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project', linkLabel: 'View Project',
priority: 'high', priority: 'high',
metadata: { projectName: p.title, mentorName: mentor.name }, metadata: { projectName: project.title, mentorName: mentor.name },
}) })
}
// Trigger MENTORING round IN_PROGRESS state transition (best-effort) // Best-effort: mark project IN_PROGRESS in the active MENTORING round
if (touchedProjectIds.has(project.id)) {
try { try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({ const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: { where: {
projectId: p.id, projectId: project.id,
round: { round: {
roundType: 'MENTORING', roundType: 'MENTORING',
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] }, status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
@@ -746,7 +774,7 @@ export const mentorRouter = router({
}) })
if (mentoringPrs) { if (mentoringPrs) {
await triggerInProgressOnActivity( await triggerInProgressOnActivity(
p.id, project.id,
mentoringPrs.roundId, mentoringPrs.roundId,
ctx.user.id, ctx.user.id,
ctx.prisma, ctx.prisma,
@@ -759,45 +787,63 @@ export const mentorRouter = router({
) )
} }
} }
}
// One coalesced email per mentor, with all NEW project assignments. // One email per mentor, listing only their NEW projects.
if (newProjects.length > 0 && mentor.email) { for (const bucket of perMentor.values()) {
await sendMentorBulkAssignmentEmail(mentor.email, mentor.name, newProjects) if (bucket.newProjects.length === 0 || !bucket.email) continue
// Stamp notificationSentAt on every row we just created so single- await sendMentorBulkAssignmentEmail(
// assignment retries don't re-notify. bucket.email,
bucket.name,
bucket.newProjects,
)
await ctx.prisma.mentorAssignment.updateMany({ await ctx.prisma.mentorAssignment.updateMany({
where: { id: { in: createdAssignmentIds } }, where: { id: { in: bucket.assignmentIds } },
data: { notificationSentAt: new Date() }, data: { notificationSentAt: new Date() },
}) })
} }
// For each newly-assigned project whose MENTORING round is already open, // One team-intro email per touched project (only if MENTORING round
// introduce the team to the mentor(s) by email. // is currently ROUND_ACTIVE). The helper lists ALL active mentors on
for (const p of newProjects) { // the project, including any pre-existing co-mentors.
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id) for (const projectId of touchedProjectIds) {
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
} }
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN', action: 'MENTOR_BULK_ASSIGN',
entityType: 'User', entityType: 'BulkAssign',
entityId: mentor.id, entityId: 'multi',
detailsJson: { detailsJson: {
mentorEmail: mentor.email, mentorIds: validMentors.map((m) => m.id),
assignedCount: newProjects.length, projectIds: input.projectIds,
skippedCount: skippedProjects.length, totalAssigned,
newProjectIds: newProjects.map((p) => p.id), totalSkipped,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,
assigned: b.newProjects.length,
skipped: b.skippedProjects.length,
})),
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
return { return {
assignedCount: newProjects.length, totalAssigned,
skippedCount: skippedProjects.length, totalSkipped,
skippedProjects, touchedProjectCount: touchedProjectIds.size,
emailSent: newProjects.length > 0, 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,
} }
}), }),