diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 91d75ca..f55e74b 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' -import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client' +import { MentorAssignmentMethod, Prisma, type PrismaClient } from '@prisma/client' +import { sendMentorTeamAssignmentEmail } from '@/lib/email' import { getAIMentorSuggestions, getRoundRobinMentor, @@ -86,16 +87,11 @@ export const mentorRouter = router({ }, }) - // TODO(PR8 Task 8): surface all mentors. Legacy single-mentor early-return. + // 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 - if (primaryMentor) { - return { - currentMentor: primaryMentor, - suggestions: [], - source: 'ai' as const, - message: 'Project already has a mentor assigned', - } - } // Detect AI configuration so the UI can label "AI matching unavailable" // when we fall back to algorithmic ranking. An AI error mid-call still @@ -142,7 +138,9 @@ export const mentorRouter = router({ }) return { - currentMentor: null, + // 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, @@ -221,52 +219,62 @@ export const mentorRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Verify project exists and doesn't have a mentor + // 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 }, - include: { mentorAssignments: { select: { id: true } } }, }) - if (project.mentorAssignments.length > 0) { - throw new TRPCError({ - code: 'CONFLICT', - message: 'Project already has a mentor assigned', - }) - } - // Verify mentor exists const mentor = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.mentorId }, }) - // Create assignment - const assignment = await ctx.prisma.mentorAssignment.create({ - data: { - projectId: input.projectId, - mentorId: input.mentorId, - method: input.method, - assignedBy: ctx.user.id, - aiConfidenceScore: input.aiConfidenceScore, - expertiseMatchScore: input.expertiseMatchScore, - aiReasoning: input.aiReasoning, - }, - include: { - mentor: { - select: { - id: true, - name: true, - email: true, - expertiseTags: true, + // 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, + }, }, }, - 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({ @@ -281,6 +289,8 @@ export const mentorRouter = router({ 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, @@ -322,6 +332,27 @@ export const mentorRouter = router({ }, }) + // Send per-team email notification once per assignment row. Idempotency + // is enforced via MentorAssignment.notificationSentAt — a fresh row has + // it null. If the same mentor is later dropped and re-assigned (new row, + // fresh id), a new email is sent — intentional. + if (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({