From 4874491b1847773571575c6069b0c54c0ac4d632 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 14:54:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(mentor):=20getCandidates=20+=20autoAssignB?= =?UTF-8?q?ulkForRound=20procedures=20(=C2=A7C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/server/routers/mentor.ts | 252 +++++++++++++++++++ tests/unit/mentor-assignment-ux.test.ts | 308 ++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 tests/unit/mentor-assignment-ux.test.ts diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 400456d..946ef76 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -5,7 +5,9 @@ import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client' import { getAIMentorSuggestions, getRoundRobinMentor, + computeExpertiseOverlap, } from '../services/mentor-matching' +import { getOpenAI } from '@/lib/openai' import { createNotification, notifyProjectTeam, @@ -88,10 +90,17 @@ export const mentorRouter = router({ return { currentMentor: project.mentorAssignment, suggestions: [], + source: 'ai' as const, 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( ctx.prisma, input.projectId, @@ -133,10 +142,68 @@ export const mentorRouter = router({ return { currentMentor: null, suggestions: enrichedSuggestions.filter((s) => s.mentor !== null), + source, 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 */ @@ -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 + 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 */ diff --git a/tests/unit/mentor-assignment-ux.test.ts b/tests/unit/mentor-assignment-ux.test.ts new file mode 100644 index 0000000..3f91c42 --- /dev/null +++ b/tests/unit/mentor-assignment-ux.test.ts @@ -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 + } + }) +})