diff --git a/dictation_server/src/common/validators/IsUnique.validator.ts b/dictation_server/src/common/validators/IsUnique.validator.ts new file mode 100644 index 0000000..21ad6f0 --- /dev/null +++ b/dictation_server/src/common/validators/IsUnique.validator.ts @@ -0,0 +1,36 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + ValidationOptions, + registerDecorator, +} from 'class-validator'; + +@ValidatorConstraint() +export class IsUniqueArray implements ValidatorConstraintInterface { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validate(arr: any[], args: ValidationArguments) { + return arr.length === new Set(arr).size; + } + + defaultMessage(args: ValidationArguments) { + return `${args.property} should be an array of unique values`; + } +} +/** + * 配列内の値がすべてユニークであるかをチェックする + * @param [validationOptions] + * @returns + */ +export function IsUnique(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isUnique', + target: object.constructor, + propertyName: propertyName, + constraints: [], + options: validationOptions, + validator: IsUniqueArray, + }); + }; +} diff --git a/dictation_server/src/common/validators/admin.validator.ts b/dictation_server/src/common/validators/admin.validator.ts index 296ba7e..7a023cd 100644 --- a/dictation_server/src/common/validators/admin.validator.ts +++ b/dictation_server/src/common/validators/admin.validator.ts @@ -1,5 +1,6 @@ import { registerDecorator, ValidationOptions } from 'class-validator'; +// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する export const IsAdminPasswordvalid = (validationOptions?: ValidationOptions) => { return (object: any, propertyName: string) => { registerDecorator({ diff --git a/dictation_server/src/common/validators/assignees.validator.ts b/dictation_server/src/common/validators/assignees.validator.ts index fb760ec..5f9b100 100644 --- a/dictation_server/src/common/validators/assignees.validator.ts +++ b/dictation_server/src/common/validators/assignees.validator.ts @@ -4,6 +4,7 @@ import { ValidationArguments, } from 'class-validator'; import { Assignee } from '../../features/tasks/types/types'; +// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する /** * Validations options * @param [validationOptions] diff --git a/dictation_server/src/common/validators/encryptionPassword.validator.ts b/dictation_server/src/common/validators/encryptionPassword.validator.ts index 7e47547..8492628 100644 --- a/dictation_server/src/common/validators/encryptionPassword.validator.ts +++ b/dictation_server/src/common/validators/encryptionPassword.validator.ts @@ -4,7 +4,7 @@ import { ValidationOptions, } from 'class-validator'; import { SignupRequest } from '../../features/users/types/types'; - +// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する export const IsPasswordvalid = (validationOptions?: ValidationOptions) => { return (object: any, propertyName: string) => { registerDecorator({ @@ -35,7 +35,7 @@ export const IsPasswordvalid = (validationOptions?: ValidationOptions) => { }); }; }; - +// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する export const IsEncryptionPasswordPresent = ( validationOptions?: ValidationOptions, ) => { diff --git a/dictation_server/src/common/validators/roleAuthor.validator.ts b/dictation_server/src/common/validators/roleAuthor.validator.ts index c68964f..67efa15 100644 --- a/dictation_server/src/common/validators/roleAuthor.validator.ts +++ b/dictation_server/src/common/validators/roleAuthor.validator.ts @@ -9,6 +9,7 @@ import { } from '../../features/users/types/types'; import { USER_ROLES } from '../../constants'; +// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する export const IsRoleAuthorDataValid = < T extends SignupRequest | PostUpdateUserRequest, >( diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 0d207c1..c0300a0 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -326,10 +326,17 @@ export class AccountsController { @Req() req: Request, @Body() body: CreateTypistGroupRequest, ): Promise { + const { typistGroupName, typistIds } = body; // アクセストークン取得 const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; - + const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + const context = makeContext(userId); + await this.accountService.createTypistGroup( + context, + userId, + typistGroupName, + typistIds, + ); return {}; } diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 1879e68..924f9e1 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -23,6 +23,9 @@ import { getUsers, getSortCriteria, createAccountAndAdminUser, + createAccountAndAdminUserForTier5, + getTypistGroup, + getTypistGroupMember, } from './test/utility'; import { DataSource } from 'typeorm'; import { makeTestingModule } from '../../common/test/modules'; @@ -38,6 +41,7 @@ import { } from '../../common/test/overrides'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; +import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; describe('createAccount', () => { let source: DataSource = null; @@ -2288,3 +2292,232 @@ describe('getDealers', () => { }); }); }); + +describe('createTypistGroup', () => { + let source: DataSource = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + await source.destroy(); + source = null; + }); + it('TypistGroupを作成できる', async () => { + const module = await makeTestingModule(source); + const adminExternalId = 'admin-external-id'; + // 第五階層のアカウント作成 + const { accountId } = await createAccountAndAdminUserForTier5( + source, + adminExternalId, + ); + // 作成したアカウントにユーザーを3名追加する + const typiptUserExternalIds = [ + 'typist-user-external-id1', + 'typist-user-external-id2', + 'typist-user-external-id3', + ]; + const userIds: number[] = []; + for (const typiptUserExternalId of typiptUserExternalIds) { + const { userId } = await createUser( + source, + accountId, + typiptUserExternalId, + 'typist', + ); + userIds.push(userId); + } + //作成したデータを確認 + { + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + expect(accounts[0].id).toBe(accountId); + const users = await getUsers(source); + expect(users.length).toBe(4); + } + const service = module.get(AccountsService); + const typistGroupName = 'typist-group-name'; + const typistUserIds = userIds; + const context = makeContext(adminExternalId); + await service.createTypistGroup( + context, + adminExternalId, + typistGroupName, + typistUserIds, + ); + //実行結果を確認 + { + const typistGroups = await getTypistGroup(source, accountId); + expect(typistGroups.length).toBe(1); + expect(typistGroups[0].name).toBe(typistGroupName); + + const typistGroupUsers = await getTypistGroupMember( + source, + typistGroups[0].id, + ); + expect(typistGroupUsers.length).toBe(3); + expect(typistGroupUsers.map((user) => user.user_id)).toEqual(userIds); + } + }); + + it('typistIdsにRole:typist以外のユーザーが含まれていた場合、400エラーを返却する', async () => { + const module = await makeTestingModule(source); + const adminExternalId = 'admin-external-id'; + // 第五階層のアカウント作成 + const { accountId } = await createAccountAndAdminUserForTier5( + source, + adminExternalId, + ); + // 作成したアカウントにユーザーを3名追加する + const typiptUserExternalIds = [ + 'typist-user-external-id1', + 'typist-user-external-id2', + 'typist-user-external-id3', + ]; + const userIds: number[] = []; + for (const typiptUserExternalId of typiptUserExternalIds) { + const { userId } = await createUser( + source, + accountId, + typiptUserExternalId, + typiptUserExternalId === 'typist-user-external-id3' ? 'none' : 'typist', //typist-user-external-id3のみRole:none + ); + userIds.push(userId); + } + //作成したデータを確認 + { + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + expect(accounts[0].id).toBe(accountId); + const users = await getUsers(source); + expect(users.length).toBe(4); + expect(users.filter((user) => user.role === 'typist').length).toBe(2); + } + const service = module.get(AccountsService); + const typistGroupName = 'typist-group-name'; + const typistUserIds = userIds; + const context = makeContext(adminExternalId); + await expect( + service.createTypistGroup( + context, + adminExternalId, + typistGroupName, + typistUserIds, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010204'), HttpStatus.BAD_REQUEST), + ); + }); + it('typistIdsに存在しないユーザーが含まれていた場合、400エラーを返却する', async () => { + const module = await makeTestingModule(source); + const adminExternalId = 'admin-external-id'; + // 第五階層のアカウント作成 + const { accountId } = await createAccountAndAdminUserForTier5( + source, + adminExternalId, + ); + // 作成したアカウントにユーザーを3名追加する + const typiptUserExternalIds = [ + 'typist-user-external-id1', + 'typist-user-external-id2', + 'typist-user-external-id3', + ]; + const userIds: number[] = []; + for (const typiptUserExternalId of typiptUserExternalIds) { + const { userId } = await createUser( + source, + accountId, + typiptUserExternalId, + 'typist', + ); + userIds.push(userId); + } + //作成したデータを確認 + { + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + expect(accounts[0].id).toBe(accountId); + const users = await getUsers(source); + expect(users.length).toBe(4); + } + const service = module.get(AccountsService); + const typistGroupName = 'typist-group-name'; + const typistUserIds = [...userIds, 9999]; //存在しないユーザーIDを追加 + const context = makeContext(adminExternalId); + await expect( + service.createTypistGroup( + context, + adminExternalId, + typistGroupName, + typistUserIds, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010204'), HttpStatus.BAD_REQUEST), + ); + }); + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + const module = await makeTestingModule(source); + const adminExternalId = 'admin-external-id'; + // 第五階層のアカウント作成 + const { accountId } = await createAccountAndAdminUserForTier5( + source, + adminExternalId, + ); + // 作成したアカウントにユーザーを3名追加する + const typiptUserExternalIds = [ + 'typist-user-external-id1', + 'typist-user-external-id2', + 'typist-user-external-id3', + ]; + const userIds: number[] = []; + for (const typiptUserExternalId of typiptUserExternalIds) { + const { userId } = await createUser( + source, + accountId, + typiptUserExternalId, + 'typist', + ); + userIds.push(userId); + } + //作成したデータを確認 + { + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + expect(accounts[0].id).toBe(accountId); + const users = await getUsers(source); + expect(users.length).toBe(4); + } + const service = module.get(AccountsService); + const typistGroupName = 'typist-group-name'; + const typistUserIds = userIds; + const context = makeContext(adminExternalId); + //DBアクセスに失敗するようにする + const typistGroupService = module.get( + UserGroupsRepositoryService, + ); + typistGroupService.createTypistGroup = jest + .fn() + .mockRejectedValue('DB failed'); + + await expect( + service.createTypistGroup( + context, + adminExternalId, + typistGroupName, + typistUserIds, + ), + ).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); +}); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 7c1cc7b..2f9caab 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -40,6 +40,7 @@ import { OrderNotFoundError, } from '../../repositories/licenses/errors/types'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; +import { TypistIdInvalidError } from '../../repositories/user_groups/errors/types'; @Injectable() export class AccountsService { @@ -870,4 +871,57 @@ export class AccountsService { this.logger.log(`[OUT] ${this.getDealers.name}`); } } + /** + * タイピストグループを作成する + * @param context + * @param externalId + * @param typistGroupName + * @param typistIds + * @returns createTypistGroupResponse + **/ + async createTypistGroup( + context: Context, + externalId: string, + typistGroupName: string, + typistIds: number[], + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.createTypistGroup.name} | params: { ` + + `externalId: ${externalId}, ` + + `typistGroupName: ${typistGroupName}, ` + + `typistIds: ${typistIds} };`, + ); + try { + // 外部IDをもとにユーザー情報を取得する + const { account_id } = await this.usersRepository.findUserByExternalId( + externalId, + ); + // API実行ユーザーのアカウントIDでタイピストグループを作成し、タイピストグループとtypistIdsのユーザーを紐付ける + await this.userGroupsRepository.createTypistGroup( + typistGroupName, + typistIds, + account_id, + ); + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case TypistIdInvalidError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/dictation_server/src/features/accounts/test/utility.ts b/dictation_server/src/features/accounts/test/utility.ts index 42d5027..2da6207 100644 --- a/dictation_server/src/features/accounts/test/utility.ts +++ b/dictation_server/src/features/accounts/test/utility.ts @@ -6,6 +6,8 @@ import { LicenseOrder, } from '../../../repositories/licenses/entity/license.entity'; import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity'; +import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity'; +import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; // TODO: [PBI 2379] 他のUtilityからコピペしてきたもの。後日整理される前提。 export const createAccountAndAdminUser = async ( @@ -269,3 +271,93 @@ export const createUser = async ( const user = identifiers.pop() as User; return { userId: user.id, externalId: external_id, authorId: author_id }; }; + +// TODO: [PBI 2379] 第五階層のアカウント・管理者を作成する。後日整理される前提。 +export const createAccountAndAdminUserForTier5 = async ( + datasource: DataSource, + adminExternalId: string, +): Promise<{ + accountId: number; + adminId: number; + role: string; + tier: number; +}> => { + const { identifiers: account_idf } = await datasource + .getRepository(Account) + .insert({ + tier: 5, + country: 'JP', + delegation_permission: false, + locked: false, + company_name: 'test inc.', + verified: true, + deleted_at: '', + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const account = account_idf.pop() as Account; + + const { identifiers: user_idf } = await datasource + .getRepository(User) + .insert({ + account_id: account.id, + external_id: adminExternalId, + role: 'none', + accepted_terms_version: '1.0', + email_verified: true, + auto_renew: true, + license_alert: true, + notification: true, + encryption: true, + encryption_password: 'password', + prompt: true, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const user = user_idf.pop() as User; + + // Accountの管理者を設定する + await datasource.getRepository(Account).update( + { id: user.account_id }, + { + primary_admin_user_id: user.id, + }, + ); + + const accountResult = await getAccount(datasource, account.id); + const userResult = await getUser(datasource, user.id); + + return { + accountId: account.id, + adminId: user.id, + role: userResult.role, + tier: accountResult.tier, + }; +}; + +// タイピストグループを取得する +export const getTypistGroup = async ( + datasource: DataSource, + accountId: number, +): Promise => { + return await datasource.getRepository(UserGroup).find({ + where: { + account_id: accountId, + }, + }); +}; +// タイピストグループメンバーを取得する +export const getTypistGroupMember = async ( + datasource: DataSource, + userGroupId: number, +): Promise => { + return await datasource.getRepository(UserGroupMember).find({ + where: { + user_group_id: userGroupId, + }, + }); +}; diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 7b7508c..f87ec15 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -11,6 +11,7 @@ import { IsArray, } from 'class-validator'; import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator'; +import { IsUnique } from '../../../common/validators/IsUnique.validator'; import { Type } from 'class-transformer'; export class CreateAccountRequest { @@ -155,6 +156,9 @@ export class CreateTypistGroupRequest { @ApiProperty({ minItems: 1, isArray: true, type: 'integer' }) @ArrayMinSize(1) @IsArray() + @IsInt({ each: true }) + @Min(0, { each: true }) + @IsUnique() typistIds: number[]; } diff --git a/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts b/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts index 2745334..42e14fa 100644 --- a/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts +++ b/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; import { UserGroupMember } from './user_group_member.entity'; @Entity({ name: 'user_group' }) @@ -15,16 +22,16 @@ export class UserGroup { @Column({ nullable: true }) deleted_at?: Date; - @Column() - created_by: string; - @Column({ nullable: true }) + created_by?: string; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at?: Date; - @Column() - updated_by: string; - @Column({ nullable: true }) + updated_by?: string; + + @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at?: Date; @OneToMany( diff --git a/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts b/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts index 9256d0b..93f2484 100644 --- a/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts +++ b/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts @@ -5,6 +5,8 @@ import { PrimaryGeneratedColumn, JoinColumn, ManyToOne, + CreateDateColumn, + UpdateDateColumn, } from 'typeorm'; import { UserGroup } from './user_group.entity'; @@ -22,16 +24,16 @@ export class UserGroupMember { @Column({ nullable: true }) deleted_at?: Date; - @Column() - created_by: string; - @Column({ nullable: true }) + created_by?: string; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at?: Date; - @Column() - updated_by: string; - @Column({ nullable: true }) + updated_by?: string; + + @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at?: Date; @ManyToOne(() => User, (user) => user.id) diff --git a/dictation_server/src/repositories/user_groups/errors/types.ts b/dictation_server/src/repositories/user_groups/errors/types.ts new file mode 100644 index 0000000..18303df --- /dev/null +++ b/dictation_server/src/repositories/user_groups/errors/types.ts @@ -0,0 +1,2 @@ +// typistIdが不正な場合のエラー +export class TypistIdInvalidError extends Error {} diff --git a/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts b/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts index 770ecaa..12a66af 100644 --- a/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts +++ b/dictation_server/src/repositories/user_groups/user_groups.repository.service.ts @@ -2,6 +2,9 @@ import { Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; import { UserGroup } from './entity/user_group.entity'; import { UserGroupMember } from './entity/user_group_member.entity'; +import { User } from '../users/entity/user.entity'; +import { TypistIdInvalidError } from './errors/types'; +import { USER_ROLES } from '../../constants'; @Injectable() export class UserGroupsRepositoryService { @@ -43,4 +46,53 @@ export class UserGroupsRepositoryService { return groupMembers; }); } + /** + * 指定したアカウントIDでタイピストグループを作成し、そのタイピストグループとtypistIdsのユーザーを紐付ける + * @param accountId + * @param name + * @param typistIds + * @returns createdTypistGroup + */ + async createTypistGroup( + name: string, + typistIds: number[], + accountId: number, + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const userGroupRepo = entityManager.getRepository(UserGroup); + const userGroupMemberRepo = entityManager.getRepository(UserGroupMember); + // typistIdsのidを持つユーザーが、account_idのアカウントに所属していて、かつ、roleがtypistであることを確認する + const userRepo = entityManager.getRepository(User); + const userRecords = await userRepo.find({ + where: { + id: In(typistIds), + account_id: accountId, + role: USER_ROLES.TYPIST, + }, + }); + if (userRecords.length !== typistIds.length) { + throw new TypistIdInvalidError( + `Typist user not exists Error. typistIds:${typistIds}; typistIds(DB):${userRecords.map( + (x) => x.id, + )}`, + ); + } + // userGroupをDBに保存する + const userGroup = await userGroupRepo.save({ + account_id: accountId, + name, + }); + + const userGroupMembers = userRecords.map((user) => { + return { + user_group_id: userGroup.id, + user_id: user.id, + }; + }); + // userGroupMembersをDBに保存する + await userGroupMemberRepo.save(userGroupMembers); + + return userGroup; + }); + } } diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 7683de3..b4c388d 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -317,14 +317,7 @@ export class UsersRepositoryService { .createQueryBuilder() .insert() .into(License) - .values( - licenses.map((value) => ({ - expiry_date: value.expiry_date, - account_id: value.account_id, - type: value.type, - status: value.status, - })), - ) + .values(licenses) .execute(); }); }