fix(mentor): restore Add Project on mentoring rounds + gate mentor assignment
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s

Three related bugs around the mentoring-round Projects tab:

1. Add Project to Round was unreachable on MENTORING rounds — the table swap
   in the prior commit lost the button. Export AddProjectDialog from
   project-states-table and render it inside MentoringProjectsTable with an
   "Add" button in the filter row and a CTA in the empty state.
2. The "Assign Projects" quick action on the round overview linked to the
   global pool with an opaque filter; on MENTORING rounds it now switches
   to the Projects tab where the new Add Project button + auto-fill +
   per-team picker all live. Non-mentoring rounds keep the old behavior.
3. mentor.assign and mentor.bulkAssign now refuse projects that aren't
   enrolled in any MENTORING round (any status). The single-assign throws
   BAD_REQUEST with a guidance message; the bulk path filters them out and
   reports ineligibleProjectCount in the result so the UI can warn the
   admin instead of silently skipping.

Tests: the multi-mentor-assignment suite now sets up a MENTORING round +
ProjectRoundState for each project it tests against, matching the new gate.
This commit is contained in:
Matt
2026-05-26 15:20:01 +02:00
parent c4f7216bc1
commit 61dfc608cd
5 changed files with 183 additions and 27 deletions

View File

@@ -1265,6 +1265,20 @@ export default function RoundDetailPage() {
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{isMentoring ? (
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Open the Projects tab to add or auto-fill teams in this round
</p>
</div>
</button>
) : (
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
@@ -1276,6 +1290,7 @@ export default function RoundDetailPage() {
</div>
</button>
</Link>
)}
<button
onClick={() => setActiveTab('projects')}
@@ -1595,7 +1610,12 @@ export default function RoundDetailPage() {
{isMentoring && (
<>
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
<MentoringProjectsTable roundId={roundId} />
<MentoringProjectsTable
roundId={roundId}
competitionId={competitionId}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
/>
</>
)}
{!isMentoring && (

View File

@@ -33,12 +33,32 @@ import {
Loader2,
Download,
X,
Plus,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
export function MentoringProjectsTable({ roundId }: { roundId: string }) {
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
export function MentoringProjectsTable({
roundId,
competitionId,
competitionRounds,
currentSortOrder,
}: {
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
}) {
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
@@ -67,6 +87,10 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
toast.info(
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
toast.warning(
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
@@ -197,18 +221,32 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
return (
<div className="space-y-3">
{importBanner}
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
No projects in this mentoring round yet.
{!importBanner && (
<>
{' '}Use{' '}
<span className="font-medium text-foreground">
<div className="flex items-center justify-end">
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Project to Round
</span>{' '}
to populate it.
</>
)}
</Button>
</div>
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
No projects in this mentoring round yet. Click{' '}
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
above to populate it.
</div>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}
@@ -246,7 +284,8 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
</div>
<div className="relative w-full sm:max-w-xs">
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-72">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
@@ -255,6 +294,15 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
className="pl-8"
/>
</div>
<Button
size="sm"
onClick={() => setAddProjectOpen(true)}
className="shrink-0"
>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</div>
{selected.size > 0 ? (
@@ -670,6 +718,21 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}

View File

@@ -785,7 +785,7 @@ function QuickAddDialog({
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
export function AddProjectDialog({
open,
onOpenChange,
roundId,

View File

@@ -370,6 +370,25 @@ export const mentorRouter = router({
where: { id: input.projectId },
})
// Gate: the project MUST be in a MENTORING round (any status, including
// DRAFT, ACTIVE, or CLOSED). We do not allow mentor assignment for
// projects that aren't part of a mentoring round — those should be
// added to a mentoring round first.
const inMentoringRound = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING' },
},
select: { id: true },
})
if (!inMentoringRound) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'This project is not in a mentoring round. Add it to a mentoring round first, then assign mentors.',
})
}
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
@@ -704,7 +723,13 @@ export const mentorRouter = router({
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds } },
where: {
id: { in: input.projectIds },
// Gate: only projects that are in some MENTORING round (any status)
projectRoundStates: {
some: { round: { roundType: 'MENTORING' } },
},
},
select: {
id: true,
title: true,
@@ -718,6 +743,16 @@ export const mentorRouter = router({
},
})
if (projects.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'None of the selected projects are in a mentoring round. Add them to a mentoring round first.',
})
}
const ineligibleCount = input.projectIds.length - projects.length
// Track per-mentor (for emails) and per-project (for team intros) state.
const perMentor = new Map<
string,
@@ -884,6 +919,7 @@ export const mentorRouter = router({
return {
totalAssigned,
totalSkipped,
ineligibleProjectCount: ineligibleCount,
touchedProjectCount: touchedProjectIds.size,
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
mentorId: id,

View File

@@ -42,6 +42,37 @@ async function createUserWithRoles(
})
}
/**
* mentor.assign and mentor.bulkAssign now require the project to be enrolled
* in some MENTORING round. This helper sets up the minimum: one competition
* + one MENTORING round + one ProjectRoundState linking the project.
*/
async function attachToMentoringRound(programId: string, projectId: string) {
const compSlug = `comp-${uid()}`
const competition = await prisma.competition.create({
data: {
name: `Comp ${compSlug}`,
slug: compSlug,
programId,
status: 'ACTIVE',
},
})
const round = await prisma.round.create({
data: {
name: `Mentoring ${uid()}`,
slug: `mentoring-${uid()}`,
roundType: 'MENTORING',
sortOrder: 1,
status: 'ROUND_ACTIVE',
competitionId: competition.id,
},
})
await prisma.projectRoundState.create({
data: { roundId: round.id, projectId },
})
return { competitionId: competition.id, roundId: round.id }
}
describe('mentor.assign — stacking + per-team email idempotency', () => {
const programIds: string[] = []
const userIds: string[] = []
@@ -62,6 +93,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Stacking Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
@@ -93,6 +125,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Dup Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
@@ -114,7 +147,9 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-email-${uid()}` })
programIds.push(program.id)
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
await attachToMentoringRound(program.id, project1.id)
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
await attachToMentoringRound(program.id, project2.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)
@@ -144,6 +179,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
await attachToMentoringRound(program.id, project.id)
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
userIds.push(m1.id, m2.id)
@@ -169,6 +205,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
await attachToMentoringRound(program.id, project.id)
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
userIds.push(mentor.id)