fix(mentor): primary-role mentors are selectable/assignable

getCandidates, getMentorPool and bulkAssign matched MENTOR via roles[] only, so
a user with role=MENTOR but an empty roles[] array (legacy/seeded records, e.g.
Arnaud Blandin on prod) was excluded from the mentor picker and rejected by
bulk assign. Match MENTOR as primary role OR in roles[], mirroring userHasRole.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-01 19:34:40 +02:00
parent fe7f133879
commit 828c09df6d
2 changed files with 109 additions and 4 deletions

View File

@@ -315,7 +315,10 @@ export const mentorRouter = router({
const mentors = await ctx.prisma.user.findMany({ const mentors = await ctx.prisma.user.findMany({
where: { where: {
roles: { has: 'MENTOR' }, // MENTOR as primary role OR in the secondary roles[] array. Some legacy
// users have role=MENTOR with an empty roles[], so checking roles only
// would wrongly exclude them (mirrors userHasRole's fallback).
OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
status: { not: 'SUSPENDED' }, status: { not: 'SUSPENDED' },
}, },
select: { select: {
@@ -728,9 +731,11 @@ export const mentorRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({ const mentors = await ctx.prisma.user.findMany({
where: { id: { in: input.mentorIds } }, where: { id: { in: input.mentorIds } },
select: { id: true, name: true, email: true, roles: true }, select: { id: true, name: true, email: true, role: true, roles: true },
}) })
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR')) // Accept MENTOR as primary role OR in roles[] (legacy users may have
// role=MENTOR with an empty roles[] array).
const validMentors = mentors.filter((m) => m.roles.includes('MENTOR') || m.role === 'MENTOR')
if (validMentors.length === 0) { if (validMentors.length === 0) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@@ -1574,7 +1579,9 @@ export const mentorRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const mentors = await ctx.prisma.user.findMany({ const mentors = await ctx.prisma.user.findMany({
where: { where: {
roles: { has: 'MENTOR' }, // MENTOR as primary role OR in the secondary roles[] array (see
// getCandidates: legacy users may have role=MENTOR with empty roles[]).
OR: [{ role: 'MENTOR' }, { roles: { has: 'MENTOR' } }],
status: { not: 'SUSPENDED' }, status: { not: 'SUSPENDED' },
}, },
select: { select: {

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
return {
...actual,
sendMentorBulkAssignmentEmail: vi.fn(async () => true),
sendTeamMentorIntroductionEmail: vi.fn(async () => true),
}
})
import { prisma, createCaller } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
/**
* Regression: a user whose MENTOR role is the PRIMARY role but whose roles[]
* array is empty (legacy/seeded records, e.g. prod user "Arnaud Blandin") must
* still be selectable + assignable on the mentor config page. Previously the
* candidate query filtered `roles: { has: 'MENTOR' }`, which excludes an empty
* roles[] even when role === 'MENTOR'.
*/
describe('mentor.getCandidates / bulkAssign — primary-role-only mentors are assignable', () => {
let programId = ''
let projectId = ''
let mentorId = ''
let adminId = ''
const userIds: string[] = []
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(program.id)
const round = await createTestRound(competition.id, {
roundType: 'MENTORING',
status: 'ROUND_DRAFT', // draft → assignment emails are deferred
})
const project = await createTestProject(program.id)
projectId = project.id
await createTestProjectRoundState(project.id, round.id)
// The crux: primary role MENTOR, but roles[] is EMPTY.
const mentor = await prisma.user.create({
data: {
id: uid('u'),
email: `${uid('arnaud')}@test.local`,
name: 'Primary Only Mentor',
role: 'MENTOR',
roles: [],
status: 'ACTIVE',
},
})
mentorId = mentor.id
const admin = await createTestUser('SUPER_ADMIN')
adminId = admin.id
userIds.push(mentor.id, admin.id)
})
afterAll(async () => {
await cleanupTestData(programId, userIds)
})
it('getCandidates includes a primary-role-only mentor', async () => {
const caller = createCaller(mentorRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
const res = await caller.getCandidates({ projectId })
const ids = res.candidates.map((c: { id: string }) => c.id)
expect(ids).toContain(mentorId)
})
it('bulkAssign accepts a primary-role-only mentor and creates the assignment', async () => {
const caller = createCaller(mentorRouter, {
id: adminId,
email: 'admin@test.local',
role: 'SUPER_ADMIN',
})
// Must not throw "None of the selected users have the MENTOR role".
await caller.bulkAssign({ mentorIds: [mentorId], projectIds: [projectId] })
const assignment = await prisma.mentorAssignment.findFirst({
where: { projectId, mentorId },
})
expect(assignment).not.toBeNull()
})
})