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:
@@ -5,7 +5,9 @@ import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
|
|||||||
import {
|
import {
|
||||||
getAIMentorSuggestions,
|
getAIMentorSuggestions,
|
||||||
getRoundRobinMentor,
|
getRoundRobinMentor,
|
||||||
|
computeExpertiseOverlap,
|
||||||
} from '../services/mentor-matching'
|
} from '../services/mentor-matching'
|
||||||
|
import { getOpenAI } from '@/lib/openai'
|
||||||
import {
|
import {
|
||||||
createNotification,
|
createNotification,
|
||||||
notifyProjectTeam,
|
notifyProjectTeam,
|
||||||
@@ -88,10 +90,17 @@ export const mentorRouter = router({
|
|||||||
return {
|
return {
|
||||||
currentMentor: project.mentorAssignment,
|
currentMentor: project.mentorAssignment,
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
source: 'ai' as const,
|
||||||
message: 'Project already has a mentor assigned',
|
message: 'Project already has a mentor assigned',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect AI configuration so the UI can label "AI matching unavailable"
|
||||||
|
// when we fall back to algorithmic ranking. An AI error mid-call still
|
||||||
|
// reports source: 'ai' — accepted imprecision in exchange for a small diff.
|
||||||
|
const openai = await getOpenAI()
|
||||||
|
const source: 'ai' | 'fallback' = openai ? 'ai' : 'fallback'
|
||||||
|
|
||||||
const suggestions = await getAIMentorSuggestions(
|
const suggestions = await getAIMentorSuggestions(
|
||||||
ctx.prisma,
|
ctx.prisma,
|
||||||
input.projectId,
|
input.projectId,
|
||||||
@@ -133,10 +142,68 @@ export const mentorRouter = router({
|
|||||||
return {
|
return {
|
||||||
currentMentor: null,
|
currentMentor: null,
|
||||||
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
|
||||||
|
source,
|
||||||
message: null,
|
message: null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all MENTOR-role users with expertise overlap %, current load, capacity,
|
||||||
|
* and country. Drives the manual-picker tab on /admin/projects/[id]/mentor.
|
||||||
|
* Sorted by overlap desc, then by current load asc.
|
||||||
|
*/
|
||||||
|
getCandidates: adminProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
oceanIssue: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
tags: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mentors = await ctx.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
roles: { has: 'MENTOR' },
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
country: true,
|
||||||
|
expertiseTags: true,
|
||||||
|
maxAssignments: true,
|
||||||
|
mentorAssignments: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidates = mentors.map((m) => {
|
||||||
|
const { score, matchedCount } = computeExpertiseOverlap(project, m.expertiseTags)
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
email: m.email,
|
||||||
|
country: m.country,
|
||||||
|
expertiseTags: m.expertiseTags,
|
||||||
|
currentAssignments: m.mentorAssignments.length,
|
||||||
|
maxAssignments: m.maxAssignments,
|
||||||
|
overlapScore: score,
|
||||||
|
matchedKeywords: matchedCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.overlapScore - a.overlapScore || a.currentAssignments - b.currentAssignments,
|
||||||
|
)
|
||||||
|
return { candidates }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually assign a mentor to a project
|
* Manually assign a mentor to a project
|
||||||
*/
|
*/
|
||||||
@@ -608,6 +675,191 @@ export const mentorRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-scoped bulk auto-assign. Filters to projects in the round without a
|
||||||
|
* mentor, further scoped by configJson.eligibility:
|
||||||
|
* - requested_only: project.wantsMentorship === true
|
||||||
|
* - all_advancing: every project in the round
|
||||||
|
* - admin_selected: refuses (admin must pick manually)
|
||||||
|
*/
|
||||||
|
autoAssignBulkForRound: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
useAI: z.boolean().default(true),
|
||||||
|
maxAssignments: z.number().min(1).max(200).default(100),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { id: true, roundType: true, configJson: true },
|
||||||
|
})
|
||||||
|
if (round.roundType !== 'MENTORING') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Round is not a MENTORING round',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const eligibility = (config.eligibility as string) ?? 'requested_only'
|
||||||
|
if (eligibility === 'admin_selected') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'Round eligibility is admin_selected — assign each project manually.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
project: {
|
||||||
|
mentorAssignment: null,
|
||||||
|
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { project: { select: { id: true, title: true } } },
|
||||||
|
take: input.maxAssignments,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (projectStates.length === 0) {
|
||||||
|
return {
|
||||||
|
assigned: 0,
|
||||||
|
skipped: 0,
|
||||||
|
unassignable: 0,
|
||||||
|
message: 'No projects need a mentor.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let assigned = 0
|
||||||
|
let unassignable = 0
|
||||||
|
|
||||||
|
for (const { project } of projectStates) {
|
||||||
|
try {
|
||||||
|
let mentorId: string | null = null
|
||||||
|
let method: MentorAssignmentMethod = 'ALGORITHM'
|
||||||
|
let aiConfidenceScore: number | undefined
|
||||||
|
let expertiseMatchScore: number | undefined
|
||||||
|
let aiReasoning: string | undefined
|
||||||
|
|
||||||
|
if (input.useAI) {
|
||||||
|
const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
const best = suggestions[0]
|
||||||
|
mentorId = best.mentorId
|
||||||
|
method = 'AI_AUTO'
|
||||||
|
aiConfidenceScore = best.confidenceScore
|
||||||
|
expertiseMatchScore = best.expertiseMatchScore
|
||||||
|
aiReasoning = best.reasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mentorId) {
|
||||||
|
mentorId = await getRoundRobinMentor(ctx.prisma)
|
||||||
|
method = 'ALGORITHM'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mentorId) {
|
||||||
|
unassignable++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = await ctx.prisma.mentorAssignment.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
mentorId,
|
||||||
|
method,
|
||||||
|
assignedBy: ctx.user.id,
|
||||||
|
aiConfidenceScore,
|
||||||
|
expertiseMatchScore,
|
||||||
|
aiReasoning,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
mentor: { select: { id: true, name: true } },
|
||||||
|
project: { select: { title: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const teamLead = await ctx.prisma.teamMember.findFirst({
|
||||||
|
where: { projectId: project.id, role: 'LEAD' },
|
||||||
|
include: { user: { select: { name: true, email: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: mentorId,
|
||||||
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
||||||
|
title: 'New Mentee Assigned',
|
||||||
|
message: `You have been assigned to mentor "${assignment.project.title}".`,
|
||||||
|
linkUrl: `/mentor/projects/${project.id}`,
|
||||||
|
linkLabel: 'View Project',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: {
|
||||||
|
projectName: assignment.project.title,
|
||||||
|
teamLeadName: teamLead?.user?.name || 'Team Lead',
|
||||||
|
teamLeadEmail: teamLead?.user?.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await notifyProjectTeam(project.id, {
|
||||||
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
||||||
|
title: 'Mentor Assigned',
|
||||||
|
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
|
||||||
|
linkUrl: `/team/projects/${project.id}`,
|
||||||
|
linkLabel: 'View Project',
|
||||||
|
priority: 'high',
|
||||||
|
metadata: {
|
||||||
|
projectName: assignment.project.title,
|
||||||
|
mentorName: assignment.mentor.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assigned++
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'[Mentor] autoAssignBulkForRound failure for project',
|
||||||
|
project.id,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
unassignable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = await ctx.prisma.projectRoundState.count({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
project: {
|
||||||
|
mentorAssignment: { isNot: null },
|
||||||
|
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'MENTOR_BULK_ASSIGN',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: {
|
||||||
|
eligibility,
|
||||||
|
assigned,
|
||||||
|
unassignable,
|
||||||
|
skipped,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
assigned,
|
||||||
|
skipped: Math.max(0, skipped - assigned),
|
||||||
|
unassignable,
|
||||||
|
message: `Assigned ${assigned} mentor(s), ${Math.max(0, skipped - assigned)} already assigned, ${unassignable} unassignable.`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get mentor's assigned projects
|
* Get mentor's assigned projects
|
||||||
*/
|
*/
|
||||||
|
|||||||
308
tests/unit/mentor-assignment-ux.test.ts
Normal file
308
tests/unit/mentor-assignment-ux.test.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user