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 { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' 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 { import {
getAIMentorSuggestions, getAIMentorSuggestions,
getRoundRobinMentor, 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 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" // Detect AI configuration so the UI can label "AI matching unavailable"
// when we fall back to algorithmic ranking. An AI error mid-call still // when we fall back to algorithmic ranking. An AI error mid-call still
@@ -142,7 +138,9 @@ export const mentorRouter = router({
}) })
return { 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), suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
source, source,
message: null, message: null,
@@ -221,26 +219,24 @@ export const mentorRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, 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 // Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({ const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId }, where: { id: input.mentorId },
}) })
// Create assignment // Create assignment. P2002 on the composite (projectId, mentorId) unique
const assignment = await ctx.prisma.mentorAssignment.create({ // constraint means this exact mentor is already on this team — surface a
// friendly error.
let assignment
try {
assignment = await ctx.prisma.mentorAssignment.create({
data: { data: {
projectId: input.projectId, projectId: input.projectId,
mentorId: input.mentorId, mentorId: input.mentorId,
@@ -267,6 +263,18 @@ export const mentorRouter = router({
}, },
}, },
}) })
} 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 // Audit outside transaction so failures don't roll back the assignment
await logAudit({ await logAudit({
@@ -281,6 +289,8 @@ export const mentorRouter = router({
mentorId: input.mentorId, mentorId: input.mentorId,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,
method: input.method, method: input.method,
// PR8: per-team assignment (one row per mentor-project pair).
assignmentScope: 'per-team',
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, 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 // Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try { try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({ const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({