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,17 +1265,32 @@ export default function RoundDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href={poolLink}>
|
{isMentoring ? (
|
||||||
<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">
|
<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" />
|
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Assign Projects</p>
|
<p className="text-sm font-medium">Assign Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
Add projects from the pool to this round
|
Open the Projects tab to add or auto-fill teams in this round
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
) : (
|
||||||
|
<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" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Assign Projects</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Add projects from the pool to this round
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('projects')}
|
onClick={() => setActiveTab('projects')}
|
||||||
@@ -1595,7 +1610,12 @@ export default function RoundDetailPage() {
|
|||||||
{isMentoring && (
|
{isMentoring && (
|
||||||
<>
|
<>
|
||||||
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
|
||||||
<MentoringProjectsTable roundId={roundId} />
|
<MentoringProjectsTable
|
||||||
|
roundId={roundId}
|
||||||
|
competitionId={competitionId}
|
||||||
|
competitionRounds={competition?.rounds}
|
||||||
|
currentSortOrder={round?.sortOrder}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isMentoring && (
|
{!isMentoring && (
|
||||||
|
|||||||
@@ -33,12 +33,32 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Download,
|
Download,
|
||||||
X,
|
X,
|
||||||
|
Plus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
|
||||||
|
|
||||||
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
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 [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
@@ -67,6 +87,10 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
toast.info(
|
toast.info(
|
||||||
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
|
`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 {
|
} else {
|
||||||
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
|
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -197,18 +221,32 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{importBanner}
|
{importBanner}
|
||||||
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
|
<div className="flex items-center justify-end">
|
||||||
No projects in this mentoring round yet.
|
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
|
||||||
{!importBanner && (
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
<>
|
Add Project to Round
|
||||||
{' '}Use{' '}
|
</Button>
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
Add Project to Round
|
|
||||||
</span>{' '}
|
|
||||||
to populate it.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -246,14 +284,24 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
|
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
|
||||||
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
|
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:max-w-xs">
|
<div className="flex items-center gap-2">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<div className="relative w-full sm:w-72">
|
||||||
<Input
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
value={search}
|
<Input
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
placeholder="Search projects, teams, or mentors…"
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-8"
|
placeholder="Search projects, teams, or mentors…"
|
||||||
/>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -670,6 +718,21 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -785,7 +785,7 @@ function QuickAddDialog({
|
|||||||
* Create New: form to create a project and assign it directly to the round.
|
* 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.
|
* From Pool: search existing projects not yet in this round and assign them.
|
||||||
*/
|
*/
|
||||||
function AddProjectDialog({
|
export function AddProjectDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
roundId,
|
roundId,
|
||||||
|
|||||||
@@ -370,6 +370,25 @@ export const mentorRouter = router({
|
|||||||
where: { id: input.projectId },
|
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
|
// Verify mentor exists
|
||||||
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
||||||
where: { id: input.mentorId },
|
where: { id: input.mentorId },
|
||||||
@@ -704,7 +723,13 @@ export const mentorRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
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: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: 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.
|
// Track per-mentor (for emails) and per-project (for team intros) state.
|
||||||
const perMentor = new Map<
|
const perMentor = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -884,6 +919,7 @@ export const mentorRouter = router({
|
|||||||
return {
|
return {
|
||||||
totalAssigned,
|
totalAssigned,
|
||||||
totalSkipped,
|
totalSkipped,
|
||||||
|
ineligibleProjectCount: ineligibleCount,
|
||||||
touchedProjectCount: touchedProjectIds.size,
|
touchedProjectCount: touchedProjectIds.size,
|
||||||
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
|
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
|
||||||
mentorId: id,
|
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', () => {
|
describe('mentor.assign — stacking + per-team email idempotency', () => {
|
||||||
const programIds: string[] = []
|
const programIds: string[] = []
|
||||||
const userIds: string[] = []
|
const userIds: string[] = []
|
||||||
@@ -62,6 +93,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
|
const program = await createTestProgram({ name: `assign-stack-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Stacking Project' })
|
const project = await createTestProject(program.id, { title: 'Stacking Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
|
|
||||||
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
|
const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
|
||||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
|
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()}` })
|
const program = await createTestProgram({ name: `assign-dup-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Dup Project' })
|
const project = await createTestProject(program.id, { title: 'Dup Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
@@ -114,7 +147,9 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-email-${uid()}` })
|
const program = await createTestProgram({ name: `assign-email-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
|
const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
|
||||||
|
await attachToMentoringRound(program.id, project1.id)
|
||||||
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
|
const project2 = await createTestProject(program.id, { title: 'Project Beta' })
|
||||||
|
await attachToMentoringRound(program.id, project2.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
@@ -144,6 +179,7 @@ describe('mentor.assign — stacking + per-team email idempotency', () => {
|
|||||||
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
|
const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
|
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 m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
|
||||||
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
|
const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
|
||||||
userIds.push(m1.id, m2.id)
|
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()}` })
|
const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
|
const project = await createTestProject(program.id, { title: 'Re-assign Project' })
|
||||||
|
await attachToMentoringRound(program.id, project.id)
|
||||||
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
|
||||||
userIds.push(mentor.id)
|
userIds.push(mentor.id)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user