diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index c316181..7a3ff67 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -4180,7 +4180,7 @@ "properties": { "authorId": { "type": "string", - "description": "ログインしたユーザーのAuthorID(Authorでない場合は空文字)" + "description": "ログインしたユーザーのAuthorID(Authorでない場合はundefined)" }, "authorIdList": { "description": "属しているアカウントのAuthorID List(全て)", @@ -4215,7 +4215,6 @@ } }, "required": [ - "authorId", "authorIdList", "workTypeList", "isEncrypted", diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index 1f5ffed..625277c 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -239,6 +239,27 @@ export const OPTION_ITEM_VALUE_TYPE = { LAST_INPUT: 'LastInput', } as const; +/** + * オプションアイテムのタイプ文字列と数値の対応 + **/ +export const OPTION_ITEM_VALUE_TYPE_NUMBER: { + type: string; + value: number; +}[] = [ + { + type: OPTION_ITEM_VALUE_TYPE.BLANK, + value: 1, + }, + { + type: OPTION_ITEM_VALUE_TYPE.DEFAULT, + value: 2, + }, + { + type: OPTION_ITEM_VALUE_TYPE.LAST_INPUT, + value: 3, + }, +]; + /** * ADB2Cユーザのidentity.signInType * @const {string[]} @@ -261,3 +282,9 @@ export const TERM_TYPE = { EULA: 'EULA', DPA: 'DPA', } as const; + +/** + * 音声ファイルのフォーマット + * @const {string} + */ +export const USER_AUDIO_FORMAT = 'DS2(QP)'; diff --git a/dictation_server/src/features/users/types/types.ts b/dictation_server/src/features/users/types/types.ts index af38e72..9f48418 100644 --- a/dictation_server/src/features/users/types/types.ts +++ b/dictation_server/src/features/users/types/types.ts @@ -144,9 +144,11 @@ export class OptionItemList { export class GetRelationsResponse { @ApiProperty({ - description: 'ログインしたユーザーのAuthorID(Authorでない場合は空文字)', + required: false, + description: + 'ログインしたユーザーのAuthorID(Authorでない場合はundefined)', }) - authorId: string; + authorId?: string; @ApiProperty({ description: '属しているアカウントのAuthorID List(全て)' }) authorIdList: string[]; @ApiProperty({ diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index e749058..8b3df3f 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -22,6 +22,7 @@ import { LICENSE_ALLOCATED_STATUS, LICENSE_EXPIRATION_THRESHOLD_DAYS, LICENSE_TYPE, + USER_AUDIO_FORMAT, USER_LICENSE_STATUS, USER_ROLES, } from '../../constants'; @@ -44,6 +45,8 @@ import { makeTestUser, } from '../../common/test/utility'; import { v4 as uuidv4 } from 'uuid'; +import { createOptionItems, createWorktype } from '../accounts/test/utility'; +import { createWorkflow, getWorkflows } from '../workflows/test/utility'; describe('UsersService.confirmUser', () => { let source: DataSource | null = null; @@ -2666,3 +2669,187 @@ describe('UsersService.getUserName', () => { } }); }); + +describe('UsersService.getRelations', () => { + let source: DataSource | null = null; + + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, + }); + return source.initialize(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('ユーザー関連情報を取得できる(Author)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_1', + encryption: true, + encryption_password: 'password', + prompt: true, + }); + const { id: user2 } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_2', + }); + await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_3', + email_verified: false, + }); + + const worktype1 = await createWorktype( + source, + account.id, + 'worktype1', + undefined, + true, + ); + await createOptionItems(source, worktype1.id); + + const worktype2 = await createWorktype(source, account.id, 'worktype2'); + await createOptionItems(source, worktype2.id); + + await createWorkflow(source, account.id, user1, worktype1.id); + await createWorkflow(source, account.id, user1, worktype2.id); + await createWorkflow(source, account.id, user1); + await createWorkflow(source, account.id, user2, worktype1.id); + + // 作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(4); + expect(workflows[0].worktype_id).toBe(worktype1.id); + expect(workflows[0].author_id).toBe(user1); + expect(workflows[1].worktype_id).toBe(worktype2.id); + expect(workflows[1].author_id).toBe(user1); + expect(workflows[2].worktype_id).toBe(null); + expect(workflows[2].author_id).toBe(user1); + expect(workflows[3].worktype_id).toBe(worktype1.id); + expect(workflows[3].author_id).toBe(user2); + } + + const context = makeContext(external_id); + + const service = module.get(UsersService); + const relations = await service.getRelations(context, external_id); + + // レスポンスを確認 + { + expect(relations.authorId).toBe('AUTHOR_1'); + expect(relations.authorIdList.length).toBe(2); + expect(relations.authorIdList[0]).toBe('AUTHOR_1'); + expect(relations.authorIdList[1]).toBe('AUTHOR_2'); + + const workTypeList = relations.workTypeList; + expect(relations.workTypeList.length).toBe(2); + expect(workTypeList[0].workTypeId).toBe(worktype1.custom_worktype_id); + expect(workTypeList[0].optionItemList.length).toBe(10); + expect(workTypeList[0].optionItemList[0].label).toBe(''); + expect(workTypeList[0].optionItemList[0].initialValueType).toBe(2); + expect(workTypeList[0].optionItemList[0].defaultValue).toBe(''); + expect(workTypeList[1].workTypeId).toBe(worktype2.custom_worktype_id); + expect(workTypeList[1].optionItemList.length).toBe(10); + + expect(relations.isEncrypted).toBe(true); + expect(relations.encryptionPassword).toBe('password'); + expect(relations.activeWorktype).toBe(worktype1.custom_worktype_id); + expect(relations.audioFormat).toBe(USER_AUDIO_FORMAT); + expect(relations.prompt).toBe(true); + } + }); + + it('ユーザー関連情報を取得できる(Author以外)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account } = await makeTestAccount(source, { + tier: 5, + }); + const { external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.TYPIST, + encryption: false, + prompt: false, + }); + const { id: user2 } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_2', + }); + + const worktype1 = await createWorktype(source, account.id, 'worktype1'); + await createOptionItems(source, worktype1.id); + + await createWorkflow(source, account.id, user2, worktype1.id); + + // 作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].worktype_id).toBe(worktype1.id); + expect(workflows[0].author_id).toBe(user2); + } + + const context = makeContext(external_id); + + const service = module.get(UsersService); + const relations = await service.getRelations(context, external_id); + + // レスポンスを確認 + { + expect(relations.authorId).toBe(undefined); + expect(relations.authorIdList.length).toBe(1); + expect(relations.authorIdList[0]).toBe('AUTHOR_2'); + expect(relations.workTypeList.length).toBe(0); + + expect(relations.isEncrypted).toBe(false); + expect(relations.encryptionPassword).toBe(undefined); + expect(relations.activeWorktype).toBe(''); + expect(relations.audioFormat).toBe(USER_AUDIO_FORMAT); + expect(relations.prompt).toBe(false); + } + }); + + it('ユーザーが存在しない場合は、ユーザー未存在エラー', async () => { + if (!source) fail(); + + try { + const module = await makeTestingModule(source); + if (!module) fail(); + const context = makeContext(uuidv4()); + + const service = module.get(UsersService); + await service.getRelations(context, 'external_id'); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 67b36fd..be5c8d4 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -36,6 +36,8 @@ import { ADB2C_SIGN_IN_TYPE, LICENSE_EXPIRATION_THRESHOLD_DAYS, MANUAL_RECOVERY_REQUIRED, + OPTION_ITEM_VALUE_TYPE_NUMBER, + USER_AUDIO_FORMAT, USER_LICENSE_STATUS, USER_ROLES, } from '../../constants'; @@ -663,133 +665,68 @@ export class UsersService { ): Promise { this.logger.log(`[IN] [${context.trackingId}] ${this.getRelations.name}`); try { - const user = await this.usersRepository.findUserByExternalId(userId); + const { id } = await this.usersRepository.findUserByExternalId(userId); + + // ユーザー関連情報を取得 + const { user, authors, worktypes, activeWorktype } = + await this.usersRepository.getUserRelations(id); + + // AuthorIDのリストを作成 + const authorIds = authors.flatMap((author) => + author.author_id ? [author.author_id] : [], + ); + + const workTypeList = worktypes?.map((worktype) => { + return { + workTypeId: worktype.custom_worktype_id, + optionItemList: worktype.option_items.map((optionItem) => { + const initialValueType = OPTION_ITEM_VALUE_TYPE_NUMBER.find( + (x) => x.type === optionItem.default_value_type, + )?.value; + + if (!initialValueType) { + throw new Error( + `invalid default_value_type ${optionItem.default_value_type}`, + ); + } + + return { + label: optionItem.item_label, + initialValueType, + defaultValue: optionItem.initial_value, + }; + }), + }; + }); - // TODO: PBI2105 本実装時に修正すること return { - authorId: user.author_id ?? '', - authorIdList: [user.author_id ?? '', 'XXX'], - isEncrypted: true, - encryptionPassword: 'abcd@123?dcba', - audioFormat: 'DS2(QP)', - prompt: true, - workTypeList: [ - { - workTypeId: 'workType1', - optionItemList: [ - { - label: 'optionItem11', - initialValueType: 2, - defaultValue: 'default11', - }, - { - label: 'optionItem12', - initialValueType: 2, - defaultValue: 'default12', - }, - { - label: 'optionItem13', - initialValueType: 2, - defaultValue: 'default13', - }, - { - label: 'optionItem14', - initialValueType: 2, - defaultValue: 'default14', - }, - { - label: 'optionItem15', - initialValueType: 2, - defaultValue: 'default15', - }, - { - label: 'optionItem16', - initialValueType: 2, - defaultValue: 'default16', - }, - { - label: 'optionItem17', - initialValueType: 2, - defaultValue: 'default17', - }, - { - label: 'optionItem18', - initialValueType: 2, - defaultValue: 'default18', - }, - { - label: 'optionItem19', - initialValueType: 1, - defaultValue: '', - }, - { - label: 'optionItem110', - initialValueType: 3, - defaultValue: '', - }, - ], - }, - { - workTypeId: 'workType2', - optionItemList: [ - { - label: 'optionItem21', - initialValueType: 2, - defaultValue: 'default21', - }, - { - label: 'optionItem22', - initialValueType: 2, - defaultValue: 'default22', - }, - { - label: 'optionItem23', - initialValueType: 2, - defaultValue: 'defaul23', - }, - { - label: 'optionItem24', - initialValueType: 2, - defaultValue: 'default24', - }, - { - label: 'optionItem25', - initialValueType: 2, - defaultValue: 'default25', - }, - { - label: 'optionItem26', - initialValueType: 2, - defaultValue: 'default26', - }, - { - label: 'optionItem27', - initialValueType: 2, - defaultValue: 'default27', - }, - { - label: 'optionItem28', - initialValueType: 2, - defaultValue: 'default28', - }, - { - label: 'optionItem29', - initialValueType: 1, - defaultValue: '', - }, - { - label: 'optionItem210', - initialValueType: 3, - defaultValue: '', - }, - ], - }, - ], - activeWorktype: 'workType1', + authorId: user.author_id ?? undefined, + authorIdList: authorIds, + workTypeList, + isEncrypted: user.encryption, + encryptionPassword: user.encryption_password ?? undefined, + activeWorktype: activeWorktype?.custom_worktype_id ?? '', + audioFormat: USER_AUDIO_FORMAT, + prompt: user.prompt, }; } catch (e) { this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + 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/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 2b5324b..7f393c0 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -33,6 +33,8 @@ import { AdminUserNotFoundError, } from '../accounts/errors/types'; import { Account } from '../accounts/entity/account.entity'; +import { Workflow } from '../workflows/entity/workflow.entity'; +import { Worktype } from '../worktypes/entity/worktype.entity'; @Injectable() export class UsersRepositoryService { @@ -644,4 +646,83 @@ export class UsersRepositoryService { return originAccount.delegation_permission; }); } + + /** + * ユーザーに紐づく各種情報を取得する + * @param userId + * @returns user relations + */ + async getUserRelations(userId: number): Promise<{ + user: User; + authors: User[]; + worktypes: Worktype[]; + activeWorktype: Worktype | undefined; + }> { + return await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + + const user = await userRepo.findOne({ + where: { id: userId }, + relations: { account: true }, + }); + + if (!user) { + throw new UserNotFoundError(`User is Not Found. id: ${userId}`); + } + + // 運用上、アカウントがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!user.account) { + throw new AccountNotFoundError( + `Account is Not Found. user.id: ${userId}`, + ); + } + + // ユーザーの所属するアカウント内のすべてのメール認証済みAuthorユーザーを取得する + const authors = await userRepo.find({ + where: { + account_id: user.account_id, + role: USER_ROLES.AUTHOR, + email_verified: true, + }, + }); + + // ユーザーの所属するアカウント内のアクティブワークタイプを取得する + const worktypeRepo = entityManager.getRepository(Worktype); + let activeWorktype: Worktype | undefined = undefined; + const activeWorktypeId = user.account.active_worktype_id; + if (activeWorktypeId !== null) { + activeWorktype = + (await worktypeRepo.findOne({ + where: { + account_id: user.account_id, + id: activeWorktypeId, + }, + })) ?? undefined; + } + + let worktypes: Worktype[] = []; + // ユーザーのロールがAuthorの場合はルーティングルールに紐づいたワークタイプを取得する + if (user.role === USER_ROLES.AUTHOR) { + const workflowRepo = entityManager.getRepository(Workflow); + const workflows = await workflowRepo.find({ + where: { + account_id: user.account_id, + author_id: user.id, + worktype_id: Not(IsNull()), + }, + relations: { + worktype: { + option_items: true, + }, + }, + }); + + worktypes = workflows.flatMap((workflow) => + workflow.worktype ? [workflow.worktype] : [], + ); + } + + return { user, authors, worktypes, activeWorktype }; + }); + } } diff --git a/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts b/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts index 1aa911a..f9e7ac4 100644 --- a/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts +++ b/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts @@ -4,7 +4,10 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, CreateDateColumn, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { Worktype } from './worktype.entity'; @Entity({ name: 'option_items' }) export class OptionItem { @@ -32,4 +35,8 @@ export class OptionItem { type: 'datetime', }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date | null; + + @ManyToOne(() => Worktype, (worktype) => worktype.id) + @JoinColumn({ name: 'worktype_id' }) + worktype: Worktype; } diff --git a/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts b/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts index 854e634..e672152 100644 --- a/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts +++ b/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts @@ -5,7 +5,9 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, + OneToMany, } from 'typeorm'; +import { OptionItem } from './option_item.entity'; @Entity({ name: 'worktypes' }) export class Worktype { @@ -41,4 +43,7 @@ export class Worktype { type: 'datetime', }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; + + @OneToMany(() => OptionItem, (optionItem) => optionItem.worktype) + option_items: OptionItem[]; }