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 { 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({
|
||||||
|
|||||||
Reference in New Issue
Block a user