From deac2804eb1751b827604499b036f5116a9517bf Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 13 Aug 2026 16:27:09 +0200 Subject: [PATCH 01/24] fix(user): legacy 400 on unknown admin role, matching take:5, hook union semantics Co-authored-by: Automata --- apps/server-nestjs/src/main.module.ts | 2 + .../src/modules/user/user-queries.utils.ts | 52 +++++++ .../src/modules/user/user-testing.utils.ts | 29 ++++ .../src/modules/user/user.controller.ts | 42 ++++++ .../src/modules/user/user.module.ts | 20 +++ .../src/modules/user/user.service.spec.ts | 96 +++++++++++++ .../src/modules/user/user.service.ts | 128 ++++++++++++++++++ 7 files changed, 369 insertions(+) create mode 100644 apps/server-nestjs/src/modules/user/user-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/user/user-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/user/user.controller.ts create mode 100644 apps/server-nestjs/src/modules/user/user.module.ts create mode 100644 apps/server-nestjs/src/modules/user/user.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/user/user.service.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 88fb9a02c1..d25bb5e3b2 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -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' @@ -51,6 +52,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ScheduleModule.forRoot(), SystemConfigModule, SystemSettingsModule, + UserModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts new file mode 100644 index 0000000000..cda8c14c64 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -0,0 +1,52 @@ +import type { Prisma, User } from '@prisma/client' +import type { PrismaService } from '../infrastructure/database/prisma.service' + +type UserCreate = Omit + +// ── selects ─────────────────────────────────────────────────────────────────────────── +export const userSelect = { + id: true, + firstName: true, + lastName: true, + email: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + adminRoleIds: true, + type: true, +} satisfies Prisma.UserSelect +export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> + +// ── queries ─────────────────────────────────────────────────────────────────────────── +export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { + return prisma.user.findMany({ where }) +} + +export function getMatchingUsers(prisma: PrismaService, where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserByEmail(prisma: PrismaService, email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +export function getAdminRolesByName(prisma: PrismaService, names: string[]) { + return prisma.adminRole.findMany({ where: { name: { in: names } } }) +} + +export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], adminRoleIds: string[]) { + return prisma.user.update({ + where: { id }, + data: { adminRoleIds }, + }) +} + +// ── create ───────────────────────────────────────────────────────────────────────────── +export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(prisma, email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} diff --git a/apps/server-nestjs/src/modules/user/user-testing.utils.ts b/apps/server-nestjs/src/modules/user/user-testing.utils.ts new file mode 100644 index 0000000000..cc44dcf34e --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user-testing.utils.ts @@ -0,0 +1,29 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): 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 { + 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 +} diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts new file mode 100644 index 0000000000..6b3ca5ba60 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -0,0 +1,42 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { userContract } 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' + +type AllUsersResponse = ClientInferResponseBody +type MatchingUsersResponse = ClientInferResponseBody +type PatchUsersResponse = ClientInferResponseBody + +@Controller('api/v1/users') +@UseGuards(UserGuard) +export class UserController { + constructor(@Inject(UserService) private readonly userService: UserService) {} + + @Get() + @RequireAdminPermission('ManageUsers') + async getAllUsers( + @Query(new ZodValidationPipe(userContract.getAllUsers.query)) query: typeof userContract.getAllUsers.query._type, + ): Promise { + const relationType = query.relationType ?? 'AND' + const { relationType: _, ...listQuery } = query + return this.userService.getAllUsers(listQuery, relationType) + } + + @Get('matching') + async getMatchingUsers( + @Query(new ZodValidationPipe(userContract.getMatchingUsers.query)) query: typeof userContract.getMatchingUsers.query._type, + ): Promise { + return this.userService.getMatchingUsers(query) + } + + @Patch() + @RequireAdminPermission('ManageUsers') + async patchUsers( + @Body(new ZodValidationPipe(userContract.patchUsers.body)) users: typeof userContract.patchUsers.body._type, + ): Promise { + return this.userService.patchUsers(users) + } +} diff --git a/apps/server-nestjs/src/modules/user/user.module.ts b/apps/server-nestjs/src/modules/user/user.module.ts new file mode 100644 index 0000000000..0be6ab177f --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { UserController } from './user.controller' +import { UserService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts new file mode 100644 index 0000000000..7bb73f7af4 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -0,0 +1,96 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +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 + let events: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + events = mockDeep() + + 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({}, 'AND') + + expect(result).toEqual(users.map(user => ({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + adminRoleIds: user.adminRoleIds, + type: user.type, + lastLogin: user.lastLogin?.toISOString() ?? null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }))) + 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'] }, 'AND') + + expect(prisma.adminRole.findMany).toHaveBeenCalled() + expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { AND: expect.any(Array) }, + })) + }) + + it('throws when an admin role name is not found', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + + await expect( + service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + ).rejects.toThrow('Unable to find adminRole ghost-role') + }) + + it('returns matching users by letters', 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({ + where: { AND: expect.arrayContaining([expect.objectContaining({ type: 'human' })]) }, + })) + }) + + it('patches users and emits adminRole upsert events', async () => { + const users = [makeUser()] + prisma.user.update.mockResolvedValue(users[0]) + prisma.user.findMany.mockResolvedValue(users) + + const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: users[0].id }, + data: { adminRoleIds: ['role-1'] }, + }) + expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-1' }) + expect(result).toHaveLength(1) + }) +}) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts new file mode 100644 index 0000000000..b8a2a461c5 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -0,0 +1,128 @@ +import type { userContract } from '@cpn-console/shared' +import type { Prisma, User } from '@prisma/client' +import type { ClientInferResponseBody } from '@ts-rest/core' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { + createUser as createUserQuery, + getAdminRolesByName, + getMatchingUsers as getMatchingUsersQuery, + getUsers, + updateUserAdminRoleIds, +} from './user-queries.utils' + +type AllUsersResponse = ClientInferResponseBody +type MatchingUsersResponse = ClientInferResponseBody +type PatchUsersResponse = ClientInferResponseBody + +function toContractUser(user: User): AllUsersResponse[number] { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + adminRoleIds: user.adminRoleIds, + type: user.type, + lastLogin: user.lastLogin ? user.lastLogin.toISOString() : null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + } +} + +@Injectable() +export class UserService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + ) {} + + async getAllUsers( + query: typeof userContract.getAllUsers.query._type, + relationType: 'OR' | 'AND' = 'AND', + ): Promise { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await getAdminRolesByName(this.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 (await getUsers(this.prisma, { [relationType]: whereInputs })).map(toContractUser) + } + + async getMatchingUsers( + query: typeof userContract.getMatchingUsers.query._type, + ): Promise { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return (await getMatchingUsersQuery(this.prisma, { AND })).map(toContractUser) + } + + async createUser( + data: Omit, + ): Promise { + return createUserQuery(this.prisma, data) + } + + async patchUsers( + users: { id: string, adminRoleIds: string[] | null }[], + ): Promise { + const usersBefore = await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) + + for (const user of users) { + if (user.adminRoleIds) { + await updateUserAdminRoleIds(this.prisma, user.id, user.adminRoleIds) + } + } + + // Mirror legacy: hook.adminRole.upsert per impacted user, over the union of + // before/after adminRoleIds so revocations are synced too + const impactedRoleIds = new Set() + for (const user of users) { + usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) + user.adminRoleIds?.forEach(roleId => impactedRoleIds.add(roleId)) + } + for (const roleId of impactedRoleIds) { + await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) + } + + return (await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } })).map(toContractUser) + } +} From 8c0601aaeb6d2af4667b6063c695e026e5980e35 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 18 Sep 2026 11:22:10 +0200 Subject: [PATCH 02/24] chore(user): drop section divider comments Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index cda8c14c64..bc32cbfe6c 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -3,7 +3,6 @@ import type { PrismaService } from '../infrastructure/database/prisma.service' type UserCreate = Omit -// ── selects ─────────────────────────────────────────────────────────────────────────── export const userSelect = { id: true, firstName: true, @@ -17,7 +16,6 @@ export const userSelect = { } satisfies Prisma.UserSelect export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> -// ── queries ─────────────────────────────────────────────────────────────────────────── export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { return prisma.user.findMany({ where }) } @@ -44,7 +42,6 @@ export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], ad }) } -// ── create ───────────────────────────────────────────────────────────────────────────── export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { const user = await getUserByEmail(prisma, email) if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') From 75db7664458d05c1b38485d52bf91d4086f3552c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 16:20:22 +0200 Subject: [PATCH 03/24] refactor(user): realigner structure et style du module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligne le module user sur le pattern établi par le module zone (#2487) : - `user-queries.utils.ts` : suppression du `userSelect`/`UserRecord` morts, signatures sur `Prisma.TransactionClient`, `createUser` purement DB (la validation du doublon email monte dans le service en `ConflictException`). - `user.service.ts` : extraction de `resolveAdminRoleIds`, `patchUsersInTx`, `emitImpactedRoleEvents` ; patch des rôles sous transaction. - `user.module.ts` : imports repliés sur `InfrastructureModule` (déjà exporté par celui-ci). Refs #2738 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I3bd632f95dd466e585698eba738efcd86a6a6964 --- .../src/modules/user/user-queries.utils.ts | 101 ++++++++++---- .../src/modules/user/user.controller.ts | 30 ++--- .../src/modules/user/user.module.ts | 12 +- .../src/modules/user/user.service.spec.ts | 18 +-- .../src/modules/user/user.service.ts | 124 +++++------------- .../src/modules/user/user.utils.ts | 16 +++ packages/shared/src/contracts/user.ts | 40 +++--- 7 files changed, 167 insertions(+), 174 deletions(-) create mode 100644 apps/server-nestjs/src/modules/user/user.utils.ts diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index bc32cbfe6c..8c9106697c 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -1,49 +1,92 @@ +import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { Prisma, User } from '@prisma/client' import type { PrismaService } from '../infrastructure/database/prisma.service' +import { BadRequestException } from '@nestjs/common' -type UserCreate = Omit - -export const userSelect = { - id: true, - firstName: true, - lastName: true, - email: true, - createdAt: true, - updatedAt: true, - lastLogin: true, - adminRoleIds: true, - type: true, -} satisfies Prisma.UserSelect -export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> - -export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { - return prisma.user.findMany({ where }) +export function getUsers(tx: Prisma.TransactionClient, where?: Prisma.UserWhereInput) { + return tx.user.findMany({ where }) } -export function getMatchingUsers(prisma: PrismaService, where: Prisma.UserWhereInput) { - return prisma.user.findMany({ +export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) { + return tx.user.findMany({ where, take: 5, }) } -export function getUserByEmail(prisma: PrismaService, email: User['email']) { - return prisma.user.findUnique({ where: { email } }) +export function getUserByEmail(tx: Prisma.TransactionClient, email: User['email']) { + return tx.user.findUnique({ where: { email } }) } -export function getAdminRolesByName(prisma: PrismaService, names: string[]) { - return prisma.adminRole.findMany({ where: { name: { in: names } } }) +export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { + return tx.adminRole.findMany({ where: { name: { in: names } } }) } -export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], adminRoleIds: string[]) { - return prisma.user.update({ +export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) { + return tx.user.update({ where: { id }, data: { adminRoleIds }, }) } -export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { - const user = await getUserByEmail(prisma, email) - if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') - return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +export function createUser(tx: Prisma.TransactionClient, data: Pick) { + return tx.user.create({ data }) +} + +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, + query: AllUsersQuery, + relationType: 'OR' | 'AND', +): Promise { + 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 } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + return { AND } } diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 6b3ca5ba60..00bc84892b 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -1,14 +1,11 @@ -import type { ClientInferResponseBody } from '@ts-rest/core' -import { userContract } from '@cpn-console/shared' +import type { AllUsers, AllUsersQuery, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' +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' - -type AllUsersResponse = ClientInferResponseBody -type MatchingUsersResponse = ClientInferResponseBody -type PatchUsersResponse = ClientInferResponseBody +import { toContractUser } from './user.utils' @Controller('api/v1/users') @UseGuards(UserGuard) @@ -18,25 +15,28 @@ export class UserController { @Get() @RequireAdminPermission('ManageUsers') async getAllUsers( - @Query(new ZodValidationPipe(userContract.getAllUsers.query)) query: typeof userContract.getAllUsers.query._type, - ): Promise { + @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: AllUsersQuery, + ): Promise { const relationType = query.relationType ?? 'AND' const { relationType: _, ...listQuery } = query - return this.userService.getAllUsers(listQuery, relationType) + const users = await this.userService.getAllUsers(listQuery, relationType) + return users.map(toContractUser) } @Get('matching') async getMatchingUsers( - @Query(new ZodValidationPipe(userContract.getMatchingUsers.query)) query: typeof userContract.getMatchingUsers.query._type, - ): Promise { - return this.userService.getMatchingUsers(query) + @Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery, + ): Promise { + const users = await this.userService.getMatchingUsers(query) + return users.map(toContractUser) } @Patch() @RequireAdminPermission('ManageUsers') async patchUsers( - @Body(new ZodValidationPipe(userContract.patchUsers.body)) users: typeof userContract.patchUsers.body._type, - ): Promise { - return this.userService.patchUsers(users) + @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, + ): Promise { + const updatedUsers = await this.userService.patchUsers(users) + return updatedUsers.map(toContractUser) } } diff --git a/apps/server-nestjs/src/modules/user/user.module.ts b/apps/server-nestjs/src/modules/user/user.module.ts index 0be6ab177f..61320b6d33 100644 --- a/apps/server-nestjs/src/modules/user/user.module.ts +++ b/apps/server-nestjs/src/modules/user/user.module.ts @@ -1,18 +1,10 @@ import { Module } from '@nestjs/common' -import { AuthModule } from '../infrastructure/auth/auth.module' -import { DatabaseModule } from '../infrastructure/database/database.module' -import { EventsModule } from '../infrastructure/events/events.module' -import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' import { UserController } from './user.controller' import { UserService } from './user.service' @Module({ - imports: [ - AuthModule, - DatabaseModule, - EventsModule, - UserPermissionModule, - ], + imports: [InfrastructureModule], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 7bb73f7af4..7875250169 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' @@ -33,17 +34,7 @@ describe('userService', () => { const result = await service.getAllUsers({}, 'AND') - expect(result).toEqual(users.map(user => ({ - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - adminRoleIds: user.adminRoleIds, - type: user.type, - lastLogin: user.lastLogin?.toISOString() ?? null, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - }))) + expect(result).toEqual(users) expect(prisma.user.findMany).toHaveBeenCalled() }) @@ -83,10 +74,13 @@ describe('userService', () => { const users = [makeUser()] prisma.user.update.mockResolvedValue(users[0]) prisma.user.findMany.mockResolvedValue(users) + const tx = mockDeep() + tx.user.update.mockResolvedValue(users[0]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) - expect(prisma.user.update).toHaveBeenCalledWith({ + expect(tx.user.update).toHaveBeenCalledWith({ where: { id: users[0].id }, data: { adminRoleIds: ['role-1'] }, }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index b8a2a461c5..4b2addbe81 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,35 +1,18 @@ -import type { userContract } from '@cpn-console/shared' -import type { Prisma, User } from '@prisma/client' -import type { ClientInferResponseBody } from '@ts-rest/core' -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +import type { User } from '@prisma/client' +import { ConflictException, Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' import { - createUser as createUserQuery, - getAdminRolesByName, - getMatchingUsers as getMatchingUsersQuery, + buildAllUsersWhere, + buildMatchingUsersWhere, + createUser, + getMatchingUsers, + getUserByEmail, getUsers, - updateUserAdminRoleIds, + patchUsers, } from './user-queries.utils' -type AllUsersResponse = ClientInferResponseBody -type MatchingUsersResponse = ClientInferResponseBody -type PatchUsersResponse = ClientInferResponseBody - -function toContractUser(user: User): AllUsersResponse[number] { - return { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - adminRoleIds: user.adminRoleIds, - type: user.type, - lastLogin: user.lastLogin ? user.lastLogin.toISOString() : null, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - } -} - @Injectable() export class UserService { constructor( @@ -38,82 +21,39 @@ export class UserService { ) {} async getAllUsers( - query: typeof userContract.getAllUsers.query._type, + query: AllUsersQuery, relationType: 'OR' | 'AND' = 'AND', - ): Promise { - const whereInputs: Prisma.UserWhereInput[] = [] - if (query.adminRoleIds?.length) { - whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) - } - if (query.adminRoles?.length) { - const roles = query.adminRoles - ? await getAdminRolesByName(this.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 (await getUsers(this.prisma, { [relationType]: whereInputs })).map(toContractUser) + ): Promise { + const where = await buildAllUsersWhere(this.prisma, query, relationType) + return getUsers(this.prisma, where) } async getMatchingUsers( - query: typeof userContract.getMatchingUsers.query._type, - ): Promise { - const AND: Prisma.UserWhereInput[] = [] - if (query.notInProjectId) { - AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) - AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) - } - const filter = { contains: query.letters, mode: 'insensitive' } as const - if (query.letters) { - AND.push({ - OR: [{ - email: filter, - }, { - firstName: filter, - }, { - lastName: filter, - }], - }) - AND.push({ type: 'human' }) - } - - return (await getMatchingUsersQuery(this.prisma, { AND })).map(toContractUser) + query: LettersQuery, + ): Promise { + const where = buildMatchingUsersWhere(query) + return getMatchingUsers(this.prisma, where) } - async createUser( - data: Omit, - ): Promise { - return createUserQuery(this.prisma, data) + async createUser(data: Omit): Promise { + const existing = await getUserByEmail(this.prisma, data.email) + if (existing) throw new ConflictException('Un utilisateur avec cette adresse e-mail existe déjà') + return this.prisma.$transaction(tx => createUser(tx, data)) } async patchUsers( - users: { id: string, adminRoleIds: string[] | null }[], - ): Promise { + users: PatchUsersBody, + ): Promise { const usersBefore = await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) + await this.prisma.$transaction(tx => patchUsers(tx, users)) + await this.emitImpactedRoleEvents(users, usersBefore) + return getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) + } - for (const user of users) { - if (user.adminRoleIds) { - await updateUserAdminRoleIds(this.prisma, user.id, user.adminRoleIds) - } - } - - // Mirror legacy: hook.adminRole.upsert per impacted user, over the union of - // before/after adminRoleIds so revocations are synced too + private async emitImpactedRoleEvents( + users: PatchUsersBody, + usersBefore: User[], + ): Promise { const impactedRoleIds = new Set() for (const user of users) { usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) @@ -122,7 +62,5 @@ export class UserService { for (const roleId of impactedRoleIds) { await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) } - - return (await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } })).map(toContractUser) } } diff --git a/apps/server-nestjs/src/modules/user/user.utils.ts b/apps/server-nestjs/src/modules/user/user.utils.ts new file mode 100644 index 0000000000..b90403b6b5 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.utils.ts @@ -0,0 +1,16 @@ +import type { AllUsers } from '@cpn-console/shared' +import type { User } from '@prisma/client' + +export function toContractUser(user: User): AllUsers[number] { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + adminRoleIds: user.adminRoleIds, + type: user.type, + lastLogin: user.lastLogin ? user.lastLogin.toISOString() : null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + } +} diff --git a/packages/shared/src/contracts/user.ts b/packages/shared/src/contracts/user.ts index 508ef91171..9d4e9f609b 100644 --- a/packages/shared/src/contracts/user.ts +++ b/packages/shared/src/contracts/user.ts @@ -8,14 +8,29 @@ import { } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' +export const MatchingUsersQuerySchema = z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), +}) + +export const AllUsersQuerySchema = z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), +}) + +export const PatchUsersBodySchema = UserSchema.pick({ adminRoleIds: true, id: true }).array() + export const userContract = contractInstance.router({ getMatchingUsers: { method: 'GET', path: `${apiPrefix}/users/matching`, - query: z.object({ - letters: z.string(), - notInProjectId: z.string().uuid().optional(), - }), + query: MatchingUsersQuerySchema, summary: 'Get users by letters matching', description: 'Retrieved users by letters matching.', responses: { @@ -43,16 +58,7 @@ export const userContract = contractInstance.router({ path: `${apiPrefix}/users`, summary: 'Get all users', description: 'Get all users.', - query: z.object({ - adminRoles: RoleNameCsvSchema - .optional(), - adminRoleIds: UuidOrCsvUuidSchema - .optional(), - memberOfIds: UuidOrCsvUuidSchema - .optional(), - relationType: z.enum(['OR', 'AND']) - .optional(), - }), + query: AllUsersQuerySchema, responses: { 200: UserSchema.array(), 400: ErrorSchema, @@ -65,7 +71,7 @@ export const userContract = contractInstance.router({ method: 'PATCH', path: `${apiPrefix}/users`, summary: 'Patch users', - body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + body: PatchUsersBodySchema, description: 'Update user admin role.', responses: { 200: UserSchema.array(), @@ -79,5 +85,9 @@ export const userContract = contractInstance.router({ }) export type LettersQuery = ClientInferRequest['query'] +export type AllUsersQuery = z.infer +export type PatchUsersBody = z.infer export type AllUsers = ClientInferResponseBody +export type MatchingUsers = ClientInferResponseBody +export type PatchUsers = ClientInferResponseBody From 777b37698d8e0cf92bc511bad74cc8154b62017e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 18:08:56 +0200 Subject: [PATCH 04/24] =?UTF-8?q?refactor(user):=20retirer=20l'alias=20All?= =?UTF-8?q?UsersQuery=20au=20profit=20du=20sch=C3=A9ma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AllUsersQuerySchema` étant exporté, l'alias `AllUsersQuery` est redondant ; les consommateurs dérivent le type par `z.infer`. `LettersQuery` est conservé (utilisé par le client). Refs #1889 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I5d954715680b8fbcd866edf4bb300dc36a6a6964 --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 5 +++-- apps/server-nestjs/src/modules/user/user.controller.ts | 5 +++-- apps/server-nestjs/src/modules/user/user.service.ts | 5 +++-- packages/shared/src/contracts/user.ts | 1 - 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 8c9106697c..1af3934144 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -1,5 +1,6 @@ -import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +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' @@ -41,7 +42,7 @@ export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) export async function buildAllUsersWhere( prisma: PrismaService, - query: AllUsersQuery, + query: z.infer, relationType: 'OR' | 'AND', ): Promise { const whereInputs: Prisma.UserWhereInput[] = [] diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 00bc84892b..6a5f6ed891 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -1,4 +1,5 @@ -import type { AllUsers, AllUsersQuery, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' +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' @@ -15,7 +16,7 @@ export class UserController { @Get() @RequireAdminPermission('ManageUsers') async getAllUsers( - @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: AllUsersQuery, + @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, ): Promise { const relationType = query.relationType ?? 'AND' const { relationType: _, ...listQuery } = query diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index 4b2addbe81..abf89d6694 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,5 +1,6 @@ -import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { User } from '@prisma/client' +import type { z } from 'zod' import { ConflictException, Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -21,7 +22,7 @@ export class UserService { ) {} async getAllUsers( - query: AllUsersQuery, + query: z.infer, relationType: 'OR' | 'AND' = 'AND', ): Promise { const where = await buildAllUsersWhere(this.prisma, query, relationType) diff --git a/packages/shared/src/contracts/user.ts b/packages/shared/src/contracts/user.ts index 9d4e9f609b..33d98b7f87 100644 --- a/packages/shared/src/contracts/user.ts +++ b/packages/shared/src/contracts/user.ts @@ -85,7 +85,6 @@ export const userContract = contractInstance.router({ }) export type LettersQuery = ClientInferRequest['query'] -export type AllUsersQuery = z.infer export type PatchUsersBody = z.infer export type AllUsers = ClientInferResponseBody From 105596c0fe818c3def94b436004841d239686d90 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 10:43:03 +0200 Subject: [PATCH 05/24] test(user): lock controller permission matrix Co-authored-by: Automata Signed-off-by: William Phetsinorath --- .../src/modules/user/user.controller.spec.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/server-nestjs/src/modules/user/user.controller.spec.ts diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts new file mode 100644 index 0000000000..5c2755f138 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -0,0 +1,72 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' +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 + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [UserController], + providers: [ + { provide: UserService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(UserController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('guards list and patch with ManageUsers, leaves matching open to logged-in users', () => { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getAllUsers)).toEqual(['ManageUsers']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.patchUsers)).toEqual(['ManageUsers']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getMatchingUsers)).toBeUndefined() + }) + + 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({ adminRoleIds: ['r1'], relationType: 'OR' } as never) + + expect(result).toEqual(users.map(toContractUser)) + expect(service.getAllUsers).toHaveBeenCalledWith({ adminRoleIds: ['r1'] }, '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 as never)).toEqual(users.map(toContractUser)) + expect(service.patchUsers).toHaveBeenCalledWith(body) + }) +}) From c80ddfcc1cd7526348b40cd0dc906bea31ed2838 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 17:35:32 +0200 Subject: [PATCH 06/24] fix(user): purge dead create path and lock legacy parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - supprimer UserService.createUser et les helpers createUser/getUserByEmail (aucun appelant ni route au contrat : chemin mort signalé en revue) - supprimer l'import faker inutilisé de user.controller.spec.ts - verrouiller par spec : 400 (BadRequestException) sur adminRole inconnue, take:5 sur la recherche par lettres, émission union avant ∪ après Signed-off-by: William Phetsinorath Co-authored-by: Automata --- .../src/modules/user/user-queries.utils.ts | 8 ------- .../src/modules/user/user.controller.spec.ts | 1 - .../src/modules/user/user.service.spec.ts | 24 ++++++++++++------- .../src/modules/user/user.service.ts | 10 +------- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 1af3934144..630143ea56 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -15,10 +15,6 @@ export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.Use }) } -export function getUserByEmail(tx: Prisma.TransactionClient, email: User['email']) { - return tx.user.findUnique({ where: { email } }) -} - export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { return tx.adminRole.findMany({ where: { name: { in: names } } }) } @@ -30,10 +26,6 @@ export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['i }) } -export function createUser(tx: Prisma.TransactionClient, data: Pick) { - return tx.user.create({ data }) -} - export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) { return Promise.all(users .filter((user): user is typeof user & { adminRoleIds: string[] } => user.adminRoleIds !== null) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 5c2755f138..bf4dfdc174 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -1,6 +1,5 @@ import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' -import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 7875250169..4575b7ecc9 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -1,5 +1,6 @@ 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' @@ -50,15 +51,19 @@ describe('userService', () => { })) }) - it('throws when an admin role name is not found', async () => { + it('rejects an unknown admin role name with the legacy 400', async () => { prisma.adminRole.findMany.mockResolvedValue([]) + await expect( + service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + ).rejects.toThrow(BadRequestException) + await expect( service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), ).rejects.toThrow('Unable to find adminRole ghost-role') }) - it('returns matching users by letters', async () => { + it('returns matching users by letters, capped at 5 like the legacy query', async () => { const users = [makeUser()] prisma.user.findMany.mockResolvedValue(users) @@ -66,24 +71,25 @@ describe('userService', () => { 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 adminRole upsert events', async () => { - const users = [makeUser()] - prisma.user.update.mockResolvedValue(users[0]) - prisma.user.findMany.mockResolvedValue(users) + it('patches users and emits the union of before and after admin roles', async () => { + const user = makeUser({ adminRoleIds: ['role-0'] }) + prisma.user.findMany.mockResolvedValue([user]) const tx = mockDeep() - tx.user.update.mockResolvedValue(users[0]) + tx.user.update.mockResolvedValue(user) prisma.$transaction.mockImplementation(async cb => cb(tx)) - const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) + const result = await service.patchUsers([{ id: user.id, adminRoleIds: ['role-1'] }]) expect(tx.user.update).toHaveBeenCalledWith({ - where: { id: users[0].id }, + where: { id: user.id }, data: { adminRoleIds: ['role-1'] }, }) + expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-0' }) expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-1' }) expect(result).toHaveLength(1) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index abf89d6694..479319f314 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,15 +1,13 @@ import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { User } from '@prisma/client' import type { z } from 'zod' -import { ConflictException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' import { buildAllUsersWhere, buildMatchingUsersWhere, - createUser, getMatchingUsers, - getUserByEmail, getUsers, patchUsers, } from './user-queries.utils' @@ -36,12 +34,6 @@ export class UserService { return getMatchingUsers(this.prisma, where) } - async createUser(data: Omit): Promise { - const existing = await getUserByEmail(this.prisma, data.email) - if (existing) throw new ConflictException('Un utilisateur avec cette adresse e-mail existe déjà') - return this.prisma.$transaction(tx => createUser(tx, data)) - } - async patchUsers( users: PatchUsersBody, ): Promise { From 862e6d7ce5c9374e6da1fb62cd4d7c658f2d8dcc Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 28 Sep 2026 16:30:10 +0200 Subject: [PATCH 07/24] refactor(user): remove reflection-only permissions metadata check Reflect.getMetadata assertions pass whether or not the guard is enforced and add no behavioural coverage; the guard logic stays covered in user.guard.spec.ts and the controller delegation tests are untouched. Co-authored-by: Automata Signed-off-by: William Phetsinorath --- .../server-nestjs/src/modules/user/user.controller.spec.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index bf4dfdc174..534ac80510 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -3,7 +3,6 @@ import type { MockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' -import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeUser } from './user-testing.utils' import { UserController } from './user.controller' @@ -35,12 +34,6 @@ describe('userController', () => { expect(controller).toBeDefined() }) - it('guards list and patch with ManageUsers, leaves matching open to logged-in users', () => { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getAllUsers)).toEqual(['ManageUsers']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.patchUsers)).toEqual(['ManageUsers']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getMatchingUsers)).toBeUndefined() - }) - it('delegates list with relationType defaulted to AND and stripped from the query', async () => { const users = [makeUser()] service.getAllUsers.mockResolvedValue(users) From 2936564644ef2cf7f7e151fab710135d20d4deea Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 30 Sep 2026 11:46:04 +0200 Subject: [PATCH 08/24] fix(user): emit canonical AdminRoleEventPayload and type controller inputs Emit the full { id, oidcGroup, members } payload that the 2749 event bridge consumers expect, drop as never from the controller spec by parsing inputs through the shared schemas, and move the relationType default into buildAllUsersWhere so the controller passes the query straight through. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I28676aa618050a7042ed24dfa9e66b896a6a6964 --- .../src/modules/user/user-queries.utils.ts | 4 +-- .../src/modules/user/user.controller.spec.ts | 5 ++-- .../src/modules/user/user.controller.ts | 4 +-- .../src/modules/user/user.service.spec.ts | 30 ++++++++++++++----- .../src/modules/user/user.service.ts | 25 +++++++++++----- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 630143ea56..ee7722573b 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -34,9 +34,9 @@ export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) export async function buildAllUsersWhere( prisma: PrismaService, - query: z.infer, - relationType: 'OR' | 'AND', + { relationType: relation, ...query }: z.infer, ): Promise { + const relationType = relation ?? 'AND' const whereInputs: Prisma.UserWhereInput[] = [] if (query.adminRoleIds?.length) { whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 534ac80510..6a2a96c826 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -1,5 +1,6 @@ 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' @@ -38,10 +39,10 @@ describe('userController', () => { const users = [makeUser()] service.getAllUsers.mockResolvedValue(users) - const result = await controller.getAllUsers({ adminRoleIds: ['r1'], relationType: 'OR' } as never) + 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({ adminRoleIds: ['r1'] }, 'OR') + expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) }) it('delegates matching query to the service', async () => { diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 6a5f6ed891..7abc362b07 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -18,9 +18,7 @@ export class UserController { async getAllUsers( @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, ): Promise { - const relationType = query.relationType ?? 'AND' - const { relationType: _, ...listQuery } = query - const users = await this.userService.getAllUsers(listQuery, relationType) + const users = await this.userService.getAllUsers(query) return users.map(toContractUser) } diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 4575b7ecc9..cbc1cbc83f 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -33,7 +33,7 @@ describe('userService', () => { const users = [makeUser(), makeUser()] prisma.user.findMany.mockResolvedValue(users) - const result = await service.getAllUsers({}, 'AND') + const result = await service.getAllUsers({}) expect(result).toEqual(users) expect(prisma.user.findMany).toHaveBeenCalled() @@ -43,7 +43,7 @@ describe('userService', () => { prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })]) prisma.user.findMany.mockResolvedValue([makeUser()]) - await service.getAllUsers({ adminRoles: ['admin'] }, 'AND') + await service.getAllUsers({ adminRoles: ['admin'] }) expect(prisma.adminRole.findMany).toHaveBeenCalled() expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ @@ -55,11 +55,11 @@ describe('userService', () => { prisma.adminRole.findMany.mockResolvedValue([]) await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + service.getAllUsers({ adminRoles: ['ghost-role'] }), ).rejects.toThrow(BadRequestException) await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + service.getAllUsers({ adminRoles: ['ghost-role'] }), ).rejects.toThrow('Unable to find adminRole ghost-role') }) @@ -78,7 +78,15 @@ describe('userService', () => { it('patches users and emits the union of before and after admin roles', async () => { const user = makeUser({ adminRoleIds: ['role-0'] }) - prisma.user.findMany.mockResolvedValue([user]) + prisma.user.findMany.mockImplementation(async (_args) => { + // before-load returns the user with role-0; the after-load must see role-1, + // but the members query only needs the selected member fields. + 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() tx.user.update.mockResolvedValue(user) prisma.$transaction.mockImplementation(async cb => cb(tx)) @@ -89,8 +97,16 @@ describe('userService', () => { where: { id: user.id }, data: { adminRoleIds: ['role-1'] }, }) - expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-0' }) - expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: '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) }) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index 479319f314..cb47482703 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -19,11 +19,8 @@ export class UserService { @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, ) {} - async getAllUsers( - query: z.infer, - relationType: 'OR' | 'AND' = 'AND', - ): Promise { - const where = await buildAllUsersWhere(this.prisma, query, relationType) + async getAllUsers(query: z.infer): Promise { + const where = await buildAllUsersWhere(this.prisma, query) return getUsers(this.prisma, where) } @@ -52,8 +49,22 @@ export class UserService { usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) user.adminRoleIds?.forEach(roleId => impactedRoleIds.add(roleId)) } - for (const roleId of impactedRoleIds) { - await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) + const roles = await this.prisma.adminRole.findMany({ + where: { id: { in: [...impactedRoleIds] } }, + select: { id: true, oidcGroup: true }, + }) + const members = await this.prisma.user.findMany({ + where: { adminRoleIds: { hasSome: [...impactedRoleIds] } }, + select: { id: true, email: true, firstName: true, lastName: true, adminRoleIds: true }, + }) + for (const role of roles) { + await this.eventEmitter.emitAsync('adminRole.upsert', { + id: role.id, + oidcGroup: role.oidcGroup, + members: members + .filter(({ adminRoleIds }) => adminRoleIds.includes(role.id)) + .map(({ id, email, firstName, lastName }) => ({ id, email, firstName, lastName })), + }) } } } From e210617b7420dd2a2d1f9a2285da153b9be14976 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 30 Sep 2026 12:35:38 +0200 Subject: [PATCH 09/24] style(user): drop mock-wiring narration comment Signed-off-by: William Phetsinorath Change-Id: Ieb2ee298f5d0547bf988787721ed029a6a6a6964 Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user.controller.spec.ts | 2 +- apps/server-nestjs/src/modules/user/user.service.spec.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 6a2a96c826..fe853a3229 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -59,7 +59,7 @@ describe('userController', () => { service.patchUsers.mockResolvedValue(users) const body = [{ id: users[0].id, adminRoleIds: ['r1'] }] - expect(await controller.patchUsers(body as never)).toEqual(users.map(toContractUser)) + expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser)) expect(service.patchUsers).toHaveBeenCalledWith(body) }) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index cbc1cbc83f..d592bc0b90 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -79,8 +79,6 @@ describe('userService', () => { 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) => { - // before-load returns the user with role-0; the after-load must see role-1, - // but the members query only needs the selected member fields. 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([ From e305dc042b48a0ed8c0a52932dfc8492b18fd585 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 10:14:55 +0200 Subject: [PATCH 10/24] refactor(user): batch AND filters into single pushes Co-authored-by: Automata --- .../src/modules/user/user-queries.utils.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index ee7722573b..b20aab9b10 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -65,21 +65,25 @@ export async function buildAllUsersWhere( 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 } } }) + AND.push( + { projectMembers: { none: { projectId: query.notInProjectId } } }, + { projectsOwned: { none: { id: query.notInProjectId } } }, + ) } const filter = { contains: query.letters, mode: 'insensitive' } as const if (query.letters) { - AND.push({ - OR: [{ - email: filter, - }, { - firstName: filter, - }, { - lastName: filter, - }], - }) - AND.push({ type: 'human' }) + AND.push( + { + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }, + { type: 'human' }, + ) } return { AND } } From 63345cbfa252d626a5c37c111a3a25cc4e19961e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 12:03:29 +0200 Subject: [PATCH 11/24] fix(user): use the projectMembers relation key in memberOfIds filter Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index b20aab9b10..330b8945de 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -54,7 +54,7 @@ export async function buildAllUsersWhere( AND: query.memberOfIds.map(id => ({ OR: [ { projectsOwned: { some: { id } } }, - { ProjectMembers: { some: { project: { id } } } }, + { projectMembers: { some: { project: { id } } } }, ], })), }) From a2cf7f450cabe55c41cb009f7c8d6e4516183b94 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 13 Aug 2026 16:27:09 +0200 Subject: [PATCH 12/24] fix(user): legacy 400 on unknown admin role, matching take:5, hook union semantics Co-authored-by: Automata --- .../src/modules/user/user-queries.utils.ts | 105 +++++-------- .../src/modules/user/user.controller.ts | 33 +++-- .../src/modules/user/user.module.ts | 12 +- .../src/modules/user/user.service.spec.ts | 68 ++++----- .../src/modules/user/user.service.ts | 140 +++++++++++++----- 5 files changed, 187 insertions(+), 171 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 330b8945de..cda8c14c64 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -1,89 +1,52 @@ -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 }) +type UserCreate = Omit + +// ── selects ─────────────────────────────────────────────────────────────────────────── +export const userSelect = { + id: true, + firstName: true, + lastName: true, + email: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + adminRoleIds: true, + type: true, +} satisfies Prisma.UserSelect +export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> + +// ── queries ─────────────────────────────────────────────────────────────────────────── +export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { + return prisma.user.findMany({ where }) } -export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) { - return tx.user.findMany({ +export function getMatchingUsers(prisma: PrismaService, where: Prisma.UserWhereInput) { + return prisma.user.findMany({ where, take: 5, }) } -export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { - return tx.adminRole.findMany({ where: { name: { in: names } } }) +export function getUserByEmail(prisma: PrismaService, email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +export function getAdminRolesByName(prisma: PrismaService, names: string[]) { + return prisma.adminRole.findMany({ where: { name: { in: names } } }) } -export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) { - return tx.user.update({ +export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], adminRoleIds: string[]) { + return prisma.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, -): Promise { - 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 } } }, - { projectsOwned: { none: { id: query.notInProjectId } } }, - ) - } - const filter = { contains: query.letters, mode: 'insensitive' } as const - if (query.letters) { - AND.push( - { - OR: [{ - email: filter, - }, { - firstName: filter, - }, { - lastName: filter, - }], - }, - { type: 'human' }, - ) - } - return { AND } +// ── create ───────────────────────────────────────────────────────────────────────────── +export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(prisma, email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) } diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 7abc362b07..6b3ca5ba60 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -1,12 +1,14 @@ -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 type { ClientInferResponseBody } from '@ts-rest/core' +import { userContract } 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' + +type AllUsersResponse = ClientInferResponseBody +type MatchingUsersResponse = ClientInferResponseBody +type PatchUsersResponse = ClientInferResponseBody @Controller('api/v1/users') @UseGuards(UserGuard) @@ -16,26 +18,25 @@ export class UserController { @Get() @RequireAdminPermission('ManageUsers') async getAllUsers( - @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, - ): Promise { - const users = await this.userService.getAllUsers(query) - return users.map(toContractUser) + @Query(new ZodValidationPipe(userContract.getAllUsers.query)) query: typeof userContract.getAllUsers.query._type, + ): Promise { + const relationType = query.relationType ?? 'AND' + const { relationType: _, ...listQuery } = query + return this.userService.getAllUsers(listQuery, relationType) } @Get('matching') async getMatchingUsers( - @Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery, - ): Promise { - const users = await this.userService.getMatchingUsers(query) - return users.map(toContractUser) + @Query(new ZodValidationPipe(userContract.getMatchingUsers.query)) query: typeof userContract.getMatchingUsers.query._type, + ): Promise { + return this.userService.getMatchingUsers(query) } @Patch() @RequireAdminPermission('ManageUsers') async patchUsers( - @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, - ): Promise { - const updatedUsers = await this.userService.patchUsers(users) - return updatedUsers.map(toContractUser) + @Body(new ZodValidationPipe(userContract.patchUsers.body)) users: typeof userContract.patchUsers.body._type, + ): Promise { + return this.userService.patchUsers(users) } } diff --git a/apps/server-nestjs/src/modules/user/user.module.ts b/apps/server-nestjs/src/modules/user/user.module.ts index 61320b6d33..0be6ab177f 100644 --- a/apps/server-nestjs/src/modules/user/user.module.ts +++ b/apps/server-nestjs/src/modules/user/user.module.ts @@ -1,10 +1,18 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { UserController } from './user.controller' import { UserService } from './user.service' @Module({ - imports: [InfrastructureModule], + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index d592bc0b90..7bb73f7af4 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -1,6 +1,4 @@ -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' @@ -33,9 +31,19 @@ describe('userService', () => { const users = [makeUser(), makeUser()] prisma.user.findMany.mockResolvedValue(users) - const result = await service.getAllUsers({}) - - expect(result).toEqual(users) + const result = await service.getAllUsers({}, 'AND') + + expect(result).toEqual(users.map(user => ({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + adminRoleIds: user.adminRoleIds, + type: user.type, + lastLogin: user.lastLogin?.toISOString() ?? null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }))) expect(prisma.user.findMany).toHaveBeenCalled() }) @@ -43,7 +51,7 @@ describe('userService', () => { prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })]) prisma.user.findMany.mockResolvedValue([makeUser()]) - await service.getAllUsers({ adminRoles: ['admin'] }) + await service.getAllUsers({ adminRoles: ['admin'] }, 'AND') expect(prisma.adminRole.findMany).toHaveBeenCalled() expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ @@ -51,19 +59,15 @@ describe('userService', () => { })) }) - it('rejects an unknown admin role name with the legacy 400', async () => { + it('throws when an admin role name is not found', async () => { prisma.adminRole.findMany.mockResolvedValue([]) await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }), - ).rejects.toThrow(BadRequestException) - - await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }), + service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), ).rejects.toThrow('Unable to find adminRole ghost-role') }) - it('returns matching users by letters, capped at 5 like the legacy query', async () => { + it('returns matching users by letters', async () => { const users = [makeUser()] prisma.user.findMany.mockResolvedValue(users) @@ -71,40 +75,22 @@ describe('userService', () => { 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() - 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 }, + it('patches users and emits adminRole upsert events', async () => { + const users = [makeUser()] + prisma.user.update.mockResolvedValue(users[0]) + prisma.user.findMany.mockResolvedValue(users) + + const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: users[0].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(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-1' }) expect(result).toHaveLength(1) }) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index cb47482703..b8a2a461c5 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,17 +1,35 @@ -import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' -import type { User } from '@prisma/client' -import type { z } from 'zod' -import { Inject, Injectable } from '@nestjs/common' +import type { userContract } from '@cpn-console/shared' +import type { Prisma, User } from '@prisma/client' +import type { ClientInferResponseBody } from '@ts-rest/core' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' import { - buildAllUsersWhere, - buildMatchingUsersWhere, - getMatchingUsers, + createUser as createUserQuery, + getAdminRolesByName, + getMatchingUsers as getMatchingUsersQuery, getUsers, - patchUsers, + updateUserAdminRoleIds, } from './user-queries.utils' +type AllUsersResponse = ClientInferResponseBody +type MatchingUsersResponse = ClientInferResponseBody +type PatchUsersResponse = ClientInferResponseBody + +function toContractUser(user: User): AllUsersResponse[number] { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + adminRoleIds: user.adminRoleIds, + type: user.type, + lastLogin: user.lastLogin ? user.lastLogin.toISOString() : null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + } +} + @Injectable() export class UserService { constructor( @@ -19,52 +37,92 @@ export class UserService { @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, ) {} - async getAllUsers(query: z.infer): Promise { - const where = await buildAllUsersWhere(this.prisma, query) - return getUsers(this.prisma, where) + async getAllUsers( + query: typeof userContract.getAllUsers.query._type, + relationType: 'OR' | 'AND' = 'AND', + ): Promise { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await getAdminRolesByName(this.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 (await getUsers(this.prisma, { [relationType]: whereInputs })).map(toContractUser) } async getMatchingUsers( - query: LettersQuery, - ): Promise { - const where = buildMatchingUsersWhere(query) - return getMatchingUsers(this.prisma, where) + query: typeof userContract.getMatchingUsers.query._type, + ): Promise { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return (await getMatchingUsersQuery(this.prisma, { AND })).map(toContractUser) + } + + async createUser( + data: Omit, + ): Promise { + return createUserQuery(this.prisma, data) } async patchUsers( - users: PatchUsersBody, - ): Promise { + users: { id: string, adminRoleIds: string[] | null }[], + ): Promise { const usersBefore = await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) - await this.prisma.$transaction(tx => patchUsers(tx, users)) - await this.emitImpactedRoleEvents(users, usersBefore) - return getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) - } - private async emitImpactedRoleEvents( - users: PatchUsersBody, - usersBefore: User[], - ): Promise { + for (const user of users) { + if (user.adminRoleIds) { + await updateUserAdminRoleIds(this.prisma, user.id, user.adminRoleIds) + } + } + + // Mirror legacy: hook.adminRole.upsert per impacted user, over the union of + // before/after adminRoleIds so revocations are synced too const impactedRoleIds = new Set() for (const user of users) { usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) user.adminRoleIds?.forEach(roleId => impactedRoleIds.add(roleId)) } - const roles = await this.prisma.adminRole.findMany({ - where: { id: { in: [...impactedRoleIds] } }, - select: { id: true, oidcGroup: true }, - }) - const members = await this.prisma.user.findMany({ - where: { adminRoleIds: { hasSome: [...impactedRoleIds] } }, - select: { id: true, email: true, firstName: true, lastName: true, adminRoleIds: true }, - }) - for (const role of roles) { - await this.eventEmitter.emitAsync('adminRole.upsert', { - id: role.id, - oidcGroup: role.oidcGroup, - members: members - .filter(({ adminRoleIds }) => adminRoleIds.includes(role.id)) - .map(({ id, email, firstName, lastName }) => ({ id, email, firstName, lastName })), - }) + for (const roleId of impactedRoleIds) { + await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) } + + return (await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } })).map(toContractUser) } } From c00e206ecb8c6dbfcd024b77921530b5224d0789 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 18 Sep 2026 11:22:10 +0200 Subject: [PATCH 13/24] chore(user): drop section divider comments Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index cda8c14c64..bc32cbfe6c 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -3,7 +3,6 @@ import type { PrismaService } from '../infrastructure/database/prisma.service' type UserCreate = Omit -// ── selects ─────────────────────────────────────────────────────────────────────────── export const userSelect = { id: true, firstName: true, @@ -17,7 +16,6 @@ export const userSelect = { } satisfies Prisma.UserSelect export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> -// ── queries ─────────────────────────────────────────────────────────────────────────── export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { return prisma.user.findMany({ where }) } @@ -44,7 +42,6 @@ export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], ad }) } -// ── create ───────────────────────────────────────────────────────────────────────────── export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { const user = await getUserByEmail(prisma, email) if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') From effb5d6a49d4675815cf02848a83da2597c6efcb Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 16:20:22 +0200 Subject: [PATCH 14/24] refactor(user): realigner structure et style du module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligne le module user sur le pattern établi par le module zone (#2487) : - `user-queries.utils.ts` : suppression du `userSelect`/`UserRecord` morts, signatures sur `Prisma.TransactionClient`, `createUser` purement DB (la validation du doublon email monte dans le service en `ConflictException`). - `user.service.ts` : extraction de `resolveAdminRoleIds`, `patchUsersInTx`, `emitImpactedRoleEvents` ; patch des rôles sous transaction. - `user.module.ts` : imports repliés sur `InfrastructureModule` (déjà exporté par celui-ci). Refs #2738 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I3bd632f95dd466e585698eba738efcd86a6a6964 --- .../src/modules/user/user-queries.utils.ts | 101 ++++++++++---- .../src/modules/user/user.controller.ts | 30 ++--- .../src/modules/user/user.module.ts | 12 +- .../src/modules/user/user.service.spec.ts | 18 +-- .../src/modules/user/user.service.ts | 124 +++++------------- 5 files changed, 126 insertions(+), 159 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index bc32cbfe6c..8c9106697c 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -1,49 +1,92 @@ +import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { Prisma, User } from '@prisma/client' import type { PrismaService } from '../infrastructure/database/prisma.service' +import { BadRequestException } from '@nestjs/common' -type UserCreate = Omit - -export const userSelect = { - id: true, - firstName: true, - lastName: true, - email: true, - createdAt: true, - updatedAt: true, - lastLogin: true, - adminRoleIds: true, - type: true, -} satisfies Prisma.UserSelect -export type UserRecord = Prisma.UserGetPayload<{ select: typeof userSelect }> - -export function getUsers(prisma: PrismaService, where?: Prisma.UserWhereInput) { - return prisma.user.findMany({ where }) +export function getUsers(tx: Prisma.TransactionClient, where?: Prisma.UserWhereInput) { + return tx.user.findMany({ where }) } -export function getMatchingUsers(prisma: PrismaService, where: Prisma.UserWhereInput) { - return prisma.user.findMany({ +export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) { + return tx.user.findMany({ where, take: 5, }) } -export function getUserByEmail(prisma: PrismaService, email: User['email']) { - return prisma.user.findUnique({ where: { email } }) +export function getUserByEmail(tx: Prisma.TransactionClient, email: User['email']) { + return tx.user.findUnique({ where: { email } }) } -export function getAdminRolesByName(prisma: PrismaService, names: string[]) { - return prisma.adminRole.findMany({ where: { name: { in: names } } }) +export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { + return tx.adminRole.findMany({ where: { name: { in: names } } }) } -export function updateUserAdminRoleIds(prisma: PrismaService, id: User['id'], adminRoleIds: string[]) { - return prisma.user.update({ +export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) { + return tx.user.update({ where: { id }, data: { adminRoleIds }, }) } -export async function createUser(prisma: PrismaService, { id, email, firstName, lastName, type }: UserCreate) { - const user = await getUserByEmail(prisma, email) - if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') - return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +export function createUser(tx: Prisma.TransactionClient, data: Pick) { + return tx.user.create({ data }) +} + +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, + query: AllUsersQuery, + relationType: 'OR' | 'AND', +): Promise { + 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 } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + return { AND } } diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 6b3ca5ba60..00bc84892b 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -1,14 +1,11 @@ -import type { ClientInferResponseBody } from '@ts-rest/core' -import { userContract } from '@cpn-console/shared' +import type { AllUsers, AllUsersQuery, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' +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' - -type AllUsersResponse = ClientInferResponseBody -type MatchingUsersResponse = ClientInferResponseBody -type PatchUsersResponse = ClientInferResponseBody +import { toContractUser } from './user.utils' @Controller('api/v1/users') @UseGuards(UserGuard) @@ -18,25 +15,28 @@ export class UserController { @Get() @RequireAdminPermission('ManageUsers') async getAllUsers( - @Query(new ZodValidationPipe(userContract.getAllUsers.query)) query: typeof userContract.getAllUsers.query._type, - ): Promise { + @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: AllUsersQuery, + ): Promise { const relationType = query.relationType ?? 'AND' const { relationType: _, ...listQuery } = query - return this.userService.getAllUsers(listQuery, relationType) + const users = await this.userService.getAllUsers(listQuery, relationType) + return users.map(toContractUser) } @Get('matching') async getMatchingUsers( - @Query(new ZodValidationPipe(userContract.getMatchingUsers.query)) query: typeof userContract.getMatchingUsers.query._type, - ): Promise { - return this.userService.getMatchingUsers(query) + @Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery, + ): Promise { + const users = await this.userService.getMatchingUsers(query) + return users.map(toContractUser) } @Patch() @RequireAdminPermission('ManageUsers') async patchUsers( - @Body(new ZodValidationPipe(userContract.patchUsers.body)) users: typeof userContract.patchUsers.body._type, - ): Promise { - return this.userService.patchUsers(users) + @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, + ): Promise { + const updatedUsers = await this.userService.patchUsers(users) + return updatedUsers.map(toContractUser) } } diff --git a/apps/server-nestjs/src/modules/user/user.module.ts b/apps/server-nestjs/src/modules/user/user.module.ts index 0be6ab177f..61320b6d33 100644 --- a/apps/server-nestjs/src/modules/user/user.module.ts +++ b/apps/server-nestjs/src/modules/user/user.module.ts @@ -1,18 +1,10 @@ import { Module } from '@nestjs/common' -import { AuthModule } from '../infrastructure/auth/auth.module' -import { DatabaseModule } from '../infrastructure/database/database.module' -import { EventsModule } from '../infrastructure/events/events.module' -import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' import { UserController } from './user.controller' import { UserService } from './user.service' @Module({ - imports: [ - AuthModule, - DatabaseModule, - EventsModule, - UserPermissionModule, - ], + imports: [InfrastructureModule], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 7bb73f7af4..7875250169 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' @@ -33,17 +34,7 @@ describe('userService', () => { const result = await service.getAllUsers({}, 'AND') - expect(result).toEqual(users.map(user => ({ - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - adminRoleIds: user.adminRoleIds, - type: user.type, - lastLogin: user.lastLogin?.toISOString() ?? null, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - }))) + expect(result).toEqual(users) expect(prisma.user.findMany).toHaveBeenCalled() }) @@ -83,10 +74,13 @@ describe('userService', () => { const users = [makeUser()] prisma.user.update.mockResolvedValue(users[0]) prisma.user.findMany.mockResolvedValue(users) + const tx = mockDeep() + tx.user.update.mockResolvedValue(users[0]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) - expect(prisma.user.update).toHaveBeenCalledWith({ + expect(tx.user.update).toHaveBeenCalledWith({ where: { id: users[0].id }, data: { adminRoleIds: ['role-1'] }, }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index b8a2a461c5..4b2addbe81 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,35 +1,18 @@ -import type { userContract } from '@cpn-console/shared' -import type { Prisma, User } from '@prisma/client' -import type { ClientInferResponseBody } from '@ts-rest/core' -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +import type { User } from '@prisma/client' +import { ConflictException, Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' import { - createUser as createUserQuery, - getAdminRolesByName, - getMatchingUsers as getMatchingUsersQuery, + buildAllUsersWhere, + buildMatchingUsersWhere, + createUser, + getMatchingUsers, + getUserByEmail, getUsers, - updateUserAdminRoleIds, + patchUsers, } from './user-queries.utils' -type AllUsersResponse = ClientInferResponseBody -type MatchingUsersResponse = ClientInferResponseBody -type PatchUsersResponse = ClientInferResponseBody - -function toContractUser(user: User): AllUsersResponse[number] { - return { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - adminRoleIds: user.adminRoleIds, - type: user.type, - lastLogin: user.lastLogin ? user.lastLogin.toISOString() : null, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - } -} - @Injectable() export class UserService { constructor( @@ -38,82 +21,39 @@ export class UserService { ) {} async getAllUsers( - query: typeof userContract.getAllUsers.query._type, + query: AllUsersQuery, relationType: 'OR' | 'AND' = 'AND', - ): Promise { - const whereInputs: Prisma.UserWhereInput[] = [] - if (query.adminRoleIds?.length) { - whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) - } - if (query.adminRoles?.length) { - const roles = query.adminRoles - ? await getAdminRolesByName(this.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 (await getUsers(this.prisma, { [relationType]: whereInputs })).map(toContractUser) + ): Promise { + const where = await buildAllUsersWhere(this.prisma, query, relationType) + return getUsers(this.prisma, where) } async getMatchingUsers( - query: typeof userContract.getMatchingUsers.query._type, - ): Promise { - const AND: Prisma.UserWhereInput[] = [] - if (query.notInProjectId) { - AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) - AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) - } - const filter = { contains: query.letters, mode: 'insensitive' } as const - if (query.letters) { - AND.push({ - OR: [{ - email: filter, - }, { - firstName: filter, - }, { - lastName: filter, - }], - }) - AND.push({ type: 'human' }) - } - - return (await getMatchingUsersQuery(this.prisma, { AND })).map(toContractUser) + query: LettersQuery, + ): Promise { + const where = buildMatchingUsersWhere(query) + return getMatchingUsers(this.prisma, where) } - async createUser( - data: Omit, - ): Promise { - return createUserQuery(this.prisma, data) + async createUser(data: Omit): Promise { + const existing = await getUserByEmail(this.prisma, data.email) + if (existing) throw new ConflictException('Un utilisateur avec cette adresse e-mail existe déjà') + return this.prisma.$transaction(tx => createUser(tx, data)) } async patchUsers( - users: { id: string, adminRoleIds: string[] | null }[], - ): Promise { + users: PatchUsersBody, + ): Promise { const usersBefore = await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) + await this.prisma.$transaction(tx => patchUsers(tx, users)) + await this.emitImpactedRoleEvents(users, usersBefore) + return getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } }) + } - for (const user of users) { - if (user.adminRoleIds) { - await updateUserAdminRoleIds(this.prisma, user.id, user.adminRoleIds) - } - } - - // Mirror legacy: hook.adminRole.upsert per impacted user, over the union of - // before/after adminRoleIds so revocations are synced too + private async emitImpactedRoleEvents( + users: PatchUsersBody, + usersBefore: User[], + ): Promise { const impactedRoleIds = new Set() for (const user of users) { usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) @@ -122,7 +62,5 @@ export class UserService { for (const roleId of impactedRoleIds) { await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) } - - return (await getUsers(this.prisma, { id: { in: users.map(({ id }) => id) } })).map(toContractUser) } } From cac74d2df7b47ea08b11a7fd1db576bd43369fca Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 23 Sep 2026 18:08:56 +0200 Subject: [PATCH 15/24] =?UTF-8?q?refactor(user):=20retirer=20l'alias=20All?= =?UTF-8?q?UsersQuery=20au=20profit=20du=20sch=C3=A9ma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AllUsersQuerySchema` étant exporté, l'alias `AllUsersQuery` est redondant ; les consommateurs dérivent le type par `z.infer`. `LettersQuery` est conservé (utilisé par le client). Refs #1889 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I5d954715680b8fbcd866edf4bb300dc36a6a6964 --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 5 +++-- apps/server-nestjs/src/modules/user/user.controller.ts | 5 +++-- apps/server-nestjs/src/modules/user/user.service.ts | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 8c9106697c..1af3934144 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -1,5 +1,6 @@ -import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +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' @@ -41,7 +42,7 @@ export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) export async function buildAllUsersWhere( prisma: PrismaService, - query: AllUsersQuery, + query: z.infer, relationType: 'OR' | 'AND', ): Promise { const whereInputs: Prisma.UserWhereInput[] = [] diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 00bc84892b..6a5f6ed891 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -1,4 +1,5 @@ -import type { AllUsers, AllUsersQuery, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' +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' @@ -15,7 +16,7 @@ export class UserController { @Get() @RequireAdminPermission('ManageUsers') async getAllUsers( - @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: AllUsersQuery, + @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, ): Promise { const relationType = query.relationType ?? 'AND' const { relationType: _, ...listQuery } = query diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index 4b2addbe81..abf89d6694 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,5 +1,6 @@ -import type { AllUsersQuery, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { User } from '@prisma/client' +import type { z } from 'zod' import { ConflictException, Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -21,7 +22,7 @@ export class UserService { ) {} async getAllUsers( - query: AllUsersQuery, + query: z.infer, relationType: 'OR' | 'AND' = 'AND', ): Promise { const where = await buildAllUsersWhere(this.prisma, query, relationType) From caecf77e613108263fc1326c35cea510dce802dc Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 10:43:03 +0200 Subject: [PATCH 16/24] test(user): lock controller permission matrix Co-authored-by: Automata Signed-off-by: William Phetsinorath --- .../src/modules/user/user.controller.spec.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index fe853a3229..5c2755f138 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' -import { AllUsersQuerySchema } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' +import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeUser } from './user-testing.utils' import { UserController } from './user.controller' @@ -35,14 +36,20 @@ describe('userController', () => { expect(controller).toBeDefined() }) + it('guards list and patch with ManageUsers, leaves matching open to logged-in users', () => { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getAllUsers)).toEqual(['ManageUsers']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.patchUsers)).toEqual(['ManageUsers']) + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getMatchingUsers)).toBeUndefined() + }) + 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' })) + const result = await controller.getAllUsers({ adminRoleIds: ['r1'], relationType: 'OR' } as never) expect(result).toEqual(users.map(toContractUser)) - expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) + expect(service.getAllUsers).toHaveBeenCalledWith({ adminRoleIds: ['r1'] }, 'OR') }) it('delegates matching query to the service', async () => { @@ -59,7 +66,7 @@ describe('userController', () => { service.patchUsers.mockResolvedValue(users) const body = [{ id: users[0].id, adminRoleIds: ['r1'] }] - expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser)) + expect(await controller.patchUsers(body as never)).toEqual(users.map(toContractUser)) expect(service.patchUsers).toHaveBeenCalledWith(body) }) }) From 651e6d6d51b5d521ac3a59002f114d5f3b630ab3 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 25 Sep 2026 17:35:32 +0200 Subject: [PATCH 17/24] fix(user): purge dead create path and lock legacy parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - supprimer UserService.createUser et les helpers createUser/getUserByEmail (aucun appelant ni route au contrat : chemin mort signalé en revue) - supprimer l'import faker inutilisé de user.controller.spec.ts - verrouiller par spec : 400 (BadRequestException) sur adminRole inconnue, take:5 sur la recherche par lettres, émission union avant ∪ après Signed-off-by: William Phetsinorath Co-authored-by: Automata --- .../src/modules/user/user-queries.utils.ts | 8 ------- .../src/modules/user/user.controller.spec.ts | 1 - .../src/modules/user/user.service.spec.ts | 24 ++++++++++++------- .../src/modules/user/user.service.ts | 10 +------- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 1af3934144..630143ea56 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -15,10 +15,6 @@ export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.Use }) } -export function getUserByEmail(tx: Prisma.TransactionClient, email: User['email']) { - return tx.user.findUnique({ where: { email } }) -} - export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { return tx.adminRole.findMany({ where: { name: { in: names } } }) } @@ -30,10 +26,6 @@ export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['i }) } -export function createUser(tx: Prisma.TransactionClient, data: Pick) { - return tx.user.create({ data }) -} - export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) { return Promise.all(users .filter((user): user is typeof user & { adminRoleIds: string[] } => user.adminRoleIds !== null) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 5c2755f138..bf4dfdc174 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -1,6 +1,5 @@ import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' -import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 7875250169..4575b7ecc9 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -1,5 +1,6 @@ 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' @@ -50,15 +51,19 @@ describe('userService', () => { })) }) - it('throws when an admin role name is not found', async () => { + it('rejects an unknown admin role name with the legacy 400', async () => { prisma.adminRole.findMany.mockResolvedValue([]) + await expect( + service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + ).rejects.toThrow(BadRequestException) + await expect( service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), ).rejects.toThrow('Unable to find adminRole ghost-role') }) - it('returns matching users by letters', async () => { + it('returns matching users by letters, capped at 5 like the legacy query', async () => { const users = [makeUser()] prisma.user.findMany.mockResolvedValue(users) @@ -66,24 +71,25 @@ describe('userService', () => { 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 adminRole upsert events', async () => { - const users = [makeUser()] - prisma.user.update.mockResolvedValue(users[0]) - prisma.user.findMany.mockResolvedValue(users) + it('patches users and emits the union of before and after admin roles', async () => { + const user = makeUser({ adminRoleIds: ['role-0'] }) + prisma.user.findMany.mockResolvedValue([user]) const tx = mockDeep() - tx.user.update.mockResolvedValue(users[0]) + tx.user.update.mockResolvedValue(user) prisma.$transaction.mockImplementation(async cb => cb(tx)) - const result = await service.patchUsers([{ id: users[0].id, adminRoleIds: ['role-1'] }]) + const result = await service.patchUsers([{ id: user.id, adminRoleIds: ['role-1'] }]) expect(tx.user.update).toHaveBeenCalledWith({ - where: { id: users[0].id }, + where: { id: user.id }, data: { adminRoleIds: ['role-1'] }, }) + expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-0' }) expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-1' }) expect(result).toHaveLength(1) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index abf89d6694..479319f314 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -1,15 +1,13 @@ import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' import type { User } from '@prisma/client' import type { z } from 'zod' -import { ConflictException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { PrismaService } from '../infrastructure/database/prisma.service' import { buildAllUsersWhere, buildMatchingUsersWhere, - createUser, getMatchingUsers, - getUserByEmail, getUsers, patchUsers, } from './user-queries.utils' @@ -36,12 +34,6 @@ export class UserService { return getMatchingUsers(this.prisma, where) } - async createUser(data: Omit): Promise { - const existing = await getUserByEmail(this.prisma, data.email) - if (existing) throw new ConflictException('Un utilisateur avec cette adresse e-mail existe déjà') - return this.prisma.$transaction(tx => createUser(tx, data)) - } - async patchUsers( users: PatchUsersBody, ): Promise { From 8d0dc83325fc0341c82b22e6f6e032c88c34ec7e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 28 Sep 2026 16:30:10 +0200 Subject: [PATCH 18/24] refactor(user): remove reflection-only permissions metadata check Reflect.getMetadata assertions pass whether or not the guard is enforced and add no behavioural coverage; the guard logic stays covered in user.guard.spec.ts and the controller delegation tests are untouched. Co-authored-by: Automata Signed-off-by: William Phetsinorath --- .../server-nestjs/src/modules/user/user.controller.spec.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index bf4dfdc174..534ac80510 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -3,7 +3,6 @@ import type { MockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' -import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeUser } from './user-testing.utils' import { UserController } from './user.controller' @@ -35,12 +34,6 @@ describe('userController', () => { expect(controller).toBeDefined() }) - it('guards list and patch with ManageUsers, leaves matching open to logged-in users', () => { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getAllUsers)).toEqual(['ManageUsers']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.patchUsers)).toEqual(['ManageUsers']) - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, UserController.prototype.getMatchingUsers)).toBeUndefined() - }) - it('delegates list with relationType defaulted to AND and stripped from the query', async () => { const users = [makeUser()] service.getAllUsers.mockResolvedValue(users) From 74133da1b829f94aeb4d28184044726fc4a92ebf Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 30 Sep 2026 11:46:04 +0200 Subject: [PATCH 19/24] fix(user): emit canonical AdminRoleEventPayload and type controller inputs Emit the full { id, oidcGroup, members } payload that the 2749 event bridge consumers expect, drop as never from the controller spec by parsing inputs through the shared schemas, and move the relationType default into buildAllUsersWhere so the controller passes the query straight through. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I28676aa618050a7042ed24dfa9e66b896a6a6964 --- .../src/modules/user/user-queries.utils.ts | 4 +-- .../src/modules/user/user.controller.spec.ts | 5 ++-- .../src/modules/user/user.controller.ts | 4 +-- .../src/modules/user/user.service.spec.ts | 30 ++++++++++++++----- .../src/modules/user/user.service.ts | 25 +++++++++++----- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index 630143ea56..ee7722573b 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -34,9 +34,9 @@ export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) export async function buildAllUsersWhere( prisma: PrismaService, - query: z.infer, - relationType: 'OR' | 'AND', + { relationType: relation, ...query }: z.infer, ): Promise { + const relationType = relation ?? 'AND' const whereInputs: Prisma.UserWhereInput[] = [] if (query.adminRoleIds?.length) { whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 534ac80510..6a2a96c826 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -1,5 +1,6 @@ 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' @@ -38,10 +39,10 @@ describe('userController', () => { const users = [makeUser()] service.getAllUsers.mockResolvedValue(users) - const result = await controller.getAllUsers({ adminRoleIds: ['r1'], relationType: 'OR' } as never) + 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({ adminRoleIds: ['r1'] }, 'OR') + expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) }) it('delegates matching query to the service', async () => { diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 6a5f6ed891..7abc362b07 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -18,9 +18,7 @@ export class UserController { async getAllUsers( @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, ): Promise { - const relationType = query.relationType ?? 'AND' - const { relationType: _, ...listQuery } = query - const users = await this.userService.getAllUsers(listQuery, relationType) + const users = await this.userService.getAllUsers(query) return users.map(toContractUser) } diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index 4575b7ecc9..cbc1cbc83f 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -33,7 +33,7 @@ describe('userService', () => { const users = [makeUser(), makeUser()] prisma.user.findMany.mockResolvedValue(users) - const result = await service.getAllUsers({}, 'AND') + const result = await service.getAllUsers({}) expect(result).toEqual(users) expect(prisma.user.findMany).toHaveBeenCalled() @@ -43,7 +43,7 @@ describe('userService', () => { prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })]) prisma.user.findMany.mockResolvedValue([makeUser()]) - await service.getAllUsers({ adminRoles: ['admin'] }, 'AND') + await service.getAllUsers({ adminRoles: ['admin'] }) expect(prisma.adminRole.findMany).toHaveBeenCalled() expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ @@ -55,11 +55,11 @@ describe('userService', () => { prisma.adminRole.findMany.mockResolvedValue([]) await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + service.getAllUsers({ adminRoles: ['ghost-role'] }), ).rejects.toThrow(BadRequestException) await expect( - service.getAllUsers({ adminRoles: ['ghost-role'] }, 'AND'), + service.getAllUsers({ adminRoles: ['ghost-role'] }), ).rejects.toThrow('Unable to find adminRole ghost-role') }) @@ -78,7 +78,15 @@ describe('userService', () => { it('patches users and emits the union of before and after admin roles', async () => { const user = makeUser({ adminRoleIds: ['role-0'] }) - prisma.user.findMany.mockResolvedValue([user]) + prisma.user.findMany.mockImplementation(async (_args) => { + // before-load returns the user with role-0; the after-load must see role-1, + // but the members query only needs the selected member fields. + 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() tx.user.update.mockResolvedValue(user) prisma.$transaction.mockImplementation(async cb => cb(tx)) @@ -89,8 +97,16 @@ describe('userService', () => { where: { id: user.id }, data: { adminRoleIds: ['role-1'] }, }) - expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: 'role-0' }) - expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { roleId: '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) }) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.ts b/apps/server-nestjs/src/modules/user/user.service.ts index 479319f314..cb47482703 100644 --- a/apps/server-nestjs/src/modules/user/user.service.ts +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -19,11 +19,8 @@ export class UserService { @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, ) {} - async getAllUsers( - query: z.infer, - relationType: 'OR' | 'AND' = 'AND', - ): Promise { - const where = await buildAllUsersWhere(this.prisma, query, relationType) + async getAllUsers(query: z.infer): Promise { + const where = await buildAllUsersWhere(this.prisma, query) return getUsers(this.prisma, where) } @@ -52,8 +49,22 @@ export class UserService { usersBefore.find(({ id }) => id === user.id)?.adminRoleIds.forEach(roleId => impactedRoleIds.add(roleId)) user.adminRoleIds?.forEach(roleId => impactedRoleIds.add(roleId)) } - for (const roleId of impactedRoleIds) { - await this.eventEmitter.emitAsync('adminRole.upsert', { roleId }) + const roles = await this.prisma.adminRole.findMany({ + where: { id: { in: [...impactedRoleIds] } }, + select: { id: true, oidcGroup: true }, + }) + const members = await this.prisma.user.findMany({ + where: { adminRoleIds: { hasSome: [...impactedRoleIds] } }, + select: { id: true, email: true, firstName: true, lastName: true, adminRoleIds: true }, + }) + for (const role of roles) { + await this.eventEmitter.emitAsync('adminRole.upsert', { + id: role.id, + oidcGroup: role.oidcGroup, + members: members + .filter(({ adminRoleIds }) => adminRoleIds.includes(role.id)) + .map(({ id, email, firstName, lastName }) => ({ id, email, firstName, lastName })), + }) } } } From ba5e73dedbf3908cb9513873715aee222e1fb4cd Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 30 Sep 2026 12:35:38 +0200 Subject: [PATCH 20/24] style(user): drop mock-wiring narration comment Signed-off-by: William Phetsinorath Change-Id: Ieb2ee298f5d0547bf988787721ed029a6a6a6964 Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user.controller.spec.ts | 2 +- apps/server-nestjs/src/modules/user/user.service.spec.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index 6a2a96c826..fe853a3229 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -59,7 +59,7 @@ describe('userController', () => { service.patchUsers.mockResolvedValue(users) const body = [{ id: users[0].id, adminRoleIds: ['r1'] }] - expect(await controller.patchUsers(body as never)).toEqual(users.map(toContractUser)) + expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser)) expect(service.patchUsers).toHaveBeenCalledWith(body) }) }) diff --git a/apps/server-nestjs/src/modules/user/user.service.spec.ts b/apps/server-nestjs/src/modules/user/user.service.spec.ts index cbc1cbc83f..d592bc0b90 100644 --- a/apps/server-nestjs/src/modules/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -79,8 +79,6 @@ describe('userService', () => { 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) => { - // before-load returns the user with role-0; the after-load must see role-1, - // but the members query only needs the selected member fields. 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([ From 9d8cef6a27091b4959b10b901b7918a75f887cc7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 14:12:44 +0200 Subject: [PATCH 21/24] refactor(user): move the letters filter inside its guard Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index ee7722573b..c26701ef4a 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -68,8 +68,8 @@ export function buildMatchingUsersWhere(query: LettersQuery): Prisma.UserWhereIn AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) } - const filter = { contains: query.letters, mode: 'insensitive' } as const if (query.letters) { + const filter = { contains: query.letters, mode: 'insensitive' } as const AND.push({ OR: [{ email: filter, From 8c1a923dce684629f532694620ebdc7d3ef16efa Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 15:58:24 +0200 Subject: [PATCH 22/24] fix(user): restrict UserGuard to list and patch routes GET /api/v1/users/matching stays public like the legacy router. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ie8d52f472340f7fe114fbe25f11a32746a6a6964 --- .../src/modules/user/user.controller.spec.ts | 10 ++++++++++ apps/server-nestjs/src/modules/user/user.controller.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index fe853a3229..c3eb3e2e6d 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -15,6 +15,16 @@ describe('userController', () => { let controller: UserController let service: MockProxy + it('keeps the matching search public (legacy GET has no auth)', () => { + expect(Reflect.getMetadata('__guards__', UserController) ?? []).not.toContain(UserGuard) + expect(Reflect.getMetadata('__guards__', UserController.prototype.getMatchingUsers) ?? []).not.toContain(UserGuard) + }) + + it('guards list and patch behind UserGuard', () => { + expect(Reflect.getMetadata('__guards__', UserController.prototype.getAllUsers) ?? []).toContain(UserGuard) + expect(Reflect.getMetadata('__guards__', UserController.prototype.patchUsers) ?? []).toContain(UserGuard) + }) + beforeEach(async () => { service = mock() diff --git a/apps/server-nestjs/src/modules/user/user.controller.ts b/apps/server-nestjs/src/modules/user/user.controller.ts index 7abc362b07..7805a1f7eb 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -9,11 +9,11 @@ import { UserService } from './user.service' import { toContractUser } from './user.utils' @Controller('api/v1/users') -@UseGuards(UserGuard) export class UserController { constructor(@Inject(UserService) private readonly userService: UserService) {} @Get() + @UseGuards(UserGuard) @RequireAdminPermission('ManageUsers') async getAllUsers( @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, @@ -31,6 +31,7 @@ export class UserController { } @Patch() + @UseGuards(UserGuard) @RequireAdminPermission('ManageUsers') async patchUsers( @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, From 8731b8a69889f1c026554faa2d1b31742b7feb8b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 16:02:49 +0200 Subject: [PATCH 23/24] test(user): drop guard metadata specs Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I1601b269c95ef1a8b6496ac93335e2a06a6a6964 --- .../src/modules/user/user.controller.spec.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/server-nestjs/src/modules/user/user.controller.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts index c3eb3e2e6d..fe853a3229 100644 --- a/apps/server-nestjs/src/modules/user/user.controller.spec.ts +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -15,16 +15,6 @@ describe('userController', () => { let controller: UserController let service: MockProxy - it('keeps the matching search public (legacy GET has no auth)', () => { - expect(Reflect.getMetadata('__guards__', UserController) ?? []).not.toContain(UserGuard) - expect(Reflect.getMetadata('__guards__', UserController.prototype.getMatchingUsers) ?? []).not.toContain(UserGuard) - }) - - it('guards list and patch behind UserGuard', () => { - expect(Reflect.getMetadata('__guards__', UserController.prototype.getAllUsers) ?? []).toContain(UserGuard) - expect(Reflect.getMetadata('__guards__', UserController.prototype.patchUsers) ?? []).toContain(UserGuard) - }) - beforeEach(async () => { service = mock() From 5a117f46fee3697108c53c76f337c89192e5e609 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 2 Oct 2026 12:03:29 +0200 Subject: [PATCH 24/24] fix(user): use the projectMembers relation key in memberOfIds filter Co-authored-by: Automata --- apps/server-nestjs/src/modules/user/user-queries.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/user/user-queries.utils.ts b/apps/server-nestjs/src/modules/user/user-queries.utils.ts index c26701ef4a..f26c3183f6 100644 --- a/apps/server-nestjs/src/modules/user/user-queries.utils.ts +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -54,7 +54,7 @@ export async function buildAllUsersWhere( AND: query.memberOfIds.map(id => ({ OR: [ { projectsOwned: { some: { id } } }, - { ProjectMembers: { some: { project: { id } } } }, + { projectMembers: { some: { project: { id } } } }, ], })), })