diff --git a/src/components/admin/round/mentoring-projects-table.tsx b/src/components/admin/round/mentoring-projects-table.tsx index 4389759..0b43cb1 100644 --- a/src/components/admin/round/mentoring-projects-table.tsx +++ b/src/components/admin/round/mentoring-projects-table.tsx @@ -43,7 +43,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { const [filter, setFilter] = useState('all') const [selected, setSelected] = useState>(new Set()) const [bulkOpen, setBulkOpen] = useState(false) - const [chosenMentorId, setChosenMentorId] = useState('') + const [chosenMentorIds, setChosenMentorIds] = useState>(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 }) { - Assign mentor to {selected.size} project + Assign mentors to {selected.size} project {selected.size === 1 ? '' : 's'} - 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't already on. Each mentor + receives one combined email; each team receives one intro email + listing all of their mentors.
-
- - setMentorSearch(e.target.value)} - placeholder="Search mentor by name, email, country, or expertise…" - className="pl-8" - /> -
-
- {(() => { - 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 ( -

- No mentors in the pool yet.{' '} - - Add mentors - - . + {(() => { + const allMentors = mentorPool?.mentors ?? [] + const chosenMentors = allMentors.filter((m) => + chosenMentorIds.has(m.id), + ) + const upperBound = chosenMentorIds.size * selected.size + + return ( + <> + {chosenMentors.length > 0 && ( +

+ {chosenMentors.map((m) => ( + + {m.name ?? m.email} + + + ))} +
+ )} + +
+ + setMentorSearch(e.target.value)} + placeholder="Search mentor by name, email, country, or expertise…" + className="pl-8" + /> +
+ +
+ {(() => { + 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 ( +

+ No mentors in the pool yet.{' '} + + Add mentors + + . +

+ ) + } + if (filteredMentors.length === 0) { + return ( +

+ No mentors match “{mentorSearch}”. +

+ ) + } + return filteredMentors.map((m) => { + const isChosen = chosenMentorIds.has(m.id) + return ( + + ) + }) + })()} +
+ + {chosenMentorIds.size > 0 && ( +

+ Will create up to{' '} + + {upperBound} + {' '} + assignment{upperBound === 1 ? '' : 's'} ( + {chosenMentorIds.size} mentor + {chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '} + project{selected.size === 1 ? '' : 's'}). Pairs that + already exist are skipped.

- ) - } - if (filteredMentors.length === 0) { - return ( -

- No mentors match “{mentorSearch}”. -

- ) - } - return filteredMentors.map((m) => { - const isChosen = chosenMentorId === m.id - return ( - - ) - }) - })()} -
+ )} + + ) + })()}
@@ -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 }) { diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index dbe7faf..6df3148 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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() + 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, } }),