feat(mentor): allow stacking mentors per team; send per-team assignment email

- mentor.assign no longer rejects on existing mentor; rejects only on
  duplicate (projectId, mentorId) via P2002 catch.
- After successful create, sendMentorTeamAssignmentEmail fires once and
  stamps MentorAssignment.notificationSentAt for idempotency.
- All existing behavior preserved: audit log, in-app notifications,
  MENTORING round auto-transition.
- mentor.getSuggestions no longer short-circuits when a mentor is already
  assigned — the suggestions list is now informational and the per-pair
  unique constraint enforces correctness at assign time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-22 16:38:14 +02:00
parent 66110598a0
commit a5ad11a1b5

View File

@@ -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({