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
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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user