Files
MOPC-Portal/src/server/routers/mentor.ts
Matt 90dcb47c25 fix(security): assertWorkspaceAccess on mentor workspace messaging
workspaceSendMessage, workspaceGetMessages, workspaceMarkRead, and
workspaceAddFileComment previously trusted the caller-supplied ID and
only checked workspaceEnabled. Any user with the MENTOR role could
read/post in any workspace, impersonating the assigned mentor and
inserting comments under any team's deliverables.

All four now run assertWorkspaceAccess (assigned mentor or team member
of the project), mirroring the file-handling procedures in the same
router. workspaceMarkRead resolves the message -> workspaceId first,
and additionally short-circuits when the caller is the sender so unread
state stays honest. workspaceAddFileComment resolves the file ->
mentorAssignmentId before the access check.

Procedures downgraded from mentorProcedure to protectedProcedure since
assertWorkspaceAccess is the real gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:13:01 +02:00

2418 lines
76 KiB
TypeScript

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: protectedProcedure
.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 }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceSendMessage(
{
workspaceId: input.mentorAssignmentId,
senderId: ctx.user.id,
message: input.message,
role: input.role,
},
ctx.prisma,
)
}),
/**
* Get workspace messages
*/
workspaceGetMessages: protectedProcedure
.input(z.object({ mentorAssignmentId: z.string() }))
.query(async ({ ctx, input }) => {
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId)
return workspaceGetMessages(input.mentorAssignmentId, ctx.prisma)
}),
/**
* Mark a workspace message as read.
* Resolves the message to its workspace and verifies caller belongs to it.
* Caller must also not be the sender (you only mark someone else's message
* as read, not your own — keeps unread state honest).
*/
workspaceMarkRead: protectedProcedure
.input(z.object({ messageId: z.string() }))
.mutation(async ({ ctx, input }) => {
const message = await ctx.prisma.mentorMessage.findUnique({
where: { id: input.messageId },
select: { workspaceId: true, senderId: true },
})
if (!message || !message.workspaceId) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Message not found' })
}
await assertWorkspaceAccess(ctx.prisma, ctx.user.id, message.workspaceId)
if (message.senderId === ctx.user.id) {
// Senders can't mark their own messages as read by others.
return { success: true }
}
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: protectedProcedure
.input(
z.object({
mentorFileId: z.string(),
content: z.string().min(1).max(5000),
parentCommentId: z.string().optional(),
})
)
.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)
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,
})),
}
}),
})