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:
@@ -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,26 +219,24 @@ 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({
|
||||
// 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,
|
||||
@@ -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
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user