fix(members): role tabs/filter include users with secondary roles
user.list and user.listInvitableIds filtered on the singular User.role column, so the type tabs (Jury/Mentor/…) omitted users holding that role as a secondary role (User.roles[]). Match the role as primary OR secondary (roles hasSome), combined with search via AND, mirroring userHasRole / hasRole middleware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,19 +232,26 @@ export const userRouter = router({
|
|||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {}
|
||||||
|
const and: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
// 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) {
|
if (roles && roles.length > 0) {
|
||||||
where.role = { in: roles }
|
and.push({ OR: [{ role: { in: roles } }, { roles: { hasSome: roles } }] })
|
||||||
} else if (role) {
|
} else if (role) {
|
||||||
where.role = role
|
and.push({ OR: [{ role }, { roles: { has: role } }] })
|
||||||
}
|
}
|
||||||
if (status) where.status = status
|
if (status) where.status = status
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
and.push({
|
||||||
|
OR: [
|
||||||
{ email: { contains: search, mode: 'insensitive' } },
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
]
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
if (and.length > 0) where.AND = and
|
||||||
|
|
||||||
const dir = sortDir ?? 'asc'
|
const dir = sortDir ?? 'asc'
|
||||||
const orderBy: Record<string, string> = sortBy
|
const orderBy: Record<string, string> = sortBy
|
||||||
@@ -373,19 +380,24 @@ export const userRouter = router({
|
|||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
status: { in: ['NONE', 'INVITED'] },
|
status: { in: ['NONE', 'INVITED'] },
|
||||||
}
|
}
|
||||||
|
const and: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
// Match primary OR secondary role (see user.list for rationale).
|
||||||
if (input.roles && input.roles.length > 0) {
|
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) {
|
} else if (input.role) {
|
||||||
where.role = input.role
|
and.push({ OR: [{ role: input.role }, { roles: { has: input.role } }] })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
where.OR = [
|
and.push({
|
||||||
|
OR: [
|
||||||
{ email: { contains: input.search, mode: 'insensitive' } },
|
{ email: { contains: input.search, mode: 'insensitive' } },
|
||||||
{ name: { 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({
|
const users = await ctx.prisma.user.findMany({
|
||||||
where,
|
where,
|
||||||
|
|||||||
93
tests/unit/members-secondary-role-filter.test.ts
Normal file
93
tests/unit/members-secondary-role-filter.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user