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:
@@ -315,7 +315,10 @@ export const mentorRouter = router({
|
||||
|
||||
const mentors = await ctx.prisma.user.findMany({
|
||||
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' },
|
||||
},
|
||||
select: {
|
||||
@@ -728,9 +731,11 @@ export const mentorRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const mentors = await ctx.prisma.user.findMany({
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -1574,7 +1579,9 @@ export const mentorRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const mentors = await ctx.prisma.user.findMany({
|
||||
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' },
|
||||
},
|
||||
select: {
|
||||
|
||||
98
tests/unit/mentor-primary-role-assignable.test.ts
Normal file
98
tests/unit/mentor-primary-role-assignable.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user