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,117 +461,181 @@ 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">
<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" /> const allMentors = mentorPool?.mentors ?? []
<Input const chosenMentors = allMentors.filter((m) =>
value={mentorSearch} chosenMentorIds.has(m.id),
onChange={(e) => setMentorSearch(e.target.value)} )
placeholder="Search mentor by name, email, country, or expertise…" const upperBound = chosenMentorIds.size * selected.size
className="pl-8"
/> return (
</div> <>
<div className="max-h-72 overflow-y-auto rounded-md border"> {chosenMentors.length > 0 && (
{(() => { <div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
const mentors = mentorPool?.mentors ?? [] {chosenMentors.map((m) => (
const q = mentorSearch.trim().toLowerCase() <Badge
const filteredMentors = q key={m.id}
? mentors.filter((m) => variant="secondary"
[ className="gap-1 pl-2 pr-1"
m.name ?? '', >
m.email, {m.name ?? m.email}
m.country ?? '', <button
...(m.expertiseTags ?? []), type="button"
] aria-label={`Remove ${m.name ?? m.email}`}
.join(' ') className="rounded-full p-0.5 hover:bg-foreground/10"
.toLowerCase() onClick={() =>
.includes(q), setChosenMentorIds((prev) => {
) const next = new Set(prev)
: mentors next.delete(m.id)
if (mentors.length === 0) { return next
return ( })
<p className="p-4 text-center text-sm text-muted-foreground"> }
No mentors in the pool yet.{' '} >
<Link <X className="h-3 w-3" />
href="/admin/members?tab=mentors" </button>
className="underline-offset-2 hover:underline" </Badge>
> ))}
Add mentors </div>
</Link> )}
.
<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" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? allMentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: allMentors
if (allMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorIds.has(m.id)
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
className="mt-1"
checked={isChosen}
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="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</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> </p>
) )}
} </>
if (filteredMentors.length === 0) { )
return ( })()}
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorId === m.id
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<input
type="radio"
name="bulk-mentor"
className="mt-1"
checked={isChosen}
onChange={() => setChosenMentorId(m.id)}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge variant="outline" className="text-[10px]">
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
</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,120 +681,169 @@ 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,
{
for (const p of projects) { email: string | null
if (p.mentorAssignments.length > 0) { name: string | null
skippedProjects.push({ id: p.id, title: p.title }) assignmentIds: string[]
continue newProjects: { id: string; title: string }[]
skippedProjects: { id: string; title: string }[]
} }
const created = await ctx.prisma.mentorAssignment.create({ >()
data: { for (const m of validMentors) {
projectId: p.id, perMentor.set(m.id, {
mentorId: mentor.id, email: m.email ?? null,
method: 'MANUAL', name: m.name ?? null,
assignedBy: ctx.user.id, 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({ for (const project of projects) {
userId: mentor.id, const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
type: NotificationTypes.MENTEE_ASSIGNED, for (const mentor of validMentors) {
title: 'New Mentee Assigned', const bucket = perMentor.get(mentor.id)!
message: `You have been assigned to mentor "${p.title}".`, if (alreadyOn.has(mentor.id)) {
linkUrl: `/mentor/projects/${p.id}`, bucket.skippedProjects.push({ id: project.id, title: project.title })
linkLabel: 'View Project', totalSkipped++
priority: 'high', continue
metadata: { projectName: p.title }, }
}) const created = await ctx.prisma.mentorAssignment.create({
data: {
await notifyProjectTeam(p.id, { projectId: project.id,
type: NotificationTypes.MENTOR_ASSIGNED, mentorId: mentor.id,
title: 'Mentor Assigned', method: 'MANUAL',
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`, assignedBy: ctx.user.id,
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) { bucket.assignmentIds.push(created.id)
await triggerInProgressOnActivity( bucket.newProjects.push({ id: project.id, title: project.title })
p.id, touchedProjectIds.add(project.id)
mentoringPrs.roundId, totalAssigned++
ctx.user.id,
ctx.prisma, 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. // 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,
} }
}), }),