Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
deac280
fix(user): legacy 400 on unknown admin role, matching take:5, hook un…
shikanime Aug 13, 2026
8c0601a
chore(user): drop section divider comments
shikanime Sep 18, 2026
75db766
refactor(user): realigner structure et style du module
shikanime Sep 23, 2026
777b376
refactor(user): retirer l'alias AllUsersQuery au profit du schéma
shikanime Sep 23, 2026
105596c
test(user): lock controller permission matrix
shikanime Sep 25, 2026
c80ddfc
fix(user): purge dead create path and lock legacy parity
shikanime Sep 25, 2026
862e6d7
refactor(user): remove reflection-only permissions metadata check
shikanime Sep 28, 2026
2936564
fix(user): emit canonical AdminRoleEventPayload and type controller i…
shikanime Sep 30, 2026
e210617
style(user): drop mock-wiring narration comment
shikanime Sep 30, 2026
e305dc0
refactor(user): batch AND filters into single pushes
shikanime Oct 2, 2026
63345cb
fix(user): use the projectMembers relation key in memberOfIds filter
shikanime Oct 2, 2026
a2cf7f4
fix(user): legacy 400 on unknown admin role, matching take:5, hook un…
shikanime Aug 13, 2026
c00e206
chore(user): drop section divider comments
shikanime Sep 18, 2026
effb5d6
refactor(user): realigner structure et style du module
shikanime Sep 23, 2026
cac74d2
refactor(user): retirer l'alias AllUsersQuery au profit du schéma
shikanime Sep 23, 2026
caecf77
test(user): lock controller permission matrix
shikanime Sep 25, 2026
651e6d6
fix(user): purge dead create path and lock legacy parity
shikanime Sep 25, 2026
8d0dc83
refactor(user): remove reflection-only permissions metadata check
shikanime Sep 28, 2026
74133da
fix(user): emit canonical AdminRoleEventPayload and type controller i…
shikanime Sep 30, 2026
ba5e73d
style(user): drop mock-wiring narration comment
shikanime Sep 30, 2026
9d8cef6
refactor(user): move the letters filter inside its guard
shikanime Oct 2, 2026
8c1a923
fix(user): restrict UserGuard to list and patch routes
shikanime Oct 2, 2026
8731b8a
test(user): drop guard metadata specs
shikanime Oct 2, 2026
5a117f4
fix(user): use the projectMembers relation key in memberOfIds filter
shikanime Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ProjectModule } from './modules/project/project.module'
import { RepositoryModule } from './modules/repository/repository.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserModule } from './modules/user/user.module'
import { VersionModule } from './modules/version/version.module'
import { getDotenvPaths } from './utils/dotenv.utils'

Expand Down Expand Up @@ -51,6 +52,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ScheduleModule.forRoot(),
SystemConfigModule,
SystemSettingsModule,
UserModule,
VersionModule,
],
controllers: [],
Expand Down
85 changes: 85 additions & 0 deletions apps/server-nestjs/src/modules/user/user-queries.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared'
import type { Prisma, User } from '@prisma/client'
import type { z } from 'zod'
import type { PrismaService } from '../infrastructure/database/prisma.service'
import { BadRequestException } from '@nestjs/common'

export function getUsers(tx: Prisma.TransactionClient, where?: Prisma.UserWhereInput) {
return tx.user.findMany({ where })
}

export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) {
return tx.user.findMany({
where,
take: 5,
})
}

export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) {
return tx.adminRole.findMany({ where: { name: { in: names } } })
}

export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) {
return tx.user.update({
where: { id },
data: { adminRoleIds },
})
}

export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) {
return Promise.all(users
.filter((user): user is typeof user & { adminRoleIds: string[] } => user.adminRoleIds !== null)
.map(user => updateUserAdminRoleIds(tx, user.id, user.adminRoleIds)))
}

export async function buildAllUsersWhere(
prisma: PrismaService,
{ relationType: relation, ...query }: z.infer<typeof AllUsersQuerySchema>,
): Promise<Prisma.UserWhereInput> {
const relationType = relation ?? 'AND'
const whereInputs: Prisma.UserWhereInput[] = []
if (query.adminRoleIds?.length) {
whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } })
}
if (query.adminRoles?.length) {
const roles = await getAdminRolesByName(prisma, query.adminRoles)
const adminRoleNameNotFound = query.adminRoles.find(nameQueried => !roles.some(({ name }) => name === nameQueried))
if (adminRoleNameNotFound) {
throw new BadRequestException(`Unable to find adminRole ${adminRoleNameNotFound}`)
}
whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } })
}
if (query.memberOfIds) {
whereInputs.push({
AND: query.memberOfIds.map(id => ({
OR: [
{ projectsOwned: { some: { id } } },
{ projectMembers: { some: { project: { id } } } },
],
})),
})
}
return { [relationType]: whereInputs }
}

