feat(mentor): bulk assignment + coalesced emails + team intros on round open
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
This commit is contained in:
@@ -8,8 +8,10 @@ import {
|
||||
type PrismaClient,
|
||||
} from '@prisma/client'
|
||||
import {
|
||||
sendMentorBulkAssignmentEmail,
|
||||
sendMentorChangeRequestEmail,
|
||||
sendMentorTeamAssignmentEmail,
|
||||
sendTeamMentorIntroductionEmail,
|
||||
} from '@/lib/email'
|
||||
import {
|
||||
getAIMentorSuggestions,
|
||||
@@ -46,6 +48,83 @@ import {
|
||||
verifyMentorUploadToken,
|
||||
} from '@/lib/mentor-upload-token'
|
||||
|
||||
/**
|
||||
* Introduce the project team to ALL active mentors via email IF the project's
|
||||
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
|
||||
* whose assignment row has `teamIntroducedAt: null`. If the round is not yet
|
||||
* active, this is a no-op — the activation step will fire the email instead.
|
||||
* Never throws.
|
||||
*/
|
||||
async function introduceTeamToMentorsIfRoundOpen(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectRoundStates: {
|
||||
where: {
|
||||
round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
mentorAssignments: {
|
||||
where: { droppedAt: null, teamIntroducedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
mentor: { select: { name: true, email: true } },
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
select: { user: { select: { name: true, email: true } } },
|
||||
},
|
||||
submittedByEmail: true,
|
||||
submittedBy: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
if (!project) return
|
||||
if (project.projectRoundStates.length === 0) return // round not active yet
|
||||
const mentors = project.mentorAssignments
|
||||
.filter((a) => a.mentor?.email)
|
||||
.map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
|
||||
if (mentors.length === 0) return
|
||||
|
||||
const recipients = new Map<string, { name: string | null }>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user?.email) {
|
||||
recipients.set(tm.user.email, { name: tm.user.name })
|
||||
}
|
||||
}
|
||||
if (
|
||||
project.submittedByEmail &&
|
||||
!recipients.has(project.submittedByEmail)
|
||||
) {
|
||||
recipients.set(project.submittedByEmail, {
|
||||
name: project.submittedBy?.name ?? null,
|
||||
})
|
||||
}
|
||||
for (const [email, { name }] of recipients) {
|
||||
await sendTeamMentorIntroductionEmail(
|
||||
email,
|
||||
name,
|
||||
project.title,
|
||||
project.id,
|
||||
mentors,
|
||||
)
|
||||
}
|
||||
await prisma.mentorAssignment.updateMany({
|
||||
where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
|
||||
data: { teamIntroducedAt: new Date() },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws TRPCError if the given user is neither the assigned mentor
|
||||
* nor a team member of the project linked to the assignment.
|
||||
@@ -414,6 +493,10 @@ export const mentorRouter = router({
|
||||
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
|
||||
}
|
||||
|
||||
// If the project's MENTORING round is already open, introduce the team
|
||||
// to their mentor(s) by email now. Otherwise the activation hook fires it.
|
||||
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
|
||||
|
||||
return assignment
|
||||
}),
|
||||
|
||||
@@ -564,6 +647,160 @@ export const mentorRouter = router({
|
||||
return assignment
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
bulkAssign: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
mentorId: z.string(),
|
||||
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,
|
||||
},
|
||||
})
|
||||
if (!mentor || !mentor.roles.includes('MENTOR')) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Selected user is not a mentor',
|
||||
})
|
||||
}
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
mentorAssignments: {
|
||||
where: { mentorId: mentor.id, droppedAt: null },
|
||||
select: { id: 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
|
||||
}
|
||||
const created = await ctx.prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: p.id,
|
||||
mentorId: mentor.id,
|
||||
method: 'MANUAL',
|
||||
assignedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
createdAssignmentIds.push(created.id)
|
||||
newProjects.push({ id: p.id, title: p.title })
|
||||
|
||||
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',
|
||||
},
|
||||
select: { roundId: true },
|
||||
})
|
||||
if (mentoringPrs) {
|
||||
await triggerInProgressOnActivity(
|
||||
p.id,
|
||||
mentoringPrs.roundId,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}
|
||||
} 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.
|
||||
await ctx.prisma.mentorAssignment.updateMany({
|
||||
where: { id: { in: createdAssignmentIds } },
|
||||
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)
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'MENTOR_BULK_ASSIGN',
|
||||
entityType: 'User',
|
||||
entityId: mentor.id,
|
||||
detailsJson: {
|
||||
mentorEmail: mentor.email,
|
||||
assignedCount: newProjects.length,
|
||||
skippedCount: skippedProjects.length,
|
||||
newProjectIds: newProjects.map((p) => p.id),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
assignedCount: newProjects.length,
|
||||
skippedCount: skippedProjects.length,
|
||||
skippedProjects,
|
||||
emailSent: newProjects.length > 0,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove mentor assignment.
|
||||
*
|
||||
@@ -842,6 +1079,18 @@ export const mentorRouter = router({
|
||||
let assigned = 0
|
||||
let unassignable = 0
|
||||
|
||||
// Coalesce per-mentor so we send ONE email per mentor at the end of the
|
||||
// batch, even when the algorithm assigns the same mentor to several teams.
|
||||
const perMentor = new Map<
|
||||
string,
|
||||
{
|
||||
email: string | null
|
||||
name: string | null
|
||||
assignmentIds: string[]
|
||||
projects: { id: string; title: string }[]
|
||||
}
|
||||
>()
|
||||
|
||||
for (const { project } of projectStates) {
|
||||
try {
|
||||
let mentorId: string | null = null
|
||||
@@ -883,7 +1132,7 @@ export const mentorRouter = router({
|
||||
aiReasoning,
|
||||
},
|
||||
include: {
|
||||
mentor: { select: { id: true, name: true } },
|
||||
mentor: { select: { id: true, name: true, email: true } },
|
||||
project: { select: { title: true } },
|
||||
},
|
||||
})
|
||||
@@ -921,6 +1170,17 @@ export const mentorRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Accumulate for the coalesced email
|
||||
const bucket = perMentor.get(mentorId) ?? {
|
||||
email: assignment.mentor.email ?? null,
|
||||
name: assignment.mentor.name ?? null,
|
||||
assignmentIds: [],
|
||||
projects: [],
|
||||
}
|
||||
bucket.assignmentIds.push(assignment.id)
|
||||
bucket.projects.push({ id: project.id, title: assignment.project.title })
|
||||
perMentor.set(mentorId, bucket)
|
||||
|
||||
assigned++
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -932,6 +1192,46 @@ export const mentorRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Send one coalesced email per mentor, then stamp notificationSentAt so
|
||||
// re-running the bulk doesn't double-notify.
|
||||
for (const bucket of perMentor.values()) {
|
||||
if (!bucket.email || bucket.projects.length === 0) continue
|
||||
await sendMentorBulkAssignmentEmail(
|
||||
bucket.email,
|
||||
bucket.name,
|
||||
bucket.projects,
|
||||
)
|
||||
try {
|
||||
await ctx.prisma.mentorAssignment.updateMany({
|
||||
where: { id: { in: bucket.assignmentIds } },
|
||||
data: { notificationSentAt: new Date() },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If the mentoring round is already open at the time of bulk auto-fill,
|
||||
// introduce each team to their new mentor(s). If the round is still
|
||||
// DRAFT, the activation hook will email later.
|
||||
const roundStatus = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { status: true },
|
||||
})
|
||||
if (roundStatus?.status === 'ROUND_ACTIVE') {
|
||||
const introducedProjects = new Set<string>()
|
||||
for (const bucket of perMentor.values()) {
|
||||
for (const p of bucket.projects) {
|
||||
if (introducedProjects.has(p.id)) continue
|
||||
introducedProjects.add(p.id)
|
||||
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = await ctx.prisma.projectRoundState.count({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
|
||||
Reference in New Issue
Block a user