All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
Email policy - mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress outbound email entirely when the project's MENTORING round is still ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications still fire), but notificationSentAt and teamIntroducedAt remain null so activateRound can pick them up later. - activateRound, when activating a MENTORING round, now does a coalesced mentor-side email pass in addition to the existing team-side intro pass. Every (mentorId) bucket of pending assignments in this round gets exactly one combined email; the row stamps prevent duplicates on re-activation. - The "send immediately" path is preserved for assignments made while the round is already ROUND_ACTIVE — mentors and teams stay in the loop in real time, but staging during draft is silent. Per-project bulk UI - The /admin/projects/[id]/mentor manual picker now has a checkbox column, header select-all, and a primary-tinted action toolbar that appears when one or more candidates are selected. Submitting calls mentor.bulkAssign with the single projectId so the cartesian server path handles dedup, coalesced emails, and team intros uniformly with the round-page bulk.
3220 lines
106 KiB
TypeScript
3220 lines
106 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc'
|
|
import {
|
|
MentorAssignmentMethod,
|
|
MentorChangeRequestStatus,
|
|
Prisma,
|
|
type PrismaClient,
|
|
} from '@prisma/client'
|
|
import {
|
|
sendMentorBulkAssignmentEmail,
|
|
sendMentorChangeRequestEmail,
|
|
sendMentorTeamAssignmentEmail,
|
|
sendTeamMentorIntroductionEmail,
|
|
} from '@/lib/email'
|
|
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'
|
|
|
|
/**
|
|
* True if the project is enrolled in a MENTORING round that is still
|
|
* ROUND_DRAFT. Used to defer mentor- and team-side emails until the round
|
|
* opens, so admins can stage assignments without sending notifications.
|
|
* If the project isn't in a MENTORING round at all, returns false
|
|
* (i.e. send emails normally — there's no round-open event to wait for).
|
|
*/
|
|
async function shouldDeferEmailsForProject(
|
|
prisma: PrismaClient,
|
|
projectId: string,
|
|
): Promise<boolean> {
|
|
const draftRoundEnrollment = await prisma.projectRoundState.findFirst({
|
|
where: {
|
|
projectId,
|
|
round: { roundType: 'MENTORING', status: 'ROUND_DRAFT' },
|
|
},
|
|
select: { id: true },
|
|
})
|
|
return draftRoundEnrollment !== null
|
|
}
|
|
|
|
/**
|
|
* Introduce the project team to ALL active mentors via email IF the project's
|
|
* MENTORING round is currently ROUND_ACTIVE. Idempotent: only emails mentors
|
|
* whose assignment row has `teamIntroducedAt: null`. If the round is not yet
|
|
* active, this is a no-op — the activation step will fire the email instead.
|
|
* Never throws.
|
|
*/
|
|
async function introduceTeamToMentorsIfRoundOpen(
|
|
prisma: PrismaClient,
|
|
projectId: string,
|
|
): Promise<void> {
|
|
try {
|
|
const project = await prisma.project.findUnique({
|
|
where: { id: projectId },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
projectRoundStates: {
|
|
where: {
|
|
round: { roundType: 'MENTORING', status: 'ROUND_ACTIVE' },
|
|
},
|
|
select: { id: true },
|
|
take: 1,
|
|
},
|
|
mentorAssignments: {
|
|
where: { droppedAt: null, teamIntroducedAt: null },
|
|
select: {
|
|
id: true,
|
|
mentor: { select: { name: true, email: true } },
|
|
},
|
|
},
|
|
teamMembers: {
|
|
select: { user: { select: { name: true, email: true } } },
|
|
},
|
|
submittedByEmail: true,
|
|
submittedBy: { select: { name: true } },
|
|
},
|
|
})
|
|
if (!project) return
|
|
if (project.projectRoundStates.length === 0) return // round not active yet
|
|
const mentors = project.mentorAssignments
|
|
.filter((a) => a.mentor?.email)
|
|
.map((a) => ({ name: a.mentor.name, email: a.mentor.email }))
|
|
if (mentors.length === 0) return
|
|
|
|
const recipients = new Map<string, { name: string | null }>()
|
|
for (const tm of project.teamMembers) {
|
|
if (tm.user?.email) {
|
|
recipients.set(tm.user.email, { name: tm.user.name })
|
|
}
|
|
}
|
|
if (
|
|
project.submittedByEmail &&
|
|
!recipients.has(project.submittedByEmail)
|
|
) {
|
|
recipients.set(project.submittedByEmail, {
|
|
name: project.submittedBy?.name ?? null,
|
|
})
|
|
}
|
|
for (const [email, { name }] of recipients) {
|
|
await sendTeamMentorIntroductionEmail(
|
|
email,
|
|
name,
|
|
project.title,
|
|
project.id,
|
|
mentors,
|
|
)
|
|
}
|
|
await prisma.mentorAssignment.updateMany({
|
|
where: { id: { in: project.mentorAssignments.map((a) => a.id) } },
|
|
data: { teamIntroducedAt: new Date() },
|
|
})
|
|
} catch (e) {
|
|
console.error('[introduceTeamToMentorsIfRoundOpen] failed (non-fatal):', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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' })
|
|
}
|
|
|
|
/**
|
|
* Project-scoped workspace access check (PR8 multi-mentor).
|
|
*
|
|
* Allowed when the user is either:
|
|
* 1) currently assigned as a mentor on this project (droppedAt = null), OR
|
|
* 2) a team member of the project.
|
|
*
|
|
* Also requires at least one active mentor assignment for the project with
|
|
* workspaceEnabled = true — meaning the project actually has a live workspace.
|
|
* Throws TRPCError on failure. Returns nothing on success.
|
|
*/
|
|
async function assertProjectWorkspaceAccess(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
projectId: string,
|
|
): Promise<void> {
|
|
const liveMentorAssignment = await prisma.mentorAssignment.findFirst({
|
|
where: { projectId, droppedAt: null, workspaceEnabled: true },
|
|
select: { id: true },
|
|
})
|
|
if (!liveMentorAssignment) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' })
|
|
}
|
|
const mentorOnProject = await prisma.mentorAssignment.findFirst({
|
|
where: { projectId, mentorId: userId, droppedAt: null },
|
|
select: { id: true },
|
|
})
|
|
if (mentorOnProject) return
|
|
const teamMembership = await prisma.teamMember.findFirst({
|
|
where: { projectId, userId },
|
|
select: { id: true },
|
|
})
|
|
if (teamMembership) return
|
|
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: {
|
|
mentorAssignments: true,
|
|
},
|
|
})
|
|
|
|
// With multi-mentor (PR8) the project can have several mentors. The
|
|
// suggestions endpoint is informational — return whatever AI suggests
|
|
// and let `mentor.assign` enforce per-pair uniqueness. We still surface
|
|
// an existing primary mentor in the payload so UIs can label it.
|
|
const primaryMentor = project.mentorAssignments[0] ?? null
|
|
|
|
// 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 {
|
|
// TODO(PR8 Task 8): return the full mentor list. Legacy field kept
|
|
// until the admin UI is updated.
|
|
currentMentor: primaryMentor,
|
|
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 (multi-mentor: stacking is allowed; duplicate
|
|
// (projectId, mentorId) pairs are rejected by the unique constraint
|
|
// below).
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id: input.projectId },
|
|
})
|
|
|
|
// Verify mentor exists
|
|
const mentor = await ctx.prisma.user.findUniqueOrThrow({
|
|
where: { id: input.mentorId },
|
|
})
|
|
|
|
// Create assignment. P2002 on the composite (projectId, mentorId) unique
|
|
// constraint means this exact mentor is already on this team — surface a
|
|
// friendly error.
|
|
let assignment
|
|
try {
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
} catch (err) {
|
|
if (
|
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
err.code === 'P2002'
|
|
) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'This mentor is already assigned to that project.',
|
|
})
|
|
}
|
|
throw err
|
|
}
|
|
|
|
// 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,
|
|
// PR8: per-team assignment (one row per mentor-project pair).
|
|
assignmentScope: 'per-team',
|
|
},
|
|
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,
|
|
},
|
|
})
|
|
|
|
// Defer the mentor-side email if the project's MENTORING round is still
|
|
// ROUND_DRAFT — `activateRound` will coalesce and send when the admin
|
|
// opens the round. Otherwise fire the per-assignment email immediately.
|
|
const deferThisEmail = await shouldDeferEmailsForProject(
|
|
ctx.prisma,
|
|
input.projectId,
|
|
)
|
|
if (
|
|
!deferThisEmail &&
|
|
assignment.notificationSentAt == null &&
|
|
assignment.mentor.email
|
|
) {
|
|
await sendMentorTeamAssignmentEmail(
|
|
assignment.mentor.email,
|
|
assignment.mentor.name,
|
|
assignment.project.title,
|
|
input.projectId,
|
|
)
|
|
try {
|
|
await ctx.prisma.mentorAssignment.update({
|
|
where: { id: assignment.id },
|
|
data: { notificationSentAt: new Date() },
|
|
})
|
|
} catch (e) {
|
|
console.error('[Mentor] failed to stamp notificationSentAt (non-fatal):', e)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// If the project's MENTORING round is already open, introduce the team
|
|
// to their mentor(s) by email now. Otherwise the activation hook fires it.
|
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, input.projectId)
|
|
|
|
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 already have a mentor. Multi-mentor
|
|
// stacking is reserved for explicit admin assignment via `mentor.assign`;
|
|
// auto-assignment skips projects that already have at least one mentor
|
|
// to avoid double-AI-assignments.
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
where: { id: input.projectId },
|
|
include: { mentorAssignments: { select: { id: true } } },
|
|
})
|
|
|
|
if (project.mentorAssignments.length > 0) {
|
|
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
|
|
}),
|
|
|
|
/**
|
|
* Bulk-assign MANY mentors to MANY projects (cartesian product) in one
|
|
* call. Skips (mentor, project) pairs where the mentor is already an
|
|
* active mentor on that project. Each affected mentor receives ONE
|
|
* coalesced email listing only their newly-assigned projects. Each team
|
|
* whose project's MENTORING round is already open receives ONE intro
|
|
* email listing all their active mentors (including any pre-existing).
|
|
*/
|
|
bulkAssign: adminProcedure
|
|
.input(
|
|
z.object({
|
|
mentorIds: z.array(z.string()).min(1),
|
|
projectIds: z.array(z.string()).min(1),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const mentors = await ctx.prisma.user.findMany({
|
|
where: { id: { in: input.mentorIds } },
|
|
select: { id: true, name: true, email: true, roles: true },
|
|
})
|
|
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR'))
|
|
if (validMentors.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'None of the selected users have the MENTOR role',
|
|
})
|
|
}
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: input.projectIds } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
mentorAssignments: {
|
|
where: {
|
|
mentorId: { in: validMentors.map((m) => m.id) },
|
|
droppedAt: null,
|
|
},
|
|
select: { mentorId: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Track per-mentor (for emails) and per-project (for team intros) state.
|
|
const perMentor = new Map<
|
|
string,
|
|
{
|
|
email: string | null
|
|
name: string | null
|
|
assignmentIds: string[]
|
|
newProjects: { id: string; title: string }[]
|
|
skippedProjects: { id: string; title: string }[]
|
|
}
|
|
>()
|
|
for (const m of validMentors) {
|
|
perMentor.set(m.id, {
|
|
email: m.email ?? null,
|
|
name: m.name ?? null,
|
|
assignmentIds: [],
|
|
newProjects: [],
|
|
skippedProjects: [],
|
|
})
|
|
}
|
|
const touchedProjectIds = new Set<string>()
|
|
let totalAssigned = 0
|
|
let totalSkipped = 0
|
|
|
|
for (const project of projects) {
|
|
const alreadyOn = new Set(project.mentorAssignments.map((a) => a.mentorId))
|
|
for (const mentor of validMentors) {
|
|
const bucket = perMentor.get(mentor.id)!
|
|
if (alreadyOn.has(mentor.id)) {
|
|
bucket.skippedProjects.push({ id: project.id, title: project.title })
|
|
totalSkipped++
|
|
continue
|
|
}
|
|
const created = await ctx.prisma.mentorAssignment.create({
|
|
data: {
|
|
projectId: project.id,
|
|
mentorId: mentor.id,
|
|
method: 'MANUAL',
|
|
assignedBy: ctx.user.id,
|
|
},
|
|
})
|
|
bucket.assignmentIds.push(created.id)
|
|
bucket.newProjects.push({ id: project.id, title: project.title })
|
|
touchedProjectIds.add(project.id)
|
|
totalAssigned++
|
|
|
|
await createNotification({
|
|
userId: mentor.id,
|
|
type: NotificationTypes.MENTEE_ASSIGNED,
|
|
title: 'New Mentee Assigned',
|
|
message: `You have been assigned to mentor "${project.title}".`,
|
|
linkUrl: `/mentor/projects/${project.id}`,
|
|
linkLabel: 'View Project',
|
|
priority: 'high',
|
|
metadata: { projectName: project.title },
|
|
})
|
|
|
|
await notifyProjectTeam(project.id, {
|
|
type: NotificationTypes.MENTOR_ASSIGNED,
|
|
title: 'Mentor Assigned',
|
|
message: `${mentor.name || 'A mentor'} has been assigned to support your project.`,
|
|
linkUrl: `/team/projects/${project.id}`,
|
|
linkLabel: 'View Project',
|
|
priority: 'high',
|
|
metadata: { projectName: project.title, mentorName: mentor.name },
|
|
})
|
|
}
|
|
|
|
// Best-effort: mark project IN_PROGRESS in the active MENTORING round
|
|
if (touchedProjectIds.has(project.id)) {
|
|
try {
|
|
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
round: {
|
|
roundType: 'MENTORING',
|
|
status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] },
|
|
},
|
|
state: 'PENDING',
|
|
},
|
|
select: { roundId: true },
|
|
})
|
|
if (mentoringPrs) {
|
|
await triggerInProgressOnActivity(
|
|
project.id,
|
|
mentoringPrs.roundId,
|
|
ctx.user.id,
|
|
ctx.prisma,
|
|
)
|
|
}
|
|
} catch (e) {
|
|
console.error(
|
|
'[Mentor.bulkAssign] triggerInProgressOnActivity failed (non-fatal):',
|
|
e,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decide per-project whether to defer email until round-open: projects
|
|
// whose MENTORING round is still ROUND_DRAFT skip email and stamp now;
|
|
// `activateRound` will coalesce and send when the admin opens the round.
|
|
const draftProjectIds = new Set<string>()
|
|
for (const projectId of touchedProjectIds) {
|
|
if (await shouldDeferEmailsForProject(ctx.prisma, projectId)) {
|
|
draftProjectIds.add(projectId)
|
|
}
|
|
}
|
|
|
|
// One email per mentor, listing only their NEW projects whose mentoring
|
|
// round is NOT in draft. If every new project is deferred, no email.
|
|
for (const bucket of perMentor.values()) {
|
|
if (!bucket.email) continue
|
|
const sendableProjects = bucket.newProjects.filter(
|
|
(p) => !draftProjectIds.has(p.id),
|
|
)
|
|
if (sendableProjects.length === 0) continue
|
|
await sendMentorBulkAssignmentEmail(
|
|
bucket.email,
|
|
bucket.name,
|
|
sendableProjects,
|
|
)
|
|
// Only stamp notificationSentAt for the assignments that correspond
|
|
// to projects we actually emailed about. Draft-deferred ones stay
|
|
// unstamped so activateRound picks them up.
|
|
const sendableProjectIds = new Set(sendableProjects.map((p) => p.id))
|
|
await ctx.prisma.mentorAssignment.updateMany({
|
|
where: {
|
|
id: { in: bucket.assignmentIds },
|
|
projectId: { in: Array.from(sendableProjectIds) },
|
|
},
|
|
data: { notificationSentAt: new Date() },
|
|
})
|
|
}
|
|
|
|
// Team-intro email per touched project (only fires if the round is
|
|
// already ROUND_ACTIVE — the helper short-circuits otherwise, so draft
|
|
// projects are naturally deferred to activateRound's intro pass).
|
|
for (const projectId of touchedProjectIds) {
|
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, projectId)
|
|
}
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'MENTOR_BULK_ASSIGN',
|
|
entityType: 'BulkAssign',
|
|
entityId: 'multi',
|
|
detailsJson: {
|
|
mentorIds: validMentors.map((m) => m.id),
|
|
projectIds: input.projectIds,
|
|
totalAssigned,
|
|
totalSkipped,
|
|
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
|
|
mentorId: id,
|
|
assigned: b.newProjects.length,
|
|
skipped: b.skippedProjects.length,
|
|
})),
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return {
|
|
totalAssigned,
|
|
totalSkipped,
|
|
touchedProjectCount: touchedProjectIds.size,
|
|
perMentor: Array.from(perMentor.entries()).map(([id, b]) => ({
|
|
mentorId: id,
|
|
mentorName: b.name,
|
|
assigned: b.newProjects.length,
|
|
skipped: b.skippedProjects.length,
|
|
})),
|
|
emailsSent: Array.from(perMentor.values()).filter(
|
|
(b) => b.newProjects.length > 0 && b.email,
|
|
).length,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Remove mentor assignment.
|
|
*
|
|
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
|
|
* specific co-mentor. Legacy callers passing only `projectId` get the
|
|
* most-recent assignment removed (kept for backward compatibility).
|
|
*/
|
|
unassign: adminProcedure
|
|
.input(
|
|
z
|
|
.object({
|
|
assignmentId: z.string().optional(),
|
|
projectId: z.string().optional(),
|
|
})
|
|
.refine((v) => !!v.assignmentId || !!v.projectId, {
|
|
message: 'Either assignmentId or projectId is required',
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const assignment = input.assignmentId
|
|
? await ctx.prisma.mentorAssignment.findUnique({
|
|
where: { id: input.assignmentId },
|
|
include: {
|
|
mentor: { select: { id: true, name: true } },
|
|
project: { select: { id: true, title: true } },
|
|
},
|
|
})
|
|
: await ctx.prisma.mentorAssignment.findFirst({
|
|
where: { projectId: input.projectId! },
|
|
orderBy: { assignedAt: 'desc' },
|
|
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',
|
|
})
|
|
}
|
|
|
|
// Delete assignment
|
|
await ctx.prisma.mentorAssignment.delete({
|
|
where: { id: assignment.id },
|
|
})
|
|
|
|
// 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: assignment.project.id,
|
|
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,
|
|
mentorAssignments: { none: {} },
|
|
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: {
|
|
mentorAssignments: { none: { droppedAt: 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
|
|
|
|
// Coalesce per-mentor so we send ONE email per mentor at the end of the
|
|
// batch, even when the algorithm assigns the same mentor to several teams.
|
|
const perMentor = new Map<
|
|
string,
|
|
{
|
|
email: string | null
|
|
name: string | null
|
|
assignmentIds: string[]
|
|
projects: { id: string; title: string }[]
|
|
}
|
|
>()
|
|
|
|
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, email: 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,
|
|
},
|
|
})
|
|
|
|
// Accumulate for the coalesced email
|
|
const bucket = perMentor.get(mentorId) ?? {
|
|
email: assignment.mentor.email ?? null,
|
|
name: assignment.mentor.name ?? null,
|
|
assignmentIds: [],
|
|
projects: [],
|
|
}
|
|
bucket.assignmentIds.push(assignment.id)
|
|
bucket.projects.push({ id: project.id, title: assignment.project.title })
|
|
perMentor.set(mentorId, bucket)
|
|
|
|
assigned++
|
|
} catch (err) {
|
|
console.error(
|
|
'[Mentor] autoAssignBulkForRound failure for project',
|
|
project.id,
|
|
err,
|
|
)
|
|
unassignable++
|
|
}
|
|
}
|
|
|
|
// Defer all emails when the round is still ROUND_DRAFT — activateRound
|
|
// will coalesce and send them when the admin opens the round. Stamp
|
|
// notificationSentAt only for assignments we actually email about, so
|
|
// activateRound's `notificationSentAt IS NULL` filter catches the rest.
|
|
const roundStatus = await ctx.prisma.round.findUnique({
|
|
where: { id: input.roundId },
|
|
select: { status: true },
|
|
})
|
|
const isRoundLive = roundStatus?.status === 'ROUND_ACTIVE'
|
|
|
|
if (isRoundLive) {
|
|
for (const bucket of perMentor.values()) {
|
|
if (!bucket.email || bucket.projects.length === 0) continue
|
|
await sendMentorBulkAssignmentEmail(
|
|
bucket.email,
|
|
bucket.name,
|
|
bucket.projects,
|
|
)
|
|
try {
|
|
await ctx.prisma.mentorAssignment.updateMany({
|
|
where: { id: { in: bucket.assignmentIds } },
|
|
data: { notificationSentAt: new Date() },
|
|
})
|
|
} catch (e) {
|
|
console.error(
|
|
'[Mentor.autoAssignBulkForRound] failed to stamp notificationSentAt (non-fatal):',
|
|
e,
|
|
)
|
|
}
|
|
}
|
|
|
|
const introducedProjects = new Set<string>()
|
|
for (const bucket of perMentor.values()) {
|
|
for (const p of bucket.projects) {
|
|
if (introducedProjects.has(p.id)) continue
|
|
introducedProjects.add(p.id)
|
|
await introduceTeamToMentorsIfRoundOpen(ctx.prisma, p.id)
|
|
}
|
|
}
|
|
}
|
|
// If the round is still ROUND_DRAFT, no emails fire here — the assignments
|
|
// remain unstamped and activateRound will batch-send when the round opens.
|
|
|
|
const skipped = await ctx.prisma.projectRoundState.count({
|
|
where: {
|
|
roundId: input.roundId,
|
|
project: {
|
|
mentorAssignments: { some: {} },
|
|
...(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, mentorAssignments: { some: {} } },
|
|
},
|
|
}),
|
|
ctx.prisma.projectRoundState.count({
|
|
where: {
|
|
roundId: input.roundId,
|
|
project: { mentorAssignments: { some: {} } },
|
|
},
|
|
}),
|
|
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,
|
|
mentorAssignments: {
|
|
// TODO(PR8 Task 8): surface all mentors in the activity view.
|
|
// For now keep the legacy single-mentor activity row by picking the
|
|
// latest-assigned, non-dropped assignment (or the most-recent overall).
|
|
orderBy: { assignedAt: 'desc' },
|
|
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.
|
|
// TODO(PR8 Task 8): surface all mentors. Legacy shape: pick the most
|
|
// recent non-dropped assignment for the activity row.
|
|
const firstActive = p.mentorAssignments.find((a) => !a.droppedAt) ?? null
|
|
const ma = firstActive
|
|
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
|
|
}),
|
|
|
|
/**
|
|
* List all active mentors assigned to a project (PR8 multi-mentor).
|
|
*
|
|
* Returns one row per active MentorAssignment (droppedAt = null) with the
|
|
* mentor's id + name. Used by the mentor workspace page to display the
|
|
* co-mentor team so each mentor knows who else they're working with.
|
|
*
|
|
* Authorization: caller must be an active mentor on the project (or an
|
|
* admin via mentorProcedure). Non-assigned mentors get FORBIDDEN.
|
|
*/
|
|
getProjectMentors: mentorProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
if (!isAdmin) {
|
|
const ownAssignment = await ctx.prisma.mentorAssignment.findFirst({
|
|
where: {
|
|
projectId: input.projectId,
|
|
mentorId: ctx.user.id,
|
|
droppedAt: null,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
if (!ownAssignment) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not assigned to mentor this project',
|
|
})
|
|
}
|
|
}
|
|
|
|
const assignments = await ctx.prisma.mentorAssignment.findMany({
|
|
where: { projectId: input.projectId, droppedAt: null },
|
|
select: {
|
|
id: true,
|
|
mentor: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { assignedAt: 'asc' },
|
|
})
|
|
|
|
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' },
|
|
},
|
|
mentorAssignments: {
|
|
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,
|
|
projectId: assignment.projectId,
|
|
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 project's mentor workspace. Authorized for any mentor
|
|
* currently assigned to the project, or any team member of the project.
|
|
*
|
|
* Project-scoped (PR8): all co-mentors share one file list, and files
|
|
* survive even when an originating assignment is later dropped.
|
|
*/
|
|
workspaceGetFiles: protectedProcedure
|
|
.input(z.object({ projectId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, input.projectId)
|
|
return workspaceGetFilesService(input.projectId, ctx.prisma)
|
|
}),
|
|
|
|
/**
|
|
* Issue a short-lived presigned GET URL to download a workspace file.
|
|
*/
|
|
workspaceGetFileDownloadUrl: protectedProcedure
|
|
.input(z.object({
|
|
mentorFileId: z.string(),
|
|
disposition: z.enum(['inline', 'attachment']).default('attachment'),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
where: { id: input.mentorFileId },
|
|
select: { bucket: true, objectKey: true, fileName: true, mimeType: true, projectId: true },
|
|
})
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
|
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
|
|
input.disposition === 'inline'
|
|
? { inline: true, contentType: file.mimeType }
|
|
: { downloadFileName: file.fileName })
|
|
return { url }
|
|
}),
|
|
|
|
/**
|
|
* Delete a workspace file. Authorized for the uploader, any mentor
|
|
* currently assigned to the file's project, or any team member of the
|
|
* file's project. Final auth check lives in the service.
|
|
*/
|
|
workspaceDeleteFile: protectedProcedure
|
|
.input(z.object({ mentorFileId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const file = await ctx.prisma.mentorFile.findUnique({
|
|
where: { id: input.mentorFileId },
|
|
select: { projectId: true },
|
|
})
|
|
if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
|
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: { projectId: true },
|
|
})
|
|
if (!file) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' })
|
|
}
|
|
await assertProjectWorkspaceAccess(ctx.prisma, ctx.user.id, file.projectId)
|
|
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,
|
|
})),
|
|
}
|
|
}),
|
|
|
|
// ===========================================================================
|
|
// Mentor change requests (PR8)
|
|
//
|
|
// Applicants (team members) or admins can open a PENDING change request for
|
|
// a project — optionally targeting a specific co-mentor assignment. Admins
|
|
// are notified by email; mentors are intentionally NOT notified, even after
|
|
// resolution (per design decision in the PR8 plan).
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Open a new mentor change request. Allowed for:
|
|
* • SUPER_ADMIN / PROGRAM_ADMIN (any project), or
|
|
* • a team member of the target project.
|
|
*
|
|
* Rejects with CONFLICT if the same user already has an open (PENDING) request
|
|
* for the same project. The raw `reason` is intentionally NOT included in
|
|
* audit logs — only its length — for privacy. Email delivery to admins is
|
|
* best-effort and never throws.
|
|
*/
|
|
requestChange: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
projectId: z.string().min(1),
|
|
targetAssignmentId: z.string().min(1).optional(),
|
|
reason: z.string().min(10).max(2000),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
|
|
|
// Authorization: admin OR team member of the project
|
|
if (!isAdmin) {
|
|
const teamMembership = await ctx.prisma.teamMember.findFirst({
|
|
where: { projectId: input.projectId, userId: ctx.user.id },
|
|
select: { id: true },
|
|
})
|
|
if (!teamMembership) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not a member of this project',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Load project (also confirms it exists) and validate optional target
|
|
const project = await ctx.prisma.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { id: true, title: true },
|
|
})
|
|
if (!project) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
|
}
|
|
|
|
if (input.targetAssignmentId) {
|
|
const targetAssignment = await ctx.prisma.mentorAssignment.findUnique({
|
|
where: { id: input.targetAssignmentId },
|
|
select: { id: true, projectId: true },
|
|
})
|
|
if (!targetAssignment || targetAssignment.projectId !== input.projectId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Target assignment does not belong to this project',
|
|
})
|
|
}
|
|
}
|
|
|
|
// One open request per (user, project)
|
|
const existingOpen = await ctx.prisma.mentorChangeRequest.findFirst({
|
|
where: {
|
|
projectId: input.projectId,
|
|
requestedByUserId: ctx.user.id,
|
|
status: MentorChangeRequestStatus.PENDING,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
if (existingOpen) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'You already have an open mentor change request for this project.',
|
|
})
|
|
}
|
|
|
|
const created = await ctx.prisma.mentorChangeRequest.create({
|
|
data: {
|
|
projectId: input.projectId,
|
|
targetAssignmentId: input.targetAssignmentId ?? null,
|
|
requestedByUserId: ctx.user.id,
|
|
reason: input.reason,
|
|
status: MentorChangeRequestStatus.PENDING,
|
|
},
|
|
select: { id: true, status: true, createdAt: true },
|
|
})
|
|
|
|
// Notify admins (best-effort, never throw)
|
|
try {
|
|
const admins = await ctx.prisma.user.findMany({
|
|
where: {
|
|
OR: [
|
|
{ roles: { has: 'SUPER_ADMIN' } },
|
|
{ roles: { has: 'PROGRAM_ADMIN' } },
|
|
],
|
|
status: 'ACTIVE',
|
|
},
|
|
select: { email: true },
|
|
})
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
|
const adminDashboardUrl = `${baseUrl.replace(/\/$/, '')}/admin/projects/${input.projectId}/mentor`
|
|
await sendMentorChangeRequestEmail(
|
|
admins.map((a) => a.email),
|
|
project.title,
|
|
ctx.user.name ?? null,
|
|
input.reason,
|
|
adminDashboardUrl,
|
|
)
|
|
} catch (err) {
|
|
// Defense-in-depth: the helper already has its own try/catch
|
|
console.error('[mentor.requestChange] notify admins failed:', err)
|
|
}
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'MENTOR_CHANGE_REQUEST_CREATE',
|
|
entityType: 'MentorChangeRequest',
|
|
entityId: created.id,
|
|
detailsJson: {
|
|
projectId: input.projectId,
|
|
targetAssignmentId: input.targetAssignmentId ?? null,
|
|
reasonLength: input.reason.length,
|
|
},
|
|
})
|
|
|
|
return created
|
|
}),
|
|
|
|
/**
|
|
* Admin inbox — list MentorChangeRequest rows, optionally filtered by status
|
|
* and/or project. PENDING rows are surfaced first; within each status group
|
|
* rows are ordered by createdAt desc. No pagination (low-volume admin view).
|
|
*/
|
|
listChangeRequests: adminProcedure
|
|
.input(
|
|
z
|
|
.object({
|
|
status: z.nativeEnum(MentorChangeRequestStatus).optional(),
|
|
projectId: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const where: Prisma.MentorChangeRequestWhereInput = {}
|
|
if (input?.status) where.status = input.status
|
|
if (input?.projectId) where.projectId = input.projectId
|
|
|
|
const rows = await ctx.prisma.mentorChangeRequest.findMany({
|
|
where,
|
|
include: {
|
|
project: { select: { id: true, title: true } },
|
|
targetAssignment: {
|
|
select: {
|
|
id: true,
|
|
mentor: { select: { id: true, name: true, email: true } },
|
|
},
|
|
},
|
|
requestedBy: { select: { id: true, name: true, email: true } },
|
|
resolvedBy: { select: { id: true, name: true, email: true } },
|
|
},
|
|
// PENDING first, then RESOLVED/DISMISSED. Within each: newest first.
|
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
|
})
|
|
|
|
// Enum order is PENDING < RESOLVED < DISMISSED alphabetically — DISMISSED
|
|
// is "D" so it sorts before PENDING. Re-sort in JS to guarantee PENDING
|
|
// appears first regardless of enum string ordering.
|
|
const statusRank: Record<MentorChangeRequestStatus, number> = {
|
|
[MentorChangeRequestStatus.PENDING]: 0,
|
|
[MentorChangeRequestStatus.RESOLVED]: 1,
|
|
[MentorChangeRequestStatus.DISMISSED]: 2,
|
|
}
|
|
return rows.sort((a, b) => {
|
|
const sa = statusRank[a.status] - statusRank[b.status]
|
|
if (sa !== 0) return sa
|
|
return b.createdAt.getTime() - a.createdAt.getTime()
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Admin resolves a PENDING request as RESOLVED or DISMISSED. Re-resolution
|
|
* is rejected. No email or notification is sent to the requester or mentors
|
|
* (per PR8 design decision — mentors are never informed of change requests).
|
|
*/
|
|
resolveChangeRequest: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string().min(1),
|
|
status: z.enum(['RESOLVED', 'DISMISSED']),
|
|
resolutionNote: z.string().max(2000).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.prisma.mentorChangeRequest.findUnique({
|
|
where: { id: input.id },
|
|
select: { id: true, status: true, projectId: true },
|
|
})
|
|
if (!existing) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Request not found' })
|
|
}
|
|
if (existing.status !== MentorChangeRequestStatus.PENDING) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Request already resolved',
|
|
})
|
|
}
|
|
|
|
const updated = await ctx.prisma.mentorChangeRequest.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
status: input.status as MentorChangeRequestStatus,
|
|
resolvedByUserId: ctx.user.id,
|
|
resolvedAt: new Date(),
|
|
resolutionNote: input.resolutionNote ?? null,
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'MENTOR_CHANGE_REQUEST_RESOLVE',
|
|
entityType: 'MentorChangeRequest',
|
|
entityId: existing.id,
|
|
detailsJson: {
|
|
status: input.status,
|
|
projectId: existing.projectId,
|
|
},
|
|
})
|
|
|
|
return updated
|
|
}),
|
|
})
|