feat(mentor): getCandidates + autoAssignBulkForRound procedures (§C)

- getCandidates: lists MENTOR-role users with expertise-overlap %, load,
  capacity. Drives the manual picker on /admin/projects/[id]/mentor.
- autoAssignBulkForRound: round-scoped bulk auto-fill respecting the
  round's configJson.eligibility (requested_only / all_advancing /
  admin_selected). Skips already-assigned projects.
- getSuggestions returns source: 'ai' | 'fallback' so the UI can label
  the AI tab when OPENAI_API_KEY is missing.
- Tests cover ordering, skip-already-assigned, eligibility refusal, and
  the source flag.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §C
This commit is contained in:
Matt
2026-04-28 14:54:43 +02:00
parent c29410fd4e
commit 4874491b18
2 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestProject,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'
/**
* The default `createTestUser` helper only populates the singular `role`
* column — but `mentor.getCandidates` filters on the multi-role `roles[]`
* array. Wrap creation here so test users land with the right shape.
*/
async function createUserWithRoles(
primaryRole: UserRole,
rolesArray: UserRole[],
overrides: { expertiseTags?: string[]; maxAssignments?: number | null; country?: string | null } = {},
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: `${id}@test.local`,
name: `Test ${primaryRole}`,
role: primaryRole,
roles: rolesArray,
status: 'ACTIVE',
expertiseTags: overrides.expertiseTags ?? [],
maxAssignments: overrides.maxAssignments ?? null,
country: overrides.country ?? null,
},
})
}
describe('mentor.getCandidates', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('returns all MENTOR-role users sorted by expertise overlap, excluding non-mentors', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `getCandidates-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, {
title: 'Reef Monitor',
description: 'Coral reef IoT sensor for ocean acidification.',
tags: ['coral', 'iot', 'sensors'],
})
const mentorHigh = await createUserWithRoles('MENTOR', ['MENTOR'], {
expertiseTags: ['coral', 'iot', 'marine biology'],
})
userIds.push(mentorHigh.id)
const mentorLow = await createUserWithRoles('MENTOR', ['MENTOR'], {
expertiseTags: ['marketing'],
})
userIds.push(mentorLow.id)
const nonMentor = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER'], {
expertiseTags: ['coral', 'iot'],
})
userIds.push(nonMentor.id)
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.getCandidates({ projectId: project.id })
const mentorIds = result.candidates.map((c: { id: string }) => c.id)
expect(mentorIds).toContain(mentorHigh.id)
expect(mentorIds).toContain(mentorLow.id)
expect(mentorIds).not.toContain(nonMentor.id)
const high = result.candidates.find((c: { id: string }) => c.id === mentorHigh.id)!
const low = result.candidates.find((c: { id: string }) => c.id === mentorLow.id)!
expect(high.overlapScore).toBeGreaterThan(low.overlapScore)
// Default sort: highest overlap first
const indexHigh = mentorIds.indexOf(mentorHigh.id)
const indexLow = mentorIds.indexOf(mentorLow.id)
expect(indexHigh).toBeLessThan(indexLow)
})
it('exposes load + capacity per candidate', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `getCandidates-load-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'P1', tags: ['x'] })
const otherProject = await createTestProject(program.id, { title: 'P2', tags: ['x'] })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], {
expertiseTags: ['x'],
maxAssignments: 3,
})
userIds.push(mentor.id)
await prisma.mentorAssignment.create({
data: {
projectId: otherProject.id,
mentorId: mentor.id,
method: 'MANUAL',
assignedBy: admin.id,
},
})
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.getCandidates({ projectId: project.id })
const found = result.candidates.find((c: { id: string }) => c.id === mentor.id)!
expect(found.currentAssignments).toBe(1)
expect(found.maxAssignments).toBe(3)
})
})
describe('mentor.autoAssignBulkForRound', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('respects requested_only eligibility — skips projects without wantsMentorship=true', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `bulk-requested-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
configJson: { eligibility: 'requested_only' },
})
const projWithRequest = await prisma.project.create({
data: {
id: uid('proj'),
title: 'Wants Mentorship',
programId: program.id,
tags: ['x'],
wantsMentorship: true,
},
})
const projWithoutRequest = await prisma.project.create({
data: {
id: uid('proj'),
title: 'No Request',
programId: program.id,
tags: ['x'],
wantsMentorship: false,
},
})
await prisma.projectRoundState.create({
data: { projectId: projWithRequest.id, roundId: round.id, state: 'PENDING' },
})
await prisma.projectRoundState.create({
data: { projectId: projWithoutRequest.id, roundId: round.id, state: 'PENDING' },
})
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.autoAssignBulkForRound({ roundId: round.id, useAI: false })
expect(result.assigned).toBe(1)
const requestedAssigned = await prisma.mentorAssignment.findUnique({
where: { projectId: projWithRequest.id },
})
expect(requestedAssigned).not.toBeNull()
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({
where: { projectId: projWithoutRequest.id },
})
expect(skippedNotAssigned).toBeNull()
})
it('skips projects already assigned (any method)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `bulk-skip-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
configJson: { eligibility: 'all_advancing' },
})
const existingMentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
const otherMentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
userIds.push(existingMentor.id, otherMentor.id)
const projAlreadyAssigned = await prisma.project.create({
data: { id: uid('proj'), title: 'Already', programId: program.id, tags: ['x'], wantsMentorship: true },
})
const projUnassigned = await prisma.project.create({
data: { id: uid('proj'), title: 'Open', programId: program.id, tags: ['x'], wantsMentorship: false },
})
await prisma.projectRoundState.create({
data: { projectId: projAlreadyAssigned.id, roundId: round.id, state: 'PENDING' },
})
await prisma.projectRoundState.create({
data: { projectId: projUnassigned.id, roundId: round.id, state: 'PENDING' },
})
await prisma.mentorAssignment.create({
data: {
projectId: projAlreadyAssigned.id,
mentorId: existingMentor.id,
method: 'MANUAL',
assignedBy: admin.id,
},
})
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.autoAssignBulkForRound({ roundId: round.id, useAI: false })
expect(result.assigned).toBe(1)
expect(result.skipped).toBe(1)
const stillExisting = await prisma.mentorAssignment.findUnique({
where: { projectId: projAlreadyAssigned.id },
})
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
})
it('refuses with admin_selected eligibility (admin must pick manually)', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `bulk-admin-selected-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
configJson: { eligibility: 'admin_selected' },
})
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
await expect(
caller.autoAssignBulkForRound({ roundId: round.id, useAI: false }),
).rejects.toThrow(/admin_selected|admin-selected|manually/i)
})
})
describe('mentor.getSuggestions source flag', () => {
const programIds: string[] = []
const userIds: string[] = []
afterAll(async () => {
for (const programId of programIds) {
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('marks source=fallback when OPENAI_API_KEY is missing', async () => {
const original = process.env.OPENAI_API_KEY
delete process.env.OPENAI_API_KEY
try {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `source-flag-${uid()}` })
programIds.push(program.id)
const project = await createTestProject(program.id, { title: 'Fallback test', tags: ['x'] })
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
userIds.push(mentor.id)
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
const result = await caller.getSuggestions({ projectId: project.id, limit: 5 })
expect(result.source).toBe('fallback')
} finally {
if (original) process.env.OPENAI_API_KEY = original
}
})
})