fix(mentor): unbreak the mentor pipeline end-to-end
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s

Adding the MENTOR role from /admin/members/[id] only updated React state — the
AlertDialog "Add role" confirmation never called the server, so prod ended up
with zero users in MENTOR roles[] and /admin/mentors showed "No mentors yet".
The dialog now awaits updateUser.mutateAsync({ roles }) before closing.

Other corrections in the same area:

- DialogContent uses flex flex-col with max-h-[90vh] overflow-y-auto so tall
  modals (e.g. Add Project to Round) scroll internally instead of overflowing
  past their own rounded background.
- getProjectsNeedingMentor now matches autoAssignBulkForRound exactly: both
  filter mentorAssignments by droppedAt: null and require
  finalistConfirmation: CONFIRMED, so the toolbar count never exceeds what
  auto-fill actually processes. The toolbar surfaces hasNoMentors /
  hasNoEligible / count / all-assigned as distinct states instead of one
  misleading "All eligible projects have a mentor" line.
- New per-team table (MentoringProjectsTable) replaces ProjectStatesTable on
  the Projects tab of MENTORING rounds. Lists every project with its active
  mentors (multi-mentor aware), filter pills, search, finalist-confirmation
  badge, and a per-row link to /admin/projects/[id]/mentor for assigning.
- Applicant team page now lists ALL active mentors (PR8 Task 7) instead of
  just mentorAssignments[0].
- Hard guard in src/lib/email.ts short-circuits sendEmail when NODE_ENV=test
  or VITEST=true so test runs can never emit real notifications again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-26 13:01:05 +02:00
parent 5b99d6a530
commit 921019aaa4
8 changed files with 443 additions and 41 deletions

View File

@@ -818,7 +818,7 @@ export const mentorRouter = router({
where: {
roundId: input.roundId,
project: {
mentorAssignments: { none: {} },
mentorAssignments: { none: { droppedAt: null } },
// Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all.

View File

@@ -227,21 +227,120 @@ export const roundRouter = router({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') return { count: 0 }
if (round.roundType !== 'MENTORING') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
if (eligibility === 'admin_selected') return { count: 0 }
if (eligibility === 'admin_selected') {
return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 }
}
const count = await ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
const eligibilityWhere =
eligibility === 'requested_only' ? { wantsMentorship: true } : {}
// Mirror autoAssignBulkForRound's filter exactly so the toolbar count
// matches what the auto-fill button will actually process.
const autoFillWhere = {
mentorAssignments: { none: { droppedAt: null } },
finalistConfirmation: { status: 'CONFIRMED' as const },
...eligibilityWhere,
}
const [count, eligibleTotal, mentorPoolSize] = await Promise.all([
ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, project: autoFillWhere },
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: {
finalistConfirmation: { status: 'CONFIRMED' as const },
...eligibilityWhere,
},
},
}),
ctx.prisma.user.count({
where: { roles: { has: 'MENTOR' }, status: { not: 'SUSPENDED' } },
}),
])
return { count, eligibleTotal, mentorPoolSize }
}),
/**
* List projects in a MENTORING round with their (multi-)mentor assignments.
* Drives the per-team assignment table on the round Projects tab so admins
* can see who is assigned to whom and add/swap mentors per project.
*/
listMentoringProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
if (round.roundType !== 'MENTORING') return { projects: [] }
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
const states = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId },
select: {
state: true,
project: {
mentorAssignments: { none: {} },
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
select: {
id: true,
title: true,
teamName: true,
country: true,
wantsMentorship: true,
competitionCategory: true,
finalistConfirmation: { select: { status: true } },
mentorAssignments: {
where: { droppedAt: null },
select: {
id: true,
method: true,
assignedAt: true,
mentor: { select: { id: true, name: true, email: true } },
},
orderBy: { assignedAt: 'asc' },
},
},
},
},
orderBy: [{ project: { title: 'asc' } }],
})
return { count }
return {
eligibility,
projects: states.map((s) => {
const isEligible =
eligibility === 'all_in_round' ||
eligibility === 'admin_selected' ||
s.project.wantsMentorship
return {
id: s.project.id,
title: s.project.title,
teamName: s.project.teamName,
country: s.project.country,
competitionCategory: s.project.competitionCategory,
wantsMentorship: s.project.wantsMentorship,
finalistConfirmationStatus:
s.project.finalistConfirmation?.status ?? null,
isEligible,
state: s.state,
mentors: s.project.mentorAssignments.map((a) => ({
assignmentId: a.id,
method: a.method,
assignedAt: a.assignedAt,
id: a.mentor.id,
name: a.mentor.name,
email: a.mentor.email,
})),
}
}),
}
}),
/**