fix(mentor): unbreak the mentor pipeline end-to-end
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
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:
@@ -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'}
|
||||
|
||||
@@ -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 ═══════════ */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user