Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
573
src/server/routers/mentor.ts
Normal file
573
src/server/routers/mentor.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
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'
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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: {
|
||||
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) {
|
||||
await ctx.prisma.mentorAssignment.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
mentorId,
|
||||
method,
|
||||
assignedBy: ctx.user.id,
|
||||
aiConfidenceScore,
|
||||
expertiseMatchScore,
|
||||
aiReasoning,
|
||||
},
|
||||
})
|
||||
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: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
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: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
},
|
||||
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: { 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,
|
||||
status: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
},
|
||||
},
|
||||
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),
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user