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