export function buildMatchingUsersWhere(query: LettersQuery): Prisma.UserWhereInput {
const AND: Prisma.UserWhereInput[] = []
if (query.notInProjectId) {
AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } })
AND.push({ projectsOwned: { none: { id: query.notInProjectId } } })

Check notice on line 69 in apps/server-nestjs/src/modules/user/user-queries.utils.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/user/user-queries.utils.ts#L69

Do not call `Array#push()` multiple times.
}
if (query.letters) {
const filter = { contains: query.letters, mode: 'insensitive' } as const
AND.push({
OR: [{
email: filter,
}, {
firstName: filter,
}, {
lastName: filter,
}],
})
AND.push({ type: 'human' })

Check notice on line 82 in apps/server-nestjs/src/modules/user/user-queries.utils.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/user/user-queries.utils.ts#L82

Do not call `Array#push()` multiple times.
}
return { AND }
}
29 changes: 29 additions & 0 deletions apps/server-nestjs/src/modules/user/user-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AdminRole, User } from '@prisma/client'
import { faker } from '@faker-js/faker'

export function makeUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
type: 'human',
adminRoleIds: [],
lastLogin: faker.date.past(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
...overrides,
} satisfies User
}

export function makeAdminRole(overrides: Partial<AdminRole> = {}): AdminRole {
return {
id: faker.string.uuid(),
name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(),
permissions: 0n,
position: faker.number.int({ min: 0, max: 100 }),
oidcGroup: '',
type: 'managed',
...overrides,
} satisfies AdminRole
}
65 changes: 65 additions & 0 deletions apps/server-nestjs/src/modules/user/user.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { TestingModule } from '@nestjs/testing'
import type { MockProxy } from 'vitest-mock-extended'
import { AllUsersQuerySchema } from '@cpn-console/shared'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mock } from 'vitest-mock-extended'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { makeUser } from './user-testing.utils'
import { UserController } from './user.controller'
import { UserService } from './user.service'
import { toContractUser } from './user.utils'

describe('userController', () => {
let module: TestingModule
let controller: UserController
let service: MockProxy<UserService>

beforeEach(async () => {
service = mock<UserService>()

module = await Test.createTestingModule({
controllers: [UserController],
providers: [
{ provide: UserService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<UserController>(UserController)
})

it('should be defined', () => {
expect(controller).toBeDefined()
})

it('delegates list with relationType defaulted to AND and stripped from the query', async () => {
const users = [makeUser()]
service.getAllUsers.mockResolvedValue(users)

const result = await controller.getAllUsers(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' }))

expect(result).toEqual(users.map(toContractUser))
expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' }))
})

it('delegates matching query to the service', async () => {
const users = [makeUser()]
service.getMatchingUsers.mockResolvedValue(users)
const query = { letters: 'ab' }

expect(await controller.getMatchingUsers(query)).toEqual(users.map(toContractUser))
expect(service.getMatchingUsers).toHaveBeenCalledWith(query)
})

it('delegates patch body to the service', async () => {
const users = [makeUser()]
service.patchUsers.mockResolvedValue(users)
const body = [{ id: users[0].id, adminRoleIds: ['r1'] }]

expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser))
expect(service.patchUsers).toHaveBeenCalledWith(body)
})
})
42 changes: 42 additions & 0 deletions apps/server-nestjs/src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { AllUsers, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared'
import type { z } from 'zod'
import { AllUsersQuerySchema, MatchingUsersQuerySchema, PatchUsersBodySchema } from '@cpn-console/shared'
import { Body, Controller, Get, Inject, Patch, Query, UseGuards } from '@nestjs/common'
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { UserService } from './user.service'
import { toContractUser } from './user.utils'

@Controller('api/v1/users')
Comment thread
shikanime marked this conversation as resolved.
export class UserController {
constructor(@Inject(UserService) private readonly userService: UserService) {}

@Get()
@UseGuards(UserGuard)
@RequireAdminPermission('ManageUsers')
async getAllUsers(
@Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer<typeof AllUsersQuerySchema>,
): Promise<AllUsers> {
const users = await this.userService.getAllUsers(query)
return users.map(toContractUser)
}

@Get('matching')
async getMatchingUsers(
@Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery,
): Promise<MatchingUsers> {
const users = await this.userService.getMatchingUsers(query)
return users.map(toContractUser)
}

@Patch()
@UseGuards(UserGuard)
@RequireAdminPermission('ManageUsers')
async patchUsers(
@Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody,
): Promise<PatchUsers> {
const updatedUsers = await this.userService.patchUsers(users)
return updatedUsers.map(toContractUser)
}
}
12 changes: 12 additions & 0 deletions apps/server-nestjs/src/modules/user/user.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
import { UserController } from './user.controller'
import { UserService } from './user.service'

@Module({
imports: [InfrastructureModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
110 changes: 110 additions & 0 deletions apps/server-nestjs/src/modules/user/user.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { Prisma } from '@prisma/client'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { BadRequestException } from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { makeAdminRole, makeUser } from './user-testing.utils'
import { UserService } from './user.service'

describe('userService', () => {
let service: UserService
let prisma: DeepMockProxy<PrismaService>
let events: DeepMockProxy<EventEmitter2>

beforeEach(async () => {
prisma = mockDeep<PrismaService>()
events = mockDeep<EventEmitter2>()

const moduleRef = await Test.createTestingModule({
providers: [
UserService,
{ provide: PrismaService, useValue: prisma },
{ provide: EventEmitter2, useValue: events },
],
}).compile()

service = moduleRef.get(UserService)
})

it('lists all users with the query filters', async () => {
const users = [makeUser(), makeUser()]
prisma.user.findMany.mockResolvedValue(users)

const result = await service.getAllUsers({})

expect(result).toEqual(users)
expect(prisma.user.findMany).toHaveBeenCalled()
})

it('filters users by admin roles', async () => {
prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })])
prisma.user.findMany.mockResolvedValue([makeUser()])

await service.getAllUsers({ adminRoles: ['admin'] })

expect(prisma.adminRole.findMany).toHaveBeenCalled()
expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { AND: expect.any(Array) },
}))
})

it('rejects an unknown admin role name with the legacy 400', async () => {
prisma.adminRole.findMany.mockResolvedValue([])

await expect(
service.getAllUsers({ adminRoles: ['ghost-role'] }),
).rejects.toThrow(BadRequestException)

await expect(
service.getAllUsers({ adminRoles: ['ghost-role'] }),
).rejects.toThrow('Unable to find adminRole ghost-role')
})

it('returns matching users by letters, capped at 5 like the legacy query', async () => {
const users = [makeUser()]
prisma.user.findMany.mockResolvedValue(users)

const result = await service.getMatchingUsers({ letters: 'joh' })

expect(result).toHaveLength(1)
expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({
take: 5,
where: { AND: expect.arrayContaining([expect.objectContaining({ type: 'human' })]) },
}))
})

it('patches users and emits the union of before and after admin roles', async () => {
const user = makeUser({ adminRoleIds: ['role-0'] })
prisma.user.findMany.mockImplementation(async (_args) => {
return [_args.select?.adminRoleIds ? { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, adminRoleIds: ['role-0', 'role-1'] } : user]
})
prisma.adminRole.findMany.mockResolvedValue([
makeAdminRole({ id: 'role-0', oidcGroup: 'group-0' }),
makeAdminRole({ id: 'role-1', oidcGroup: 'group-1' }),
])
const tx = mockDeep<Prisma.TransactionClient>()
tx.user.update.mockResolvedValue(user)
prisma.$transaction.mockImplementation(async cb => cb(tx))

const result = await service.patchUsers([{ id: user.id, adminRoleIds: ['role-1'] }])

expect(tx.user.update).toHaveBeenCalledWith({
where: { id: user.id },
data: { adminRoleIds: ['role-1'] },
})
expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', {
id: 'role-0',
oidcGroup: 'group-0',
members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }],
})
expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', {
id: 'role-1',
oidcGroup: 'group-1',
members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }],
})
expect(result).toHaveLength(1)
})
})
Loading
Loading