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..f26c3183f6 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user-queries.utils.ts @@ -0,0 +1,85 @@ +import type { AllUsersQuerySchema, LettersQuery, PatchUsersBody } from '@cpn-console/shared' +import type { Prisma, User } from '@prisma/client' +import type { z } from 'zod' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { BadRequestException } from '@nestjs/common' + +export function getUsers(tx: Prisma.TransactionClient, where?: Prisma.UserWhereInput) { + return tx.user.findMany({ where }) +} + +export function getMatchingUsers(tx: Prisma.TransactionClient, where: Prisma.UserWhereInput) { + return tx.user.findMany({ + where, + take: 5, + }) +} + +export function getAdminRolesByName(tx: Prisma.TransactionClient, names: string[]) { + return tx.adminRole.findMany({ where: { name: { in: names } } }) +} + +export function updateUserAdminRoleIds(tx: Prisma.TransactionClient, id: User['id'], adminRoleIds: string[]) { + return tx.user.update({ + where: { id }, + data: { adminRoleIds }, + }) +} + +export function patchUsers(tx: Prisma.TransactionClient, users: PatchUsersBody) { + return Promise.all(users + .filter((user): user is typeof user & { adminRoleIds: string[] } => user.adminRoleIds !== null) + .map(user => updateUserAdminRoleIds(tx, user.id, user.adminRoleIds))) +} + +export async function buildAllUsersWhere( + prisma: PrismaService, + { relationType: relation, ...query }: z.infer, +): 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 } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + if (query.letters) { + const filter = { contains: query.letters, mode: 'insensitive' } as const + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + return { AND } +} 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.spec.ts b/apps/server-nestjs/src/modules/user/user.controller.spec.ts new file mode 100644 index 0000000000..fe853a3229 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.controller.spec.ts @@ -0,0 +1,65 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { AllUsersQuerySchema } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeUser } from './user-testing.utils' +import { UserController } from './user.controller' +import { UserService } from './user.service' +import { toContractUser } from './user.utils' + +describe('userController', () => { + let module: TestingModule + let controller: UserController + let service: MockProxy + + 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('delegates list with relationType defaulted to AND and stripped from the query', async () => { + const users = [makeUser()] + service.getAllUsers.mockResolvedValue(users) + + const result = await controller.getAllUsers(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) + + expect(result).toEqual(users.map(toContractUser)) + expect(service.getAllUsers).toHaveBeenCalledWith(AllUsersQuerySchema.parse({ adminRoleIds: 'd33a4d4a-3543-4bba-b880-d2d4efb9607c', relationType: 'OR' })) + }) + + it('delegates matching query to the service', async () => { + const users = [makeUser()] + service.getMatchingUsers.mockResolvedValue(users) + const query = { letters: 'ab' } + + expect(await controller.getMatchingUsers(query)).toEqual(users.map(toContractUser)) + expect(service.getMatchingUsers).toHaveBeenCalledWith(query) + }) + + it('delegates patch body to the service', async () => { + const users = [makeUser()] + service.patchUsers.mockResolvedValue(users) + const body = [{ id: users[0].id, adminRoleIds: ['r1'] }] + + expect(await controller.patchUsers(body)).toEqual(users.map(toContractUser)) + expect(service.patchUsers).toHaveBeenCalledWith(body) + }) +}) 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..7805a1f7eb --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.controller.ts @@ -0,0 +1,42 @@ +import type { AllUsers, LettersQuery, MatchingUsers, PatchUsers, PatchUsersBody } from '@cpn-console/shared' +import type { z } from 'zod' +import { AllUsersQuerySchema, MatchingUsersQuerySchema, PatchUsersBodySchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Patch, Query, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { UserService } from './user.service' +import { toContractUser } from './user.utils' + +@Controller('api/v1/users') +export class UserController { + constructor(@Inject(UserService) private readonly userService: UserService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ManageUsers') + async getAllUsers( + @Query(new ZodValidationPipe(AllUsersQuerySchema)) query: z.infer, + ): Promise { + const users = await this.userService.getAllUsers(query) + return users.map(toContractUser) + } + + @Get('matching') + async getMatchingUsers( + @Query(new ZodValidationPipe(MatchingUsersQuerySchema)) query: LettersQuery, + ): Promise { + const users = await this.userService.getMatchingUsers(query) + return users.map(toContractUser) + } + + @Patch() + @UseGuards(UserGuard) + @RequireAdminPermission('ManageUsers') + async patchUsers( + @Body(new ZodValidationPipe(PatchUsersBodySchema)) users: PatchUsersBody, + ): Promise { + 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 new file mode 100644 index 0000000000..61320b6d33 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { UserController } from './user.controller' +import { UserService } from './user.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [UserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} 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..d592bc0b90 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.service.spec.ts @@ -0,0 +1,110 @@ +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeAdminRole, makeUser } from './user-testing.utils' +import { UserService } from './user.service' + +describe('userService', () => { + let service: UserService + let prisma: DeepMockProxy + 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({}) + + expect(result).toEqual(users) + expect(prisma.user.findMany).toHaveBeenCalled() + }) + + it('filters users by admin roles', async () => { + prisma.adminRole.findMany.mockResolvedValue([makeAdminRole({ name: 'admin' })]) + prisma.user.findMany.mockResolvedValue([makeUser()]) + + await service.getAllUsers({ adminRoles: ['admin'] }) + + expect(prisma.adminRole.findMany).toHaveBeenCalled() + expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { AND: expect.any(Array) }, + })) + }) + + it('rejects an unknown admin role name with the legacy 400', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + + await expect( + service.getAllUsers({ adminRoles: ['ghost-role'] }), + ).rejects.toThrow(BadRequestException) + + await expect( + service.getAllUsers({ adminRoles: ['ghost-role'] }), + ).rejects.toThrow('Unable to find adminRole ghost-role') + }) + + it('returns matching users by letters, capped at 5 like the legacy query', async () => { + const users = [makeUser()] + prisma.user.findMany.mockResolvedValue(users) + + const result = await service.getMatchingUsers({ letters: 'joh' }) + + expect(result).toHaveLength(1) + expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ + take: 5, + where: { AND: expect.arrayContaining([expect.objectContaining({ type: 'human' })]) }, + })) + }) + + it('patches users and emits the union of before and after admin roles', async () => { + const user = makeUser({ adminRoleIds: ['role-0'] }) + prisma.user.findMany.mockImplementation(async (_args) => { + return [_args.select?.adminRoleIds ? { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, adminRoleIds: ['role-0', 'role-1'] } : user] + }) + prisma.adminRole.findMany.mockResolvedValue([ + makeAdminRole({ id: 'role-0', oidcGroup: 'group-0' }), + makeAdminRole({ id: 'role-1', oidcGroup: 'group-1' }), + ]) + const tx = mockDeep() + tx.user.update.mockResolvedValue(user) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.patchUsers([{ id: user.id, adminRoleIds: ['role-1'] }]) + + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: user.id }, + data: { adminRoleIds: ['role-1'] }, + }) + expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { + id: 'role-0', + oidcGroup: 'group-0', + members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }], + }) + expect(events.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { + id: 'role-1', + oidcGroup: 'group-1', + members: [{ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName }], + }) + expect(result).toHaveLength(1) + }) +}) 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..cb47482703 --- /dev/null +++ b/apps/server-nestjs/src/modules/user/user.service.ts @@ -0,0 +1,70 @@ +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 { EventEmitter2 } from '@nestjs/event-emitter' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { + buildAllUsersWhere, + buildMatchingUsersWhere, + getMatchingUsers, + getUsers, + patchUsers, +} from './user-queries.utils' + +@Injectable() +export class UserService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @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 getMatchingUsers( + query: LettersQuery, + ): Promise { + const where = buildMatchingUsersWhere(query) + return getMatchingUsers(this.prisma, where) + } + + async patchUsers( + 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) } }) + } + + 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)) + 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 })), + }) + } + } +} 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..33d98b7f87 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,8 @@ export const userContract = contractInstance.router({ }) export type LettersQuery = ClientInferRequest['query'] +export type PatchUsersBody = z.infer export type AllUsers = ClientInferResponseBody +export type MatchingUsers = ClientInferResponseBody +export type PatchUsers = ClientInferResponseBody