diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 7b7442b..02cfac9 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -232,19 +232,26 @@ export const userRouter = router({ const skip = (page - 1) * perPage const where: Record = {} + const and: Record[] = [] + // Match the role as EITHER the primary role (User.role) or a secondary + // role (User.roles[]) so the type tabs include multi-role users, mirroring + // the multi-role checks used elsewhere (userHasRole / hasRole middleware). if (roles && roles.length > 0) { - where.role = { in: roles } + and.push({ OR: [{ role: { in: roles } }, { roles: { hasSome: roles } }] }) } else if (role) { - where.role = role + and.push({ OR: [{ role }, { roles: { has: role } }] }) } if (status) where.status = status if (search) { - where.OR = [ - { email: { contains: search, mode: 'insensitive' } }, - { name: { contains: search, mode: 'insensitive' } }, - ] + and.push({ + OR: [ + { email: { contains: search, mode: 'insensitive' } }, + { name: { contains: search, mode: 'insensitive' } }, + ], + }) } + if (and.length > 0) where.AND = and const dir = sortDir ?? 'asc' const orderBy: Record = sortBy @@ -373,19 +380,24 @@ export const userRouter = router({ const where: Record = { status: { in: ['NONE', 'INVITED'] }, } + const and: Record[] = [] + // Match primary OR secondary role (see user.list for rationale). if (input.roles && input.roles.length > 0) { - where.role = { in: input.roles } + and.push({ OR: [{ role: { in: input.roles } }, { roles: { hasSome: input.roles } }] }) } else if (input.role) { - where.role = input.role + and.push({ OR: [{ role: input.role }, { roles: { has: input.role } }] }) } if (input.search) { - where.OR = [ - { email: { contains: input.search, mode: 'insensitive' } }, - { name: { contains: input.search, mode: 'insensitive' } }, - ] + and.push({ + OR: [ + { email: { contains: input.search, mode: 'insensitive' } }, + { name: { contains: input.search, mode: 'insensitive' } }, + ], + }) } + if (and.length > 0) where.AND = and const users = await ctx.prisma.user.findMany({ where, diff --git a/tests/unit/members-secondary-role-filter.test.ts b/tests/unit/members-secondary-role-filter.test.ts new file mode 100644 index 0000000..d138fea --- /dev/null +++ b/tests/unit/members-secondary-role-filter.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { prisma, createCaller } from '../setup' +import { createTestUser, uid } from '../helpers' +import { userRouter } from '../../src/server/routers/user' + +/** + * Regression: the admin Members tabs filter by type (Jury, Mentor, …) must + * include users who hold that type as a SECONDARY role (User.roles[]), not only + * users whose PRIMARY role (User.role) matches. Previously the query filtered on + * `where.role` alone, so multi-role users were missing from the relevant tab. + */ +describe('user.list / listInvitableIds — role filter includes secondary roles', () => { + const token = uid('secrole') + const ids: string[] = [] + let adminId = '' + let aId = '' // primary JURY_MEMBER + secondary MENTOR + let bId = '' // primary MENTOR + let cId = '' // JURY only (control) + + beforeAll(async () => { + const admin = await createTestUser('SUPER_ADMIN') + adminId = admin.id + ids.push(admin.id) + + const a = await prisma.user.create({ + data: { + id: uid('u'), + email: `${token}-a@test.local`, + name: `${token} A`, + role: 'JURY_MEMBER', + roles: ['JURY_MEMBER', 'MENTOR'], + status: 'NONE', + }, + }) + const b = await prisma.user.create({ + data: { + id: uid('u'), + email: `${token}-b@test.local`, + name: `${token} B`, + role: 'MENTOR', + roles: ['MENTOR'], + status: 'NONE', + }, + }) + const c = await prisma.user.create({ + data: { + id: uid('u'), + email: `${token}-c@test.local`, + name: `${token} C`, + role: 'JURY_MEMBER', + roles: ['JURY_MEMBER'], + status: 'NONE', + }, + }) + aId = a.id + bId = b.id + cId = c.id + ids.push(a.id, b.id, c.id) + }) + + afterAll(async () => { + await prisma.user.deleteMany({ where: { id: { in: ids } } }) + }) + + it('list: the Mentor tab includes a user whose MENTOR role is secondary', async () => { + const caller = createCaller(userRouter, { + id: adminId, + email: 'admin@test.local', + role: 'SUPER_ADMIN', + }) + + const res = await caller.list({ roles: ['MENTOR'], search: token, perPage: 100 }) + const returnedIds = res.users.map((u: { id: string }) => u.id) + + expect(returnedIds).toContain(bId) // primary mentor (already worked) + expect(returnedIds).toContain(aId) // secondary mentor (the bug) + expect(returnedIds).not.toContain(cId) // jury-only must NOT appear + }) + + it('listInvitableIds: the Mentor tab includes a user whose MENTOR role is secondary', async () => { + const caller = createCaller(userRouter, { + id: adminId, + email: 'admin@test.local', + role: 'SUPER_ADMIN', + }) + + const res = await caller.listInvitableIds({ roles: ['MENTOR'], search: token }) + + expect(res.userIds).toContain(bId) + expect(res.userIds).toContain(aId) + expect(res.userIds).not.toContain(cId) + }) +})