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
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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user