From 18a614188940f00bfb04d97de5f990d0b6e4ac02 Mon Sep 17 00:00:00 2001 From: Michal Cieslar Date: Fri, 9 Feb 2024 23:17:07 +0100 Subject: [PATCH] add get users endpoint --- .../adminUserHttpController.ts | 56 ++++++++++++++ .../schemas/findUsersSchema.ts | 30 ++++++++ .../findUsersQueryHandler.ts | 14 ++++ ...dUsersQueryHandlerImpl.integration.test.ts | 77 +++++++++++++++++++ .../findUsersQueryHandlerImpl.ts | 27 +++++++ .../userRepository/userRepository.ts | 11 +++ .../userRepositoryImpl.integration.test.ts | 57 ++++++++++++++ .../userRepository/userRepositoryImpl.ts | 52 +++++++++++++ .../backend/src/modules/userModule/symbols.ts | 1 + .../src/modules/userModule/userModule.ts | 8 ++ common/contracts/src/index.ts | 2 + .../contracts/src/schemas/user/findUsers.ts | 19 +++++ 12 files changed, 354 insertions(+) create mode 100644 apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/schemas/findUsersSchema.ts create mode 100644 apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.ts create mode 100644 apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.integration.test.ts create mode 100644 apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.ts create mode 100644 common/contracts/src/schemas/user/findUsers.ts diff --git a/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/adminUserHttpController.ts b/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/adminUserHttpController.ts index f7a06ac..5808091 100644 --- a/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/adminUserHttpController.ts +++ b/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/adminUserHttpController.ts @@ -18,6 +18,12 @@ import { type FindUserPathParamsDTO, type FindUserResponseBodyDTO, } from './schemas/findUserSchema.js'; +import { + type FindUsersQueryParamsDTO, + findUsersQueryParamsDTOSchema, + findUsersResponseBodyDTOSchema, + type FindUsersResponseBodyDTO, +} from './schemas/findUsersSchema.js'; import { type GrantBucketAccessResponseBodyDTO, type GrantBucketAccessBodyDTO, @@ -50,6 +56,7 @@ import { type DeleteUserCommandHandler } from '../../../application/commandHandl import { type GrantBucketAccessCommandHandler } from '../../../application/commandHandlers/grantBucketAccessCommandHandler/grantBucketAccessCommandHandler.js'; import { type RevokeBucketAccessCommandHandler } from '../../../application/commandHandlers/revokeBucketAccessCommandHandler/revokeBucketAccessCommandHandler.js'; import { type FindUserQueryHandler } from '../../../application/queryHandlers/findUserQueryHandler/findUserQueryHandler.js'; +import { type FindUsersQueryHandler } from '../../../application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.js'; import { type User } from '../../../domain/entities/user/user.js'; import { type UserDTO } from '../common/userDTO.js'; @@ -60,6 +67,7 @@ export class AdminUserHttpController implements HttpController { private readonly createUserCommandHandler: CreateUserCommandHandler, private readonly deleteUserCommandHandler: DeleteUserCommandHandler, private readonly findUserQueryHandler: FindUserQueryHandler, + private readonly findUsersQueryHandler: FindUsersQueryHandler, private readonly grantBucketAccessCommandHandler: GrantBucketAccessCommandHandler, private readonly revokeBucketAccessCommandHandler: RevokeBucketAccessCommandHandler, private readonly accessControlService: AccessControlService, @@ -144,6 +152,24 @@ export class AdminUserHttpController implements HttpController { tags: ['User'], description: 'Find user by id.', }), + new HttpRoute({ + method: HttpMethodName.get, + handler: this.findUsers.bind(this), + schema: { + request: { + queryParams: findUsersQueryParamsDTOSchema, + }, + response: { + [HttpStatusCode.ok]: { + schema: findUsersResponseBodyDTOSchema, + description: 'Users found.', + }, + }, + }, + securityMode: SecurityMode.bearer, + tags: ['User'], + description: 'Find users.', + }), new HttpRoute({ method: HttpMethodName.delete, path: ':id', @@ -251,6 +277,36 @@ export class AdminUserHttpController implements HttpController { }; } + private async findUsers( + request: HttpRequest, + ): Promise> { + await this.accessControlService.verifyBearerToken({ + authorizationHeader: request.headers['authorization'], + expectedRole: UserRole.admin, + }); + + const page = request.queryParams.page ?? 1; + + const pageSize = request.queryParams.pageSize ?? 10; + + const { users, totalUsers } = await this.findUsersQueryHandler.execute({ + page, + pageSize, + }); + + return { + statusCode: HttpStatusCode.ok, + body: { + data: users.map((user) => this.mapUserToUserDTO(user)), + metadata: { + page, + pageSize, + totalPages: Math.ceil(totalUsers / pageSize), + }, + }, + }; + } + private async deleteUser( request: HttpRequest, ): Promise> { diff --git a/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/schemas/findUsersSchema.ts b/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/schemas/findUsersSchema.ts new file mode 100644 index 0000000..48f4896 --- /dev/null +++ b/apps/backend/src/modules/userModule/api/httpControllers/adminUserHttpController/schemas/findUsersSchema.ts @@ -0,0 +1,30 @@ +import { type Static, Type } from '@sinclair/typebox'; + +import type * as contracts from '@common/contracts'; + +import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; +import { userDTOSchema } from '../../common/userDTO.js'; + +export const findUsersQueryParamsDTOSchema = Type.Object({ + page: Type.Optional(Type.Integer({ minimum: 1 })), + pageSize: Type.Optional(Type.Integer({ minimum: 1 })), +}); + +export type FindUsersQueryParamsDTO = TypeExtends< + Static, + contracts.FindUsersQueryParams +>; + +export const findUsersResponseBodyDTOSchema = Type.Object({ + data: Type.Array(userDTOSchema), + metadata: Type.Object({ + page: Type.Integer(), + pageSize: Type.Integer(), + totalPages: Type.Integer(), + }), +}); + +export type FindUsersResponseBodyDTO = TypeExtends< + Static, + contracts.FindUsersResponseBody +>; diff --git a/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.ts b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.ts new file mode 100644 index 0000000..6a1e6fd --- /dev/null +++ b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.ts @@ -0,0 +1,14 @@ +import { type QueryHandler } from '../../../../../common/types/queryHandler.js'; +import { type User } from '../../../domain/entities/user/user.js'; + +export interface FindUsersQueryHandlerPayload { + readonly page: number; + readonly pageSize: number; +} + +export interface FindUsersQueryHandlerResult { + readonly users: User[]; + readonly totalUsers: number; +} + +export type FindUsersQueryHandler = QueryHandler; diff --git a/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.integration.test.ts b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.integration.test.ts new file mode 100644 index 0000000..173e3f6 --- /dev/null +++ b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.integration.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, afterEach, expect, it, describe } from 'vitest'; + +import { type FindUsersQueryHandler } from './findUsersQueryHandler.js'; +import { Application } from '../../../../../core/application.js'; +import { type SqliteDatabaseClient } from '../../../../../core/database/sqliteDatabaseClient/sqliteDatabaseClient.js'; +import { coreSymbols } from '../../../../../core/symbols.js'; +import { symbols } from '../../../symbols.js'; +import { UserTestUtils } from '../../../tests/utils/userTestUtils/userTestUtils.js'; + +describe('FindUsersQueryHandler', () => { + let findUsersQueryHandler: FindUsersQueryHandler; + + let sqliteDatabaseClient: SqliteDatabaseClient; + + let userTestUtils: UserTestUtils; + + beforeEach(async () => { + const container = Application.createContainer(); + + findUsersQueryHandler = container.get(symbols.findUsersQueryHandler); + + sqliteDatabaseClient = container.get(coreSymbols.sqliteDatabaseClient); + + userTestUtils = new UserTestUtils(sqliteDatabaseClient); + + await userTestUtils.truncate(); + }); + + afterEach(async () => { + await userTestUtils.truncate(); + + await sqliteDatabaseClient.destroy(); + }); + + it('finds Users', async () => { + const user1 = await userTestUtils.createAndPersist(); + + const user2 = await userTestUtils.createAndPersist(); + + const result = await findUsersQueryHandler.execute({ + page: 1, + pageSize: 10, + }); + + expect(result.users[0]?.getId()).toEqual(user1.id); + + expect(result.users[1]?.getId()).toEqual(user2.id); + + expect(result.totalUsers).toBe(2); + }); + + it('paginates Users', async () => { + const user1 = await userTestUtils.createAndPersist(); + + await userTestUtils.createAndPersist(); + + const result = await findUsersQueryHandler.execute({ + page: 1, + pageSize: 1, + }); + + expect(result.users[0]?.getId()).toEqual(user1.id); + + expect(result.totalUsers).toBe(2); + }); + + it('returns empty array if no Users found', async () => { + const result = await findUsersQueryHandler.execute({ + page: 1, + pageSize: 10, + }); + + expect(result.users).toEqual([]); + + expect(result.totalUsers).toBe(0); + }); +}); diff --git a/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.ts b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.ts new file mode 100644 index 0000000..58247f2 --- /dev/null +++ b/apps/backend/src/modules/userModule/application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.ts @@ -0,0 +1,27 @@ +import { + type FindUsersQueryHandler, + type FindUsersQueryHandlerPayload, + type FindUsersQueryHandlerResult, +} from './findUsersQueryHandler.js'; +import { type UserRepository } from '../../../domain/repositories/userRepository/userRepository.js'; + +export class FindUsersQueryHandlerImpl implements FindUsersQueryHandler { + public constructor(private readonly userRepository: UserRepository) {} + + public async execute(payload: FindUsersQueryHandlerPayload): Promise { + const { page, pageSize } = payload; + + const [users, totalUsers] = await Promise.all([ + this.userRepository.findUsers({ + page, + pageSize, + }), + this.userRepository.countUsers(), + ]); + + return { + users, + totalUsers, + }; + } +} diff --git a/apps/backend/src/modules/userModule/domain/repositories/userRepository/userRepository.ts b/apps/backend/src/modules/userModule/domain/repositories/userRepository/userRepository.ts index 511b3c7..7978e1e 100644 --- a/apps/backend/src/modules/userModule/domain/repositories/userRepository/userRepository.ts +++ b/apps/backend/src/modules/userModule/domain/repositories/userRepository/userRepository.ts @@ -14,6 +14,15 @@ export interface FindUserPayload { readonly email?: string; } +export interface FindUsersPayload { + readonly page: number; + readonly pageSize: number; +} + +export interface FindUsersResult { + readonly users: User[]; +} + export interface FindUserTokensPayload { readonly userId: string; } @@ -34,6 +43,8 @@ export interface DeleteUserPayload { export interface UserRepository { createUser(input: CreateUserPayload): Promise; findUser(input: FindUserPayload): Promise; + findUsers(input: FindUsersPayload): Promise; + countUsers(): Promise; findUserTokens(input: FindUserTokensPayload): Promise; findUserBuckets(input: FindUserBucketsPayload): Promise; updateUser(input: UpdateUserPayload): Promise; diff --git a/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.integration.test.ts b/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.integration.test.ts index dabf90b..f77f40e 100644 --- a/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.integration.test.ts +++ b/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.integration.test.ts @@ -103,6 +103,63 @@ describe('UserRepositoryImpl', () => { }); }); + describe('Find all', () => { + it('finds all Users', async () => { + const user1 = await userTestUtils.createAndPersist(); + + const user2 = await userTestUtils.createAndPersist(); + + const users = await userRepository.findUsers({ + page: 1, + pageSize: 10, + }); + + expect(users.length).toEqual(2); + + expect(users.find((user) => user.getId() === user1.id)).not.toBeNull(); + + expect(users.find((user) => user.getId() === user2.id)).not.toBeNull(); + }); + + it('returns empty array if there are no Users', async () => { + const users = await userRepository.findUsers({ + page: 1, + pageSize: 10, + }); + + expect(users.length).toEqual(0); + }); + + it('returns empty array if there are no Users on the given page', async () => { + await userTestUtils.createAndPersist(); + + const users = await userRepository.findUsers({ + page: 2, + pageSize: 10, + }); + + expect(users.length).toEqual(0); + }); + }); + + describe('Count', () => { + it('counts Users', async () => { + await userTestUtils.createAndPersist(); + + await userTestUtils.createAndPersist(); + + const count = await userRepository.countUsers(); + + expect(count).toEqual(2); + }); + + it('returns 0 if there are no Users', async () => { + const count = await userRepository.countUsers(); + + expect(count).toEqual(0); + }); + }); + describe('Update', () => { it(`creates User's refresh tokens`, async () => { const user = await userTestUtils.createAndPersist(); diff --git a/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.ts b/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.ts index 7071ebb..355bbdf 100644 --- a/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.ts +++ b/apps/backend/src/modules/userModule/infrastructure/repositories/userRepository/userRepositoryImpl.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { type UserBucketMapper } from './userBucketMapper/userBucketMapper.js'; import { type UserMapper } from './userMapper/userMapper.js'; import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js'; @@ -19,6 +20,7 @@ import { type DeleteUserPayload, type FindUserTokensPayload, type FindUserBucketsPayload, + type FindUsersPayload, } from '../../../domain/repositories/userRepository/userRepository.js'; import { type RefreshTokenRawEntity } from '../../databases/userDatabase/tables/refreshTokenTable/refreshTokenRawEntity.js'; import { RefreshTokenTable } from '../../databases/userDatabase/tables/refreshTokenTable/refreshTokenTable.js'; @@ -142,6 +144,56 @@ export class UserRepositoryImpl implements UserRepository { return this.userMapper.mapToDomain(rawEntity); } + public async findUsers(payload: FindUsersPayload): Promise { + const { page, pageSize } = payload; + + let rawEntities: UserRawEntity[]; + + try { + rawEntities = await this.sqliteDatabaseClient(this.userTable.name) + .select('*') + .offset((page - 1) * pageSize) + .limit(pageSize); + } catch (error) { + this.loggerService.error({ + message: 'Error while finding Users.', + error, + }); + + throw new RepositoryError({ + entity: 'Users', + operation: 'find', + }); + } + + return rawEntities.map((rawEntity) => this.userMapper.mapToDomain(rawEntity)); + } + + public async countUsers(): Promise { + try { + const result = await this.sqliteDatabaseClient(this.userTable.name).count('* as count').first(); + + if (!result) { + throw new RepositoryError({ + entity: 'Users', + operation: 'count', + }); + } + + return Number((result as any).count); + } catch (error) { + this.loggerService.error({ + message: 'Error while counting Users.', + error, + }); + + throw new RepositoryError({ + entity: 'Users', + operation: 'count', + }); + } + } + public async findUserBuckets(payload: FindUserBucketsPayload): Promise { const { userId } = payload; diff --git a/apps/backend/src/modules/userModule/symbols.ts b/apps/backend/src/modules/userModule/symbols.ts index ee7ddd6..4206537 100644 --- a/apps/backend/src/modules/userModule/symbols.ts +++ b/apps/backend/src/modules/userModule/symbols.ts @@ -10,6 +10,7 @@ export const symbols = { grantBucketAccessCommandHandler: Symbol('grantBucketAccessCommandHandler'), revokeBucketAccessCommandHandler: Symbol('revokeBucketAccessCommandHandler'), findUserQueryHandler: Symbol('findUserQueryHandler'), + findUsersQueryHandler: Symbol('findUsersQueryHandler'), findUserBucketsQueryHandler: Symbol('findUserBucketsQueryHandler'), loginUserCommandHandler: Symbol('loginUserCommandHandler'), refreshUserTokensCommandHandler: Symbol('refreshUserTokensCommandHandler'), diff --git a/apps/backend/src/modules/userModule/userModule.ts b/apps/backend/src/modules/userModule/userModule.ts index baf9587..6634a43 100644 --- a/apps/backend/src/modules/userModule/userModule.ts +++ b/apps/backend/src/modules/userModule/userModule.ts @@ -18,6 +18,8 @@ import { type FindUserBucketsQueryHandler } from './application/queryHandlers/fi import { FindUserBucketsQueryHandlerImpl } from './application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandlerImpl.js'; import { type FindUserQueryHandler } from './application/queryHandlers/findUserQueryHandler/findUserQueryHandler.js'; import { FindUserQueryHandlerImpl } from './application/queryHandlers/findUserQueryHandler/findUserQueryHandlerImpl.js'; +import { type FindUsersQueryHandler } from './application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.js'; +import { FindUsersQueryHandlerImpl } from './application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.js'; import { type HashService } from './application/services/hashService/hashService.js'; import { HashServiceImpl } from './application/services/hashService/hashServiceImpl.js'; import { type PasswordValidationService } from './application/services/passwordValidationService/passwordValidationService.js'; @@ -168,6 +170,11 @@ export class UserModule implements DependencyInjectionModule { () => new FindUserQueryHandlerImpl(container.get(symbols.userRepository)), ); + container.bind( + symbols.findUsersQueryHandler, + () => new FindUsersQueryHandlerImpl(container.get(symbols.userRepository)), + ); + container.bind( symbols.findUserBucketsQueryHandler, () => new FindUserBucketsQueryHandlerImpl(container.get(symbols.userRepository)), @@ -192,6 +199,7 @@ export class UserModule implements DependencyInjectionModule { container.get(symbols.createUserCommandHandler), container.get(symbols.deleteUserCommandHandler), container.get(symbols.findUserQueryHandler), + container.get(symbols.findUsersQueryHandler), container.get(symbols.grantBucketAccessCommandHandler), container.get(symbols.revokeBucketAccessCommandHandler), container.get(authSymbols.accessControlService), diff --git a/common/contracts/src/index.ts b/common/contracts/src/index.ts index 0435c7b..7697ce1 100644 --- a/common/contracts/src/index.ts +++ b/common/contracts/src/index.ts @@ -18,6 +18,8 @@ export * from './schemas/user/grantBucketAccess.js'; export * from './schemas/user/revokeBucketAccess.js'; +export * from './schemas/user/findUsers.js'; + export * from './schemas/resource/findResources.js'; export * from './schemas/resource/deleteResource.js'; diff --git a/common/contracts/src/schemas/user/findUsers.ts b/common/contracts/src/schemas/user/findUsers.ts new file mode 100644 index 0000000..0fd5c0b --- /dev/null +++ b/common/contracts/src/schemas/user/findUsers.ts @@ -0,0 +1,19 @@ +import { type UserRole } from './userRole.js'; + +export interface FindUsersQueryParams { + readonly page?: number; + readonly pageSize?: number; +} + +export interface FindUsersResponseBody { + readonly data: { + readonly id: string; + readonly email: string; + readonly role: UserRole; + }[]; + readonly metadata: { + readonly page: number; + readonly pageSize: number; + readonly totalPages: number; + }; +}