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

@@ -976,17 +976,39 @@ export default function MemberDetailPage() {
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onClick={async () => {
if (!pendingAdditionalRole) return
const { role: r, action } = pendingAdditionalRole
if (action === 'add') {
setAdditionalRoles((prev) =>
prev.includes(r) ? prev : [...prev, r]
const nextAdditional =
action === 'add'
? additionalRoles.includes(r)
? additionalRoles
: [...additionalRoles, r]
: additionalRoles.filter((x) => x !== r)
const nextAllRoles = [
role,
...nextAdditional.filter((x) => x !== role),
] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
try {
await updateUser.mutateAsync({
id: userId,
roles: nextAllRoles,
})
setAdditionalRoles(nextAdditional)
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success(
action === 'add'
? `${r.replace(/_/g, ' ')} role added`
: `${r.replace(/_/g, ' ')} role removed`,
)
} else {
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update roles',
)
} finally {
setPendingAdditionalRole(null)
}
setPendingAdditionalRole(null)
}}
>
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}

View File

@@ -92,6 +92,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
@@ -168,6 +169,10 @@ function MentoringBulkAssignToolbar({
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
)
const count = pending?.count ?? 0
const eligibleTotal = pending?.eligibleTotal ?? 0
const mentorPoolSize = pending?.mentorPoolSize ?? 0
const hasNoMentors = mentorPoolSize === 0
const hasNoEligible = eligibleTotal === 0
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
onSuccess: (result) => {
@@ -190,23 +195,41 @@ function MentoringBulkAssignToolbar({
auto-fill is disabled. Assign each project manually.
</span>
</>
) : hasNoMentors ? (
<span className="text-muted-foreground">
No mentors in the pool yet {' '}
<Link
href="/admin/members?tab=mentors"
className="text-foreground underline-offset-2 hover:underline"
>
add mentors
</Link>{' '}
before auto-filling.
</span>
) : hasNoEligible ? (
<span className="text-muted-foreground">
No projects are eligible for mentorship in this round (
{eligibilityLabel}).
</span>
) : count > 0 ? (
<>
<span className="font-medium">{count}</span>{' '}
<span className="text-muted-foreground">
project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel})
of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still
needs a mentor ({eligibilityLabel})
</span>
</>
) : (
<span className="text-muted-foreground">
All eligible projects have a mentor.
All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '}
already have a mentor.
</span>
)}
</div>
<Button
size="sm"
onClick={() => bulk.mutate({ roundId })}
disabled={isAdminSelected || count === 0 || bulk.isPending}
disabled={isAdminSelected || count === 0 || hasNoMentors || bulk.isPending}
>
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Auto-fill remaining
@@ -1570,19 +1593,24 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
{isMentoring && (
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
<>
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
<MentoringProjectsTable roundId={roundId} />
</>
)}
{!isMentoring && (
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
)}
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}

View File

@@ -357,15 +357,33 @@ export default function ApplicantProjectPage() {
)}
</div>
{/* Mentor info — TODO(PR8 Task 7): list ALL assigned mentors */}
{project.mentorAssignments?.[0]?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignments[0].mentor.name} ({project.mentorAssignments[0].mentor.email})
</p>
</div>
)}
{(() => {
type MentorAssignment = {
droppedAt: Date | string | null
mentor: { name: string | null; email: string } | null
}
const active = (
(project.mentorAssignments as MentorAssignment[] | undefined) ?? []
).filter((a) => !a.droppedAt && a.mentor)
if (active.length === 0) return null
return (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">
{active.length === 1 ? 'Assigned Mentor' : `Assigned Mentors (${active.length})`}
</p>
<ul className="space-y-0.5">
{active.map((a, idx) => (
<li key={`${a.mentor!.email}-${idx}`} className="text-sm text-muted-foreground">
{a.mentor!.name ?? a.mentor!.email}
{a.mentor!.name && (
<span className="text-xs"> ({a.mentor!.email})</span>
)}
</li>
))}
</ul>
</div>
)
})()}
{/* Tags */}
{project.tags && project.tags.length > 0 && (