diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index b444a35..e3da6d1 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -1265,17 +1265,32 @@ export default function RoundDetailPage() {

Project Management

- - - + ) : ( + + + + )}
+
+ No projects in this mentoring round yet. Click{' '} + Add Project to Round{' '} + above to populate it. +
+ + { + utils.round.listMentoringProjects.invalidate({ roundId }) + utils.round.getProjectsNeedingMentor.invalidate({ roundId }) + utils.round.getMentoringImportCandidates.invalidate({ roundId }) + utils.mentor.getRoundStats.invalidate({ roundId }) + }} + />
) } @@ -246,14 +284,24 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { -
- - setSearch(e.target.value)} - placeholder="Search projects, teams, or mentors…" - className="pl-8" - /> +
+
+ + setSearch(e.target.value)} + placeholder="Search projects, teams, or mentors…" + className="pl-8" + /> +
+
@@ -670,6 +718,21 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) { + + { + utils.round.listMentoringProjects.invalidate({ roundId }) + utils.round.getProjectsNeedingMentor.invalidate({ roundId }) + utils.round.getMentoringImportCandidates.invalidate({ roundId }) + utils.mentor.getRoundStats.invalidate({ roundId }) + }} + /> ) } diff --git a/src/components/admin/round/project-states-table.tsx b/src/components/admin/round/project-states-table.tsx index b80ed64..dc50087 100644 --- a/src/components/admin/round/project-states-table.tsx +++ b/src/components/admin/round/project-states-table.tsx @@ -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, diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 3af18b8..bc09876 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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, diff --git a/tests/unit/multi-mentor-assignment.test.ts b/tests/unit/multi-mentor-assignment.test.ts index bae675c..8dfdd28 100644 --- a/tests/unit/multi-mentor-assignment.test.ts +++ b/tests/unit/multi-mentor-assignment.test.ts @@ -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)