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 [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorId, setChosenMentorId] = useState<string>('')
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils()
@@ -63,17 +63,28 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.assignedCount === 0 && result.skippedCount > 0) {
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
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 {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
`Assigned mentor to ${result.assignedCount} project${
result.assignedCount === 1 ? '' : 's'
}${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${
result.emailSent ? ' · email sent' : ''
`Created ${result.totalAssigned} assignment${
result.totalAssigned === 1 ? '' : 's'
} across ${result.touchedProjectCount} project${
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 })
@@ -83,7 +94,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate()
setSelected(new Set())
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
setBulkOpen(false)
},
@@ -442,7 +453,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
onOpenChange={(next) => {
if (!next) {
setBulkOpen(false)
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
}
}}
@@ -450,117 +461,181 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<DialogContent>
<DialogHeader>
<DialogTitle>
Assign mentor to {selected.size} project
Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'}
</DialogTitle>
<DialogDescription>
Choose one mentor they'll receive a single email listing every
new assignment. Projects where they're already an active mentor
will be skipped.
Tick any number of mentors. Each chosen mentor will be added to
every selected project they aren&apos;t already on. Each mentor
receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription>
</DialogHeader>
<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" />
<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 mentors = mentorPool?.mentors ?? []
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? mentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: mentors
if (mentors.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>
.
{(() => {
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">
<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>
)
}
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>
<DialogFooter>
@@ -568,7 +643,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
variant="outline"
onClick={() => {
setBulkOpen(false)
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
}}
>
@@ -577,16 +652,19 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<Button
onClick={() =>
bulkAssignMutation.mutate({
mentorId: chosenMentorId,
mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected),
})
}
disabled={!chosenMentorId || bulkAssignMutation.isPending}
disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
>
{bulkAssignMutation.isPending && (
<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'}
</Button>
</DialogFooter>

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,
}
}),