From 828c09df6d310413dcc451aeb10ee304d17ba447 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 1 Jun 2026 19:34:40 +0200 Subject: [PATCH] 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) --- src/server/routers/mentor.ts | 15 ++- .../mentor-primary-role-assignable.test.ts | 98 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/unit/mentor-primary-role-assignable.test.ts diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 426e45f..5ad2f13 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -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: { diff --git a/tests/unit/mentor-primary-role-assignable.test.ts b/tests/unit/mentor-primary-role-assignable.test.ts new file mode 100644 index 0000000..c9ec060 --- /dev/null +++ b/tests/unit/mentor-primary-role-assignable.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' + +vi.mock('@/lib/email', async () => { + const actual = await vi.importActual('@/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() + }) +})