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 {
|
||||
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<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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user