MOPC-App/src/server/routers/mentor.ts

709 lines
20 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure } from '../trpc'
import { MentorAssignmentMethod } from '@prisma/client'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
} from '../services/mentor-matching'
import {
createNotification,
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
export const mentorRouter = router({
/**
* Get AI-suggested mentor matches for a project
*/
getSuggestions: adminProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(10).default(5),
})
)
.query(async ({ ctx, input }) => {
// Verify project exists
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
mentorAssignment: true,
},
})
if (project.mentorAssignment) {
return {
currentMentor: project.mentorAssignment,
suggestions: [],
message: 'Project already has a mentor assigned',
}
}
const suggestions = await getAIMentorSuggestions(
ctx.prisma,
input.projectId,
input.limit
)
// Enrich with mentor details
const enrichedSuggestions = await Promise.all(
suggestions.map(async (suggestion) => {
const mentor = await ctx.prisma.user.findUnique({
where: { id: suggestion.mentorId },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
return {
...suggestion,
mentor: mentor
? {
id: mentor.id,
name: mentor.name,
email: mentor.email,
expertiseTags: mentor.expertiseTags,
assignmentCount: mentor.mentorAssignments.length,
}
: null,
}
})
)
return {
currentMentor: null,
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
message: null,
}
}),
/**
* Manually assign a mentor to a project
*/
assign: adminProcedure
.input(
z.object({
projectId: z.string(),
mentorId: z.string(),
method: z.nativeEnum(MentorAssignmentMethod).default('MANUAL'),
aiConfidenceScore: z.number().optional(),
expertiseMatchScore: z.number().optional(),
aiReasoning: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
})
if (project.mentorAssignment) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
await createNotification({
userId: input.mentorId,
type: NotificationTypes.MENTEE_ASSIGNED,
title: 'New Mentee Assigned',
message: `You have been assigned to mentor "${assignment.project.title}".`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment
}),
/**
* Auto-assign a mentor using AI or round-robin
*/
autoAssign: adminProcedure
.input(
z.object({
projectId: z.string(),
useAI: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
})
if (project.mentorAssignment) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
// Try AI matching first
const suggestions = await getAIMentorSuggestions(ctx.prisma, input.projectId, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
// Fallback to round-robin
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (!mentorId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No available mentors found',
})
}
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId,
mentorName: assignment.mentor.name,
method,
aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get team lead info for mentor notification
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor of new mentee
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/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
teamLeadName: teamLead?.user?.name || 'Team Lead',
teamLeadEmail: teamLead?.user?.email,
},
})
// Notify project team of mentor assignment
await notifyProjectTeam(input.projectId, {
type: NotificationTypes.MENTOR_ASSIGNED,
title: 'Mentor Assigned',
message: `${assignment.mentor.name || 'A mentor'} has been assigned to support your project.`,
linkUrl: `/team/projects/${input.projectId}`,
linkLabel: 'View Project',
priority: 'high',
metadata: {
projectName: assignment.project.title,
mentorName: assignment.mentor.name,
},
})
return assignment
}),
/**
* Remove mentor assignment
*/
unassign: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUnique({
where: { projectId: input.projectId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
if (!assignment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No mentor assignment found for this project',
})
}
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Bulk auto-assign mentors to projects without one
*/
bulkAutoAssign: adminProcedure
.input(
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
maxAssignments: z.number().min(1).max(100).default(50),
})
)
.mutation(async ({ ctx, input }) => {
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
roundProjects: { some: { roundId: input.roundId } },
mentorAssignment: null,
wantsMentorship: true,
},
select: { id: true },
take: input.maxAssignments,
})
if (projects.length === 0) {
return {
assigned: 0,
failed: 0,
message: 'No projects need mentor assignment',
}
}
let assigned = 0
let failed = 0
for (const project of projects) {
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) {
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: { select: { name: true } },
project: { select: { title: true } },
},
})
// Get team lead info
const teamLead = await ctx.prisma.teamMember.findFirst({
where: { projectId: project.id, role: 'LEAD' },
include: { user: { select: { name: true, email: true } } },
})
// Notify mentor
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,
},
})
// Notify project team
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++
} else {
failed++
}
} catch {
failed++
}
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
assigned,
failed,
message: `Assigned ${assigned} mentor(s), ${failed} failed`,
}
}),
/**
* Get mentor's assigned projects
*/
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
include: {
project: {
include: {
program: { select: { name: true, year: true } },
roundProjects: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
},
},
orderBy: { assignedAt: 'desc' },
})
return assignments
}),
/**
* Get detailed project info for a mentor's assigned project
*/
getProjectDetail: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
// Allow admins to access any project
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
program: { select: { id: true, name: true, year: true } },
roundProjects: {
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
},
},
},
orderBy: { role: 'asc' },
},
files: {
orderBy: { createdAt: 'desc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true },
},
},
},
},
})
return {
...project,
assignedAt: assignment?.assignedAt,
}
}),
/**
* List all mentor assignments (admin)
*/
listAssignments: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
mentorId: z.string().optional(),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where = {
...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
const [assignments, total] = await Promise.all([
ctx.prisma.mentorAssignment.findMany({
where,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
oceanIssue: true,
competitionCategory: true,
roundProjects: {
select: { status: true },
take: 1,
},
},
},
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
},
orderBy: { assignedAt: 'desc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.mentorAssignment.count({ where }),
])
return {
assignments,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
})