diff --git a/apps/server/src/modules/account/controller/account.controller.ts b/apps/server/src/modules/account/controller/account.controller.ts index 23c07326d82..92b8333fe03 100644 --- a/apps/server/src/modules/account/controller/account.controller.ts +++ b/apps/server/src/modules/account/controller/account.controller.ts @@ -13,6 +13,7 @@ import { PatchMyAccountParams, PatchMyPasswordParams, } from './dto'; +import { AccountResponseMapper } from './mapper/account-response.mapper'; @ApiTags('Account') @Authenticate('jwt') @@ -32,9 +33,9 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Query() query: AccountSearchQueryParams ): Promise { - return this.accountUc.searchAccounts(currentUser, query); + const searchResult = await this.accountUc.searchAccounts(currentUser, query); - // TODO: mapping from domain to api dto should be a responsability of the controller (also every other function here) + return AccountResponseMapper.mapToAccountSearchListResponse(searchResult); } @Get(':id') @@ -47,7 +48,8 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Param() params: AccountByIdParams ): Promise { - return this.accountUc.findAccountById(currentUser, params); + const dto = await this.accountUc.findAccountById(currentUser, params); + return AccountResponseMapper.mapToAccountResponse(dto); } // IMPORTANT!!! @@ -74,7 +76,8 @@ export class AccountController { @Param() params: AccountByIdParams, @Body() body: AccountByIdBodyParams ): Promise { - return this.accountUc.updateAccountById(currentUser, params, body); + const dto = await this.accountUc.updateAccountById(currentUser, params, body); + return AccountResponseMapper.mapToAccountResponse(dto); } @Delete(':id') @@ -87,7 +90,8 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Param() params: AccountByIdParams ): Promise { - return this.accountUc.deleteAccountById(currentUser, params); + const dto = await this.accountUc.deleteAccountById(currentUser, params); + return AccountResponseMapper.mapToAccountResponse(dto); } @Patch('me/password') diff --git a/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts index 94e2d9a2d1d..fdb9dba5809 100644 --- a/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts +++ b/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts @@ -1,9 +1,10 @@ import { accountDtoFactory } from '@shared/testing'; import { Account } from '../../domain'; import { AccountResponseMapper } from './account-response.mapper'; +import { ResolvedSearchListAccountDto } from '../../uc/dto/resolved-account.dto'; describe('AccountResponseMapper', () => { - describe('mapToResponse', () => { + describe('mapToAccountResponse', () => { describe('When mapping Account to AccountResponse', () => { const setup = () => { const testDto: Account = accountDtoFactory.buildWithId(); @@ -13,7 +14,7 @@ describe('AccountResponseMapper', () => { it('should map all fields', () => { const testDto = setup(); - const ret = AccountResponseMapper.mapToResponse(testDto); + const ret = AccountResponseMapper.mapToAccountResponse(testDto); expect(ret.id).toBe(testDto.id); expect(ret.userId).toBe(testDto.userId?.toString()); @@ -22,4 +23,50 @@ describe('AccountResponseMapper', () => { }); }); }); + + describe('mapToAccountResponses', () => { + describe('When mapping Account[] to AccountResponse[]', () => { + const setup = () => { + const testDto: Account[] = accountDtoFactory.buildListWithId(3); + return testDto; + }; + + it('should map all fields', () => { + const testDto = setup(); + + const ret = AccountResponseMapper.mapToAccountResponses(testDto); + + expect(ret.length).toBe(testDto.length); + expect(ret[0].id).toBe(testDto[0].id); + expect(ret[0].userId).toBe(testDto[0].userId?.toString()); + expect(ret[0].activated).toBe(testDto[0].activated); + expect(ret[0].username).toBe(testDto[0].username); + }); + }); + }); + + describe('mapToAccountSearchListResponse', () => { + describe('When mapping ResolvedSearchListAccountDto to AccountSearchListResponse', () => { + const setup = () => { + const testDto = accountDtoFactory.buildWithId(); + const searchListDto = new ResolvedSearchListAccountDto([testDto], 1, 0, 1); + return searchListDto; + }; + + it('should map all fields', () => { + const searchListDto = setup(); + + const ret = AccountResponseMapper.mapToAccountSearchListResponse(searchListDto); + + expect(ret.data.length).toBe(searchListDto.data.length); + expect(ret.data[0].id).toBe(searchListDto.data[0].id); + expect(ret.data[0].userId).toBe(searchListDto.data[0].userId?.toString()); + expect(ret.data[0].activated).toBe(searchListDto.data[0].activated); + expect(ret.data[0].username).toBe(searchListDto.data[0].username); + expect(ret.total).toBe(searchListDto.total); + expect(ret.skip).toBe(searchListDto.skip); + expect(ret.limit).toBe(searchListDto.limit); + }); + }); + }); }); diff --git a/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts b/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts index 61e6dbbd57a..000847506de 100644 --- a/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts +++ b/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts @@ -1,14 +1,29 @@ -import { Account } from '@src/modules/account/domain/account'; -import { AccountResponse } from '../dto'; +import { AccountResponse, AccountSearchListResponse } from '../dto'; +import { ResolvedAccountDto, ResolvedSearchListAccountDto } from '../../uc/dto/resolved-account.dto'; export class AccountResponseMapper { - static mapToResponse(account: Account): AccountResponse { + static mapToAccountResponse(resolvedAccount: ResolvedAccountDto): AccountResponse { return new AccountResponse({ - id: account.id ?? '', - userId: account.userId, - activated: account.activated, - username: account.username, - updatedAt: account.updatedAt, + id: resolvedAccount.id as string, + userId: resolvedAccount.userId, + activated: resolvedAccount.activated, + username: resolvedAccount.username ?? '', + updatedAt: resolvedAccount.updatedAt, }); } + + static mapToAccountResponses(resolvedAccounts: ResolvedAccountDto[]): AccountResponse[] { + return resolvedAccounts.map((resolvedAccount) => AccountResponseMapper.mapToAccountResponse(resolvedAccount)); + } + + static mapToAccountSearchListResponse( + resolvedSearchListAccountDto: ResolvedSearchListAccountDto + ): AccountSearchListResponse { + return new AccountSearchListResponse( + AccountResponseMapper.mapToAccountResponses(resolvedSearchListAccountDto.data), + resolvedSearchListAccountDto.total, + resolvedSearchListAccountDto.skip, + resolvedSearchListAccountDto.limit + ); + } } diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index eb9691a83e7..6e9b8b628f0 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -16,7 +16,6 @@ import { AccountByIdBodyParams, AccountByIdParams, AccountResponse, - AccountSearchListResponse, AccountSearchQueryParams, AccountSearchType, } from '../controller/dto'; @@ -24,6 +23,7 @@ import { Account } from '../domain'; import { AccountEntityToDoMapper } from '../repo/mapper'; import { AccountValidationService } from '../services/account.validation.service'; import { AccountUc } from './account.uc'; +import { ResolvedSearchListAccountDto } from './dto/resolved-account.dto'; describe('AccountUc', () => { let module: TestingModule; @@ -1059,7 +1059,7 @@ describe('AccountUc', () => { { userId: mockSuperheroUser.id } as ICurrentUser, { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams ); - const expected = new AccountSearchListResponse( + const expected = new ResolvedSearchListAccountDto( [ new AccountResponse({ id: mockStudentAccount.id ?? '', @@ -1073,7 +1073,7 @@ describe('AccountUc', () => { 0, 1 ); - expect(accounts).toStrictEqual(expected); + expect(accounts).toStrictEqual(expected); }); }); @@ -1110,8 +1110,8 @@ describe('AccountUc', () => { { userId: mockSuperheroUser.id } as ICurrentUser, { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams ); - const expected = new AccountSearchListResponse([], 0, 0, 0); - expect(accounts).toStrictEqual(expected); + const expected = new ResolvedSearchListAccountDto([], 0, 0, 0); + expect(accounts).toStrictEqual(expected); }); }); describe('When search type is username', () => { diff --git a/apps/server/src/modules/account/uc/account.uc.ts b/apps/server/src/modules/account/uc/account.uc.ts index 1748c4a1d2b..b7949089bab 100644 --- a/apps/server/src/modules/account/uc/account.uc.ts +++ b/apps/server/src/modules/account/uc/account.uc.ts @@ -11,7 +11,7 @@ import { Permission, RoleName } from '@shared/domain/interface'; import { PermissionService } from '@shared/domain/service'; import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; -// TODO: module internals should be imported with relative paths +// TODO: module internals should be imported with relative paths, AccountEntity should be moved to this module import { ICurrentUser } from '@modules/authentication'; import { AccountService } from '..'; @@ -19,8 +19,6 @@ import { AccountConfig } from '../account-config'; import { AccountByIdBodyParams, AccountByIdParams, - AccountResponse, - AccountSearchListResponse, AccountSearchQueryParams, AccountSearchType, PatchMyAccountParams, @@ -28,6 +26,8 @@ import { import { AccountResponseMapper } from '../controller/mapper/account-response.mapper'; import { Account } from '../domain'; import { AccountValidationService } from '../services/account.validation.service'; +import { ResolvedAccountDto, ResolvedSearchListAccountDto } from './dto/resolved-account.dto'; +import { AccountUcMapper } from './mapper/account-uc.mapper'; type UserPreferences = { // first login completed @@ -59,7 +59,10 @@ export class AccountUc { * @throws {ValidationError} * @throws {ForbiddenOperationError} */ - async searchAccounts(currentUser: ICurrentUser, query: AccountSearchQueryParams): Promise { + async searchAccounts( + currentUser: ICurrentUser, + query: AccountSearchQueryParams + ): Promise { const skip = query.skip ?? 0; const limit = query.limit ?? 10; const executingUser = await this.userRepo.findById(currentUser.userId, true); @@ -70,9 +73,9 @@ export class AccountUc { if (!(await this.isSuperhero(currentUser))) { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } - const [accounts, total] = await this.accountService.searchByUsernamePartialMatch(query.value, skip, limit); - const accountList = accounts.map((tempAccount) => AccountResponseMapper.mapToResponse(tempAccount)); - return new AccountSearchListResponse(accountList, total, skip, limit); + const searchDoCounted = await this.accountService.searchByUsernamePartialMatch(query.value, skip, limit); + const [searchResult, total] = AccountUcMapper.mapSearchResult(searchDoCounted); + return new ResolvedSearchListAccountDto(searchResult, total, skip, limit); } if (query.type === AccountSearchType.USER_ID) { const targetUser = await this.userRepo.findById(query.value, true); @@ -84,10 +87,10 @@ export class AccountUc { const account = await this.accountService.findByUserId(query.value); if (account) { // HINT: skip and limit should be from the query - return new AccountSearchListResponse([AccountResponseMapper.mapToResponse(account)], 1, 0, 1); + return new ResolvedSearchListAccountDto([AccountResponseMapper.mapToAccountResponse(account)], 1, 0, 1); } // HINT: skip and limit should be from the query - return new AccountSearchListResponse([], 0, 0, 0); + return new ResolvedSearchListAccountDto([], 0, 0, 0); } throw new ValidationError('Invalid search type.'); @@ -101,12 +104,12 @@ export class AccountUc { * @throws {ForbiddenOperationError} * @throws {EntityNotFoundError} */ - async findAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { + async findAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { if (!(await this.isSuperhero(currentUser))) { throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); } const account = await this.accountService.findById(params.id); - return AccountResponseMapper.mapToResponse(account); // TODO: mapping should be done in controller + return AccountUcMapper.mapToResolvedAccountDto(account); } async saveAccount(dto: Account): Promise { @@ -126,7 +129,7 @@ export class AccountUc { currentUser: ICurrentUser, params: AccountByIdParams, body: AccountByIdBodyParams - ): Promise { + ): Promise { const executingUser = await this.userRepo.findById(currentUser.userId, true); const targetAccount = await this.accountService.findById(params.id); @@ -175,9 +178,8 @@ export class AccountUc { throw new EntityNotFoundError(AccountEntity.name); } } - // TODO: mapping from domain to api dto should be a responsability of the controller - return AccountResponseMapper.mapToResponse(targetAccount); + return AccountUcMapper.mapToResolvedAccountDto(targetAccount); } /** @@ -188,13 +190,13 @@ export class AccountUc { * @throws {ForbiddenOperationError} * @throws {EntityNotFoundError} */ - async deleteAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { + async deleteAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { if (!(await this.isSuperhero(currentUser))) { throw new ForbiddenOperationError('Current user is not authorized to delete an account.'); } const account: Account = await this.accountService.findById(params.id); await this.accountService.delete(account.id ?? ''); - return AccountResponseMapper.mapToResponse(account); + return AccountUcMapper.mapToResolvedAccountDto(account); } /** diff --git a/apps/server/src/modules/account/uc/dto/resolved-account.dto.ts b/apps/server/src/modules/account/uc/dto/resolved-account.dto.ts new file mode 100644 index 00000000000..b88de001cc3 --- /dev/null +++ b/apps/server/src/modules/account/uc/dto/resolved-account.dto.ts @@ -0,0 +1,91 @@ +import { EntityId } from '@shared/domain/types'; +import { IsBoolean, IsDate, IsMongoId, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; +import { PrivacyProtect } from '@shared/controller'; +import { passwordPattern } from '../../controller/dto/password-pattern'; + +export class ResolvedAccountDto { + @IsOptional() + @IsMongoId() + readonly id?: EntityId; + + @IsOptional() + @IsDate() + readonly createdAt?: Date; + + @IsOptional() + @IsDate() + readonly updatedAt?: Date; + + @IsString() + @IsNotEmpty() + username: string; + + @PrivacyProtect() + @IsOptional() + @Matches(passwordPattern) + password?: string; + + @IsOptional() + @IsString() + token?: string; + + @IsOptional() + @IsString() + credentialHash?: string; + + @IsOptional() + @IsMongoId() + userId?: EntityId; + + @IsOptional() + @IsMongoId() + systemId?: EntityId; + + @IsOptional() + @IsDate() + lasttriedFailedLogin?: Date; + + @IsOptional() + @IsDate() + expiresAt?: Date; + + @IsOptional() + @IsBoolean() + activated?: boolean; + + @IsOptional() + idmReferenceId?: string; + + constructor(account: ResolvedAccountDto) { + this.id = account.id; + this.username = account.username; + this.userId = account.userId; + this.activated = account.activated; + this.updatedAt = account.updatedAt; + this.createdAt = account.createdAt; + this.systemId = account.systemId; + this.password = account.password; + this.token = account.token; + this.credentialHash = account.credentialHash; + this.lasttriedFailedLogin = account.lasttriedFailedLogin; + this.expiresAt = account.expiresAt; + this.idmReferenceId = account.idmReferenceId; + } +} + +export class ResolvedSearchListAccountDto { + data: ResolvedAccountDto[]; + + total: number; + + skip?: number; + + limit?: number; + + constructor(data: ResolvedAccountDto[], total: number, skip?: number, limit?: number) { + this.data = data; + this.total = total; + this.skip = skip; + this.limit = limit; + } +} diff --git a/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts new file mode 100644 index 00000000000..46f10f720fb --- /dev/null +++ b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts @@ -0,0 +1,68 @@ +import { Counted } from '@shared/domain/types'; +import { accountDtoFactory } from '@shared/testing'; +import { Account } from '../../domain/account'; +import { AccountUcMapper } from './account-uc.mapper'; + +describe('AccountUcMapper', () => { + describe('mapToResolvedAccountDto', () => { + describe('When mapping Account to ResolvedAccountDto', () => { + const setup = () => { + const testDos: Account = accountDtoFactory.buildWithId(); + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapToResolvedAccountDto(testDos); + + expect(ret.id).toBe(testDos.id); + expect(ret.userId).toBe(testDos.userId?.toString()); + expect(ret.activated).toBe(testDos.activated); + expect(ret.username).toBe(testDos.username); + }); + }); + }); + + describe('mapSearchResult', () => { + describe('When mapping Counted to Counted', () => { + const setup = () => { + const testDos: Counted = [accountDtoFactory.buildListWithId(3), 3]; + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapSearchResult(testDos); + + expect(ret[0].length).toBe(testDos[0].length); + expect(ret[0][0].id).toBe(testDos[0][0].id); + expect(ret[0][0].userId).toBe(testDos[0][0].userId?.toString()); + expect(ret[0][0].activated).toBe(testDos[0][0].activated); + expect(ret[0][0].username).toBe(testDos[0][0].username); + }); + }); + }); + + describe('mapAccountsToDo', () => { + describe('When mapping Account[] to ResolvedAccountDto[]', () => { + const setup = () => { + const testDos: Account[] = accountDtoFactory.buildListWithId(3); + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapAccountsToDo(testDos); + + expect(ret.length).toBe(testDos.length); + expect(ret[0].id).toBe(testDos[0].id); + expect(ret[0].userId).toBe(testDos[0].userId?.toString()); + expect(ret[0].activated).toBe(testDos[0].activated); + expect(ret[0].username).toBe(testDos[0].username); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts new file mode 100644 index 00000000000..a9d6b0fd61f --- /dev/null +++ b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts @@ -0,0 +1,26 @@ +import { Counted } from '@shared/domain/types'; +import { Account } from '../../domain'; +import { ResolvedAccountDto } from '../dto/resolved-account.dto'; + +export class AccountUcMapper { + static mapToResolvedAccountDto(account: Account): ResolvedAccountDto { + return new ResolvedAccountDto({ + ...account, + id: account.id, + username: account.username, + userId: account.userId, + activated: account.activated, + updatedAt: account.updatedAt, + }); + } + + static mapSearchResult(accountEntities: Counted): Counted { + const foundAccounts = accountEntities[0]; + const accountDos: ResolvedAccountDto[] = AccountUcMapper.mapAccountsToDo(foundAccounts); + return [accountDos, accountEntities[1]]; + } + + static mapAccountsToDo(accounts: Account[]): ResolvedAccountDto[] { + return accounts.map((account) => AccountUcMapper.mapToResolvedAccountDto(account)); + } +}