Files
MOPC-Portal/src/server/routers/mentor.ts

2393 lines
75 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
computeExpertiseOverlap,
} from '../services/mentor-matching'
import { getOpenAI } from '@/lib/openai'
import {
createNotification,
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
import {
activateWorkspace,
sendMessage as workspaceSendMessage,
getMessages as workspaceGetMessages,
markRead as workspaceMarkRead,
uploadFile as workspaceUploadFile,
addFileComment as workspaceAddFileComment,
promoteFile as workspacePromoteFile,
getFiles as workspaceGetFilesService,
deleteFile as workspaceDeleteFileService,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
import {
generateMentorObjectKey,
getPresignedUrl,
BUCKET_NAME,
deleteObject,
} from '@/lib/minio'
import {
signMentorUploadToken,
verifyMentorUploadToken,
} from '@/lib/mentor-upload-token'
/**
* Throws TRPCError if the given user is neither the assigned mentor
* nor a team member of the project linked to the assignment.
* Returns the loaded MentorAssignment + Project on success.
*/
async function assertWorkspaceAccess(
prisma: PrismaClient,
userId: string,
mentorAssignmentId: string,
) {
const assignment = await prisma.mentorAssignment.findUnique({
where: { id: mentorAssignmentId },
include: { project: { select: { id: true, title: true } } },
})
if (!assignment) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' })
}
if (!assignment.workspaceEnabled) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
}
if (assignment.mentorId === userId) return assignment
const teamMembership = await prisma.teamMember.findFirst({
where: { projectId: assignment.projectId, userId },
select: { id: true },
})
if (teamMembership) return assignment
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' })
}
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: [],
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,
input.limit
)
// Enrich with mentor details (batch query to avoid N+1)
const mentorIds = suggestions.map((s) => s.mentorId)
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: mentorIds } },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
const enrichedSuggestions = suggestions.map((suggestion) => {
const mentor = mentorMap.get(suggestion.mentorId)
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),
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: { not: 'SUSPENDED' },
},
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: { where: { droppedAt: null }, 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
*/
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,
},
},
},
})
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
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,
},
})
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING', status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(input.projectId, mentoringPrs.roundId, ctx.user.id, ctx.prisma)
}
} catch (e) {
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
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 logAudit({
prisma: ctx.prisma,
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',
})
}
// Delete assignment
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Audit outside transaction so failures don't roll back the unassignment
await logAudit({
prisma: ctx.prisma,
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({
programId: 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: {
programId: input.programId,
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 (err) {
console.error('Failed to send mentor assignment notifications:', err)
failed++
}
}
// Create audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
assigned,
failed,
message: `Assigned ${assigned} mentor(s), ${failed} failed`,
}
}),
/**
* 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,
// Only assign mentors to projects whose team has confirmed they will
// attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED
// confirmations and any project without a confirmation row at all.
finalistConfirmation: { status: 'CONFIRMED' },
...(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.`,
}
}),
/**
* MENTORING-round stats card: totals + request window + workspace activity.
* Single round-scoped query set; cheap enough to call uncached.
*/
getRoundStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: {
id: true,
roundType: true,
configJson: true,
windowOpenAt: true,
},
})
if (round.roundType !== 'MENTORING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round is not a MENTORING round',
})
}
const [
totalProjects,
requestedCount,
assignedAndRequested,
totalAssigned,
messageCount,
fileCount,
milestoneCount,
latestMessage,
latestFile,
latestMilestone,
] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.count({
where: { roundId: input.roundId, project: { wantsMentorship: true } },
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { wantsMentorship: true, mentorAssignment: { isNot: null } },
},
}),
ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: { mentorAssignment: { isNot: null } },
},
}),
ctx.prisma.mentorMessage.count({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
}),
ctx.prisma.mentorFile.count({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
}),
ctx.prisma.mentorMilestoneCompletion.count({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
}),
ctx.prisma.mentorMessage.findFirst({
where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
}),
ctx.prisma.mentorFile.findFirst({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
}),
ctx.prisma.mentorMilestoneCompletion.findFirst({
where: {
mentorAssignment: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
},
orderBy: { completedAt: 'desc' },
select: { completedAt: true },
}),
])
const config = (round.configJson ?? {}) as Record<string, unknown>
const deadlineDays =
typeof config.mentoringRequestDeadlineDays === 'number'
? config.mentoringRequestDeadlineDays
: 14
const deadline = round.windowOpenAt
? new Date(round.windowOpenAt.getTime() + deadlineDays * 86_400_000)
: null
const lastActivityAt =
[latestMessage?.createdAt, latestFile?.createdAt, latestMilestone?.completedAt]
.filter((d): d is Date => Boolean(d))
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
return {
totalProjects,
requestedCount,
assignedCount: totalAssigned,
awaitingAssignment: Math.max(0, requestedCount - assignedAndRequested),
requestWindow: {
deadline,
deadlineDays,
},
workspaceActivity: {
messageCount,
fileCount,
milestoneCount,
lastActivityAt,
},
}
}),
/**
* All MENTOR-role users with current/completed assignment counts, capacity,
* country, expertise, and last activity. Drives the /admin/mentors list page
* and the round-overview pool card.
*/
getMentorPool: adminProcedure
.input(z.object({ programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({
where: {
roles: { has: 'MENTOR' },
status: { not: 'SUSPENDED' },
},
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: {
where: {
droppedAt: null,
...(input.programId ? { project: { programId: input.programId } } : {}),
},
select: {
completionStatus: true,
project: { select: { id: true, title: true } },
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
files: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
milestoneCompletions: {
orderBy: { completedAt: 'desc' },
take: 1,
select: { completedAt: true },
},
},
},
},
orderBy: { name: 'asc' },
})
let totalCurrentAssignments = 0
const enriched = mentors.map((m) => {
let current = 0
let completed = 0
const activityDates: Date[] = []
const activeTeams: { id: string; title: string }[] = []
for (const a of m.mentorAssignments) {
if (a.completionStatus === 'completed') {
completed++
} else {
current++
activeTeams.push(a.project)
}
if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
if (a.files[0]) activityDates.push(a.files[0].createdAt)
if (a.milestoneCompletions[0])
activityDates.push(a.milestoneCompletions[0].completedAt)
}
totalCurrentAssignments += current
const lastActivityAt =
activityDates.length > 0
? activityDates.sort((a, b) => b.getTime() - a.getTime())[0]
: null
const capacityRemaining =
m.maxAssignments != null ? Math.max(0, m.maxAssignments - current) : null
return {
id: m.id,
name: m.name,
email: m.email,
country: m.country,
expertiseTags: m.expertiseTags,
currentAssignments: current,
completedAssignments: completed,
maxAssignments: m.maxAssignments,
capacityRemaining,
lastActivityAt,
activeTeams,
}
})
return {
mentors: enriched,
poolSize: enriched.length,
totalCurrentAssignments,
}
}),
/**
* Project-centric activity view: every project that wants mentorship,
* with assignment status, latest activity timestamps, and a derived
* status (unassigned / assigned / active / stalled).
* Drives the "Mentees & Activity" tab on /admin/mentors.
*/
getMenteeActivity: adminProcedure
.input(z.object({ programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: {
wantsMentorship: true,
...(input.programId ? { programId: input.programId } : {}),
},
select: {
id: true,
title: true,
country: true,
status: true,
oceanIssue: true,
competitionCategory: true,
mentorAssignment: {
select: {
id: true,
method: true,
assignedAt: true,
completionStatus: true,
droppedAt: true,
mentor: {
select: {
id: true,
name: true,
email: true,
maxAssignments: true,
_count: {
select: {
mentorAssignments: { where: { droppedAt: null } },
},
},
},
},
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
files: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
_count: { select: { messages: true, files: true } },
},
},
teamMembers: {
where: { role: 'LEAD' },
take: 1,
select: { user: { select: { name: true, email: true } } },
},
},
orderBy: { title: 'asc' },
})
const ACTIVE_WINDOW_MS = 7 * 86_400_000
const STALLED_WINDOW_MS = 14 * 86_400_000
const now = Date.now()
const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
const rows = projects.map((p) => {
// Treat a dropped mentor assignment as if no mentor is assigned.
const ma = p.mentorAssignment && !p.mentorAssignment.droppedAt ? p.mentorAssignment : null
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
const lastFileAt = ma?.files[0]?.createdAt ?? null
const lastActivityAt = [lastMessageAt, lastFileAt]
.filter((d): d is Date => d != null)
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
let status: 'unassigned' | 'assigned' | 'active' | 'stalled'
if (!ma) {
status = 'unassigned'
} else if (lastActivityAt && now - lastActivityAt.getTime() <= ACTIVE_WINDOW_MS) {
status = 'active'
} else {
const referenceTime = lastActivityAt ?? ma.assignedAt
const elapsed = now - referenceTime.getTime()
status = elapsed > STALLED_WINDOW_MS ? 'stalled' : 'assigned'
}
totals[status]++
const teamLead = p.teamMembers[0]?.user ?? null
return {
project: {
id: p.id,
title: p.title,
country: p.country,
status: p.status,
oceanIssue: p.oceanIssue,
competitionCategory: p.competitionCategory,
},
teamLead: teamLead ? { name: teamLead.name, email: teamLead.email } : null,
mentor: ma?.mentor
? {
id: ma.mentor.id,
name: ma.mentor.name,
email: ma.mentor.email,
currentLoad: ma.mentor._count.mentorAssignments,
maxAssignments: ma.mentor.maxAssignments,
}
: null,
assignmentMethod: ma?.method ?? null,
assignedAt: ma?.assignedAt ?? null,
lastMessageAt,
lastFileAt,
lastActivityAt,
messageCount: ma?._count.messages ?? 0,
fileCount: ma?._count.files ?? 0,
status,
}
})
return { rows, totals }
}),
/**
* Get mentor's assigned projects
*/
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id, droppedAt: null },
include: {
project: {
include: {
program: { select: { id: true, 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: {
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,
}
}),
/**
* Send a message to the project team (mentor side)
*/
sendMessage: mentorProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(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,
},
})
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 mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify project team members
await notifyProjectTeam(input.projectId, {
type: 'MENTOR_MESSAGE',
title: 'New Message from Mentor',
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
linkUrl: `/applicant/mentor`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
},
})
return mentorMessage
}),
/**
* Get messages for a project (mentor side)
*/
getMessages: 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,
},
})
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 messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from the team as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
/**
* Recent unread messages from team members across all of the mentor's
* assignments. Drives the 'Recent Messages' card on /mentor.
*/
getRecentMessages: mentorProcedure
.input(z.object({ limit: z.number().min(1).max(20).default(5) }).optional())
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 5
const unread = await ctx.prisma.mentorMessage.findMany({
where: {
senderId: { not: ctx.user.id },
isRead: false,
workspace: { mentorId: ctx.user.id },
},
include: {
sender: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take: limit,
})
return { unread }
}),
/**
* List all mentor assignments (admin)
*/
listAssignments: adminProcedure
.input(
z.object({
programId: 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.programId && { project: { programId: input.programId } }),
...(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,
status: 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),
}
}),
// =========================================================================
// Mentor Notes CRUD (F8)
// =========================================================================
/**
* Create a mentor note for an assignment
*/
createNote: mentorProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment or is admin
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const note = await ctx.prisma.mentorNote.create({
data: {
mentorAssignmentId: input.mentorAssignmentId,
authorId: ctx.user.id,
content: input.content,
isVisibleToAdmin: input.isVisibleToAdmin,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MENTOR_NOTE',
entityType: 'MentorNote',
entityId: note.id,
detailsJson: {
mentorAssignmentId: input.mentorAssignmentId,
projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return note
}),
/**
* Update a mentor note
*/
updateNote: mentorProcedure
.input(
z.object({
noteId: z.string(),
content: z.string().min(1).max(10000),
isVisibleToAdmin: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own notes',
})
}
return ctx.prisma.mentorNote.update({
where: { id: input.noteId },
data: {
content: input.content,
...(input.isVisibleToAdmin !== undefined && { isVisibleToAdmin: input.isVisibleToAdmin }),
},
include: {
author: { select: { id: true, name: true, email: true } },
},
})
}),
/**
* Delete a mentor note
*/
deleteNote: mentorProcedure
.input(z.object({ noteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const note = await ctx.prisma.mentorNote.findUniqueOrThrow({
where: { id: input.noteId },
select: { authorId: true },
})
if (note.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own notes',
})
}
return ctx.prisma.mentorNote.delete({
where: { id: input.noteId },
})
}),
/**
* Get notes for a mentor assignment
*/
getNotes: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
// Admins see all notes; mentors see only their own
const where: Record<string, unknown> = { mentorAssignmentId: input.mentorAssignmentId }
if (!isAdmin) {
where.authorId = ctx.user.id
}
return ctx.prisma.mentorNote.findMany({
where,
include: {
author: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
// =========================================================================
// Milestone Operations (F8)
// =========================================================================
/**
* Get milestones for a program with completion status
*/
getMilestones: mentorProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const milestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: input.programId },
include: {
completions: {
include: {
mentorAssignment: { select: { id: true, projectId: true } },
},
},
},
orderBy: { sortOrder: 'asc' },
})
// Get current user's assignments for completion status context
const myAssignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
select: { id: true, projectId: true },
})
const myAssignmentIds = new Set(myAssignments.map((a) => a.id))
return milestones.map((milestone: typeof milestones[number]) => ({
...milestone,
myCompletions: milestone.completions.filter((c: { mentorAssignmentId: string }) =>
myAssignmentIds.has(c.mentorAssignmentId)
),
}))
}),
/**
* Mark a milestone as completed for an assignment
*/
completeMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user owns this assignment
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true, projectId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
const completion = await ctx.prisma.mentorMilestoneCompletion.create({
data: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
completedById: ctx.user.id,
},
})
// Check if all required milestones are now completed
const milestone = await ctx.prisma.mentorMilestone.findUniqueOrThrow({
where: { id: input.milestoneId },
select: { programId: true },
})
const requiredMilestones = await ctx.prisma.mentorMilestone.findMany({
where: { programId: milestone.programId, isRequired: true },
select: { id: true },
})
const completedMilestones = await ctx.prisma.mentorMilestoneCompletion.findMany({
where: {
mentorAssignmentId: input.mentorAssignmentId,
milestoneId: { in: requiredMilestones.map((m: { id: string }) => m.id) },
},
select: { milestoneId: true },
})
const allRequiredDone = requiredMilestones.length > 0 &&
completedMilestones.length >= requiredMilestones.length
if (allRequiredDone) {
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'completed' },
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COMPLETE_MILESTONE',
entityType: 'MentorMilestoneCompletion',
entityId: completion.id,
detailsJson: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
allRequiredDone,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch (err) {
console.error('[Mentor] Audit log failed:', err)
}
return { completion, allRequiredDone }
}),
/**
* Uncomplete a milestone for an assignment
*/
uncompleteMilestone: mentorProcedure
.input(
z.object({
milestoneId: z.string(),
mentorAssignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
await ctx.prisma.mentorMilestoneCompletion.delete({
where: {
milestoneId_mentorAssignmentId: {
milestoneId: input.milestoneId,
mentorAssignmentId: input.mentorAssignmentId,
},
},
})
// Revert completion status if it was completed
await ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { completionStatus: 'in_progress' },
})
return { success: true }
}),
// =========================================================================
// Admin Milestone Management (F8)
// =========================================================================
/**
* Create a milestone for a program
*/
createMilestone: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
isRequired: z.boolean().default(false),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.create({
data: input,
})
}),
/**
* Update a milestone
*/
updateMilestone: adminProcedure
.input(
z.object({
milestoneId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional().nullable(),
isRequired: z.boolean().optional(),
deadlineOffsetDays: z.number().int().optional().nullable(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { milestoneId, ...data } = input
return ctx.prisma.mentorMilestone.update({
where: { id: milestoneId },
data,
})
}),
/**
* Delete a milestone (cascades completions)
*/
deleteMilestone: adminProcedure
.input(z.object({ milestoneId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.mentorMilestone.delete({
where: { id: input.milestoneId },
})
}),
/**
* Reorder milestones
*/
reorderMilestones: adminProcedure
.input(
z.object({
milestoneIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.milestoneIds.map((id, index) =>
ctx.prisma.mentorMilestone.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
// =========================================================================
// Activity Tracking (F8)
// =========================================================================
/**
* Track a mentor's view of an assignment
*/
trackView: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.mentorAssignmentId },
select: { mentorId: true },
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (assignment.mentorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this mentorship',
})
}
return ctx.prisma.mentorAssignment.update({
where: { id: input.mentorAssignmentId },
data: { lastViewedAt: new Date() },
})
}),
/**
* Get activity stats for all mentors (admin)
*/
getActivityStats: adminProcedure
.input(
z.object({
programId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.programId
? { project: { programId: input.programId } }
: {}
const assignments = await ctx.prisma.mentorAssignment.findMany({
where,
include: {
mentor: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
notes: { select: { id: true } },
milestoneCompletions: { select: { milestoneId: true } },
},
})
// Get message counts per mentor
const mentorIds = [...new Set(assignments.map((a) => a.mentorId))]
const messageCounts = await ctx.prisma.mentorMessage.groupBy({
by: ['senderId'],
where: { senderId: { in: mentorIds } },
_count: true,
})
const messageCountMap = new Map(messageCounts.map((m) => [m.senderId, m._count]))
// Build per-mentor stats
const mentorStats = new Map<string, {
mentor: { id: string; name: string | null; email: string }
assignments: number
lastViewedAt: Date | null
notesCount: number
milestonesCompleted: number
messagesSent: number
completionStatuses: string[]
}>()
for (const assignment of assignments) {
const existing = mentorStats.get(assignment.mentorId)
if (existing) {
existing.assignments++
existing.notesCount += assignment.notes.length
existing.milestonesCompleted += assignment.milestoneCompletions.length
existing.completionStatuses.push(assignment.completionStatus)
if (assignment.lastViewedAt && (!existing.lastViewedAt || assignment.lastViewedAt > existing.lastViewedAt)) {
existing.lastViewedAt = assignment.lastViewedAt
}
} else {
mentorStats.set(assignment.mentorId, {
mentor: assignment.mentor,
assignments: 1,
lastViewedAt: assignment.lastViewedAt,
notesCount: assignment.notes.length,
milestonesCompleted: assignment.milestoneCompletions.length,
messagesSent: messageCountMap.get(assignment.mentorId) || 0,
completionStatuses: [assignment.completionStatus],
})
}
}
return Array.from(mentorStats.values())
}),
// =========================================================================
// Workspace Procedures (Phase 4)
// =========================================================================
/**
* Activate a mentor workspace
*/
activateWorkspace: adminProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await activateWorkspace(input.mentorAssignmentId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to activate workspace',
})
}
return result
}),
/**
* Send a message in a mentor workspace
*/
workspaceSendMessage: mentorProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
message: z.string().min(1).max(5000),
role: z.enum(['MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE']),
})
)
.mutation(async ({ ctx, input }) => {
return workspaceSendMessage(
{
workspaceId: input.mentorAssignmentId,
senderId: ctx.user.id,
message: input.message,
role: input.role,
},
ctx.prisma,
)
}),
/**
* Get workspace messages
*/
workspaceGetMessages: mentorProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
}),
/**
* Mark a workspace message as read
*/
workspaceMarkRead: mentorProcedure
.input(z.object({ messageId: z.string() }))
.mutation(async ({ ctx, input }) => {
await workspaceMarkRead(input.messageId, ctx.prisma)
return { success: true }
}),
/**
* Issue a presigned upload URL + signed token for a mentor-workspace file.
* The token binds the bucket, objectKey, and uploader so the client cannot
* forge a path; workspaceUploadFile reads the token, never the
* client-supplied path.
*/
workspaceGetUploadUrl: protectedProcedure
.input(
z.object({
mentorAssignmentId: z.string(),
fileName: z.string().min(1).max(255),
mimeType: z.string().min(1).max(200),
size: z.number().int().min(0).max(500 * 1024 * 1024),
})
)
.mutation(async ({ ctx, input }) => {
const assignment = await assertWorkspaceAccess(
ctx.prisma, ctx.user.id, input.mentorAssignmentId,
)
const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName)
const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600)
const exp = Math.floor(Date.now() / 1000) + 3600
const uploadToken = signMentorUploadToken({
mentorAssignmentId: assignment.id,
uploaderUserId: ctx.user.id,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
bucket: BUCKET_NAME,
objectKey,
exp,
})
return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey }
}),
/**
* Record a workspace file upload. Requires a valid uploadToken issued by
* workspaceGetUploadUrl the token contains the server-built bucket,
* objectKey, and uploader binding. The client cannot pass a path directly.
*/
workspaceUploadFile: protectedProcedure
.input(
z.object({
uploadToken: z.string(),
description: z.string().max(2000).optional(),
})
)
.mutation(async ({ ctx, input }) => {
let payload
try {
payload = verifyMentorUploadToken(input.uploadToken)
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: e instanceof Error ? e.message : 'Invalid upload token',
})
}
if (payload.uploaderUserId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Upload token does not belong to the current user',
})
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId)
return workspaceUploadFile(
{
workspaceId: payload.mentorAssignmentId,
uploadedByUserId: ctx.user.id,
fileName: payload.fileName,
mimeType: payload.mimeType,
size: payload.size,
bucket: payload.bucket,
objectKey: payload.objectKey,
description: input.description,
},
ctx.prisma,
)
}),
/**
* List files in a workspace. Authorized for the assigned mentor or any
* project team member.
*/
workspaceGetFiles: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma)
}),
/**
* Issue a short-lived presigned GET URL to download a workspace file.
*/
workspaceGetFileDownloadUrl: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
{ downloadFileName: file.fileName })
return { url }
}),
/**
* Delete a workspace file (uploader or assigned mentor only).
*/
workspaceDeleteFile: protectedProcedure
.input(z.object({ mentorFileId: z.string() }))
.mutation(async ({ ctx, input }) => {
const file = await ctx.prisma.mentorFile.findUnique({
where: { id: input.mentorFileId },
select: { mentorAssignmentId: true },
})
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId)
try {
await workspaceDeleteFileService(
{ mentorFileId: input.mentorFileId, userId: ctx.user.id },
ctx.prisma,
deleteObject,
)
} catch (e) {
throw new TRPCError({
code: 'FORBIDDEN',
message: e instanceof Error ? e.message : 'Delete failed',
})
}
return { success: true }
}),
/**
* Add a comment to a workspace file
*/
workspaceAddFileComment: mentorProcedure
.input(
z.object({
mentorFileId: z.string(),
content: z.string().min(1).max(5000),
parentCommentId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return workspaceAddFileComment(
{
mentorFileId: input.mentorFileId,
authorId: ctx.user.id,
content: input.content,
parentCommentId: input.parentCommentId,
},
ctx.prisma,
)
}),
/**
* Promote a workspace file to official submission
*/
workspacePromoteFile: adminProcedure
.input(
z.object({
mentorFileId: z.string(),
roundId: z.string(),
slotKey: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const result = await workspacePromoteFile(
{
mentorFileId: input.mentorFileId,
roundId: input.roundId,
slotKey: input.slotKey,
promotedById: ctx.user.id,
},
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to promote file',
})
}
return result
}),
/**
* Mentor self-drops an assignment with a required reason. Notifies all
* program admins so they can re-assign. Audit-logged.
*/
dropAssignment: mentorProcedure
.input(
z.object({
assignmentId: z.string(),
reason: z
.string()
.min(10, 'Reason must be at least 10 characters')
.max(500),
}),
)
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
project: { select: { id: true, title: true } },
},
})
if (assignment.mentorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This is not your assignment',
})
}
if (assignment.droppedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Assignment is already dropped',
})
}
if (assignment.completionStatus === 'completed') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Assignment is already completed',
})
}
const dropped = await ctx.prisma.mentorAssignment.update({
where: { id: assignment.id },
data: {
droppedAt: new Date(),
droppedReason: input.reason,
droppedBy: 'mentor',
},
})
// Notify program admins (best-effort, never block the drop)
try {
const admins = await ctx.prisma.user.findMany({
where: {
roles: { hasSome: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: { not: 'SUSPENDED' },
},
select: { id: true },
})
const mentorName = ctx.user.name ?? ctx.user.email
for (const admin of admins) {
await createNotification({
userId: admin.id,
type: NotificationTypes.MENTOR_DROPPED,
title: 'Mentor dropped a team',
message: `${mentorName} dropped their mentee "${assignment.project.title}". Reason: ${input.reason}`,
linkUrl: `/admin/projects/${assignment.project.id}/mentor`,
priority: 'high',
})
}
} catch (err) {
console.error('[mentor.dropAssignment] notify admins failed:', err)
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_DROP_ASSIGNMENT',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
reason: input.reason,
projectId: assignment.project.id,
},
})
return dropped
}),
/**
* Per-mentor activity detail used by the admin mentor side sheet. For each
* (active or dropped) assignment, returns the project, key timestamps, and
* counts of messages, files, and completed milestones so the admin can see
* "what has this mentor been up to" at a glance.
*/
getMentorDetail: adminProcedure
.input(z.object({ mentorId: z.string() }))
.query(async ({ ctx, input }) => {
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
select: {
id: true,
name: true,
email: true,
country: true,
expertiseTags: true,
maxAssignments: true,
createdAt: true,
},
})
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: input.mentorId },
orderBy: { assignedAt: 'desc' },
include: {
project: {
select: {
id: true,
title: true,
competitionCategory: true,
country: true,
},
},
_count: {
select: { messages: true, files: true, milestoneCompletions: true },
},
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
files: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true },
},
milestoneCompletions: {
orderBy: { completedAt: 'desc' },
take: 1,
select: { completedAt: true },
},
},
})
return {
mentor,
assignments: assignments.map((a) => ({
id: a.id,
assignedAt: a.assignedAt,
method: a.method,
completionStatus: a.completionStatus,
droppedAt: a.droppedAt,
droppedReason: a.droppedReason,
droppedBy: a.droppedBy,
workspaceEnabled: a.workspaceEnabled,
project: a.project,
messageCount: a._count.messages,
fileCount: a._count.files,
milestoneCompletionCount: a._count.milestoneCompletions,
lastMessageAt: a.messages[0]?.createdAt ?? null,
lastFileAt: a.files[0]?.createdAt ?? null,
lastMilestoneAt: a.milestoneCompletions[0]?.completedAt ?? null,
})),
}
}),
})