From 1e4a545bf8150e5cb89ac39696b0f7ceed93defb Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Wed, 8 Nov 2023 00:25:45 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20562:=20=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=B3=E3=82=BB=E3=83=ABAPI=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2972: キャンセルAPI修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2972) - キャンセル処理に自動ルーティングを追加 ## レビューポイント - 追加したテストケースは足りているか - 自動ルーティングを修正したが修正箇所は問題ないか(To : 福永さん) - 特にworktypeが空文字だった時の挙動を修正したので、そこが業務要件とあっているか - コメントがある場所 ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- .../features/accounts/accounts.controller.ts | 4 +- .../src/features/files/files.service.ts | 1 - .../src/features/tasks/tasks.service.spec.ts | 256 ++++++++++++++++++ .../src/features/tasks/tasks.service.ts | 66 ++++- .../tasks/tasks.repository.service.ts | 21 +- 5 files changed, 327 insertions(+), 21 deletions(-) diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 76c6e9c..70319e3 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -299,9 +299,7 @@ export class AccountsController { }) @ApiBearerAuth() @UseGuards(AuthGuard) - @UseGuards( - RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }), - ) + @UseGuards(RoleGuard.requireds({ delegation: true })) @Get('typists') async getTypists(@Req() req: Request): Promise { const accessToken = retrieveAuthorizationToken(req); diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index b6ad1bf..a4b8ea4 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -204,7 +204,6 @@ export class FilesService { await this.tasksRepositoryService.autoRouting( task.audio_file_id, user.account_id, - workType, user.author_id ?? undefined, ); diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index e1abcb3..e8b0fdb 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -28,6 +28,15 @@ import { import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; import { makeTestingModule } from '../../common/test/modules'; import { createSortCriteria } from '../users/test/utility'; +import { createWorktype } from '../accounts/test/utility'; +import { + createWorkflow, + createWorkflowTypist, +} from '../workflows/test/utility'; +import { createTemplateFile } from '../templates/test/utility'; +import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; +import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; +import { Roles } from '../../common/types/role'; describe('TasksService', () => { it('タスク一覧を取得できる(admin)', async () => { @@ -2468,6 +2477,253 @@ describe('cancel', () => { new HttpException(makeErrorResponse('E010603'), HttpStatus.NOT_FOUND), ); }); + + it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行う', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId, author_id } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + //ワークタイプIDを作成 + await createWorktype(source, accountId, '01'); + // テンプレートファイルを作成 + const { id: templateFileId } = await createTemplateFile( + source, + accountId, + 'template-file-name', + 'https://example.com', + ); + // ワークフローを作成 + const { id: workflowId } = await createWorkflow( + source, + accountId, + authorUserId, + undefined, + templateFileId, + ); + // ワークフロータイピストを作成 + await createWorkflowTypist(source, workflowId, typistUserId); + + const { taskId } = await createTask( + source, + accountId, + authorUserId, + author_id ?? '', + '', + '01', + '00000001', + 'InProgress', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + const NotificationHubService = module.get( + NotificationhubService, + ); + await service.cancel( + makeContext('trackingId'), + 1, + 'typist-user-external-id', + ['typist', 'standard'], + ); + const resultTask = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); + // タスクのテンプレートファイルIDを確認 + expect(resultTask?.template_file_id).toEqual(templateFileId); + // タスクのチェックアウト権限が想定通り(ワークフローで設定されている)のユーザーIDで作成されているか確認 + expect(permisions.length).toEqual(1); + expect(permisions[0].user_id).toEqual(typistUserId); + // 通知処理が想定通りの引数で呼ばれているか確認 + expect(NotificationHubService.notify).toHaveBeenCalledWith( + makeContext('trackingId'), + [`user_${typistUserId}`], + makeNotifyMessage('M000101'), + ); + }, 1000000); + + it('API実行者のRoleがAdminの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行う(API実行者のAuthorIDと音声ファイルに紐づくWorkType)', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + // タスクの文字起こし担当者 + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + // 自動ルーティングされるタイピストユーザーを作成 + const { id: autoRoutingTypistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'auto-routing-typist-user-external-id', + role: 'typist', + }); + // API実行者 + const { + id: myAuthorUserId, + external_id, + role, + } = await makeTestUser(source, { + account_id: accountId, + external_id: 'my-author-user-external-id', + role: 'author admin', + author_id: 'MY_AUTHOR_ID', + }); + // 音声ファイルのアップロード者 + const { id: authorUserId, author_id } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + //ワークタイプIDを作成 + const { id: workTypeId, custom_worktype_id } = await createWorktype( + source, + accountId, + '01', + ); + // テンプレートファイルを作成 + const { id: templateFileId } = await createTemplateFile( + source, + accountId, + 'template-file-name', + 'https://example.com', + ); + // ワークフローを作成 + const { id: workflowId } = await createWorkflow( + source, + accountId, + myAuthorUserId, + workTypeId, + templateFileId, + ); + // ワークフロータイピストを作成 + await createWorkflowTypist(source, workflowId, autoRoutingTypistUserId); + + const { taskId } = await createTask( + source, + accountId, + authorUserId, + author_id ?? '', + custom_worktype_id, + '01', + '00000001', + 'InProgress', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + const NotificationHubService = module.get( + NotificationhubService, + ); + await service.cancel( + makeContext('trackingId'), + 1, + external_id, + role.split(' ') as Roles[], + ); + const resultTask = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); + // タスクのテンプレートファイルIDを確認 + expect(resultTask?.template_file_id).toEqual(templateFileId); + // タスクのチェックアウト権限が想定通り(ワークフローで設定されている)のユーザーIDで作成されているか確認 + expect(permisions.length).toEqual(1); + expect(permisions[0].user_id).toEqual(autoRoutingTypistUserId); + // 通知処理が想定通りの引数で呼ばれているか確認 + expect(NotificationHubService.notify).toHaveBeenCalledWith( + makeContext('trackingId'), + [`user_${autoRoutingTypistUserId}`], + makeNotifyMessage('M000101'), + ); + }); + it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルするが、一致するワークフローがない場合は自動ルーティングを行うことができない', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + // タスクの文字起こし担当者 + const { + id: typistUserId, + external_id, + role, + } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + // 音声ファイルのアップロード者 + const { id: authorUserId, author_id } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + author_id ?? '', + 'custom_worktype_id', + '01', + '00000001', + 'InProgress', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + const NotificationHubService = module.get( + NotificationhubService, + ); + await service.cancel( + makeContext('trackingId'), + 1, + external_id, + role.split(' ') as Roles[], + ); + const resultTask = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); + // タスクのチェックアウト権限が削除されていることを確認 + expect(permisions.length).toEqual(0); + // 通知処理が想定通りの引数で呼ばれていないか確認 + expect(NotificationHubService.notify).not.toHaveBeenCalled(); + }); }); describe('getNextTask', () => { diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index 783ab23..164602c 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -33,6 +33,7 @@ import { NotificationhubService } from '../../gateways/notificationhub/notificat import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; import { Context } from '../../common/log'; +import { User } from '../../repositories/users/entity/user.entity'; @Injectable() export class TasksService { @@ -382,19 +383,29 @@ export class TasksService { externalId: string, role: Roles[], ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`, + ); + let user: User; try { - this.logger.log( - `[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`, + // ユーザー取得 + user = await this.usersRepository.findUserByExternalId(externalId); + } catch (e) { + this.logger.error(`error=${e}`); + this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, ); - const { id, account_id } = - await this.usersRepository.findUserByExternalId(externalId); + } + try { // roleにAdminが含まれていれば、文字起こし担当でなくてもキャンセルできるため、ユーザーIDは指定しない - return await this.taskRepository.cancel( + await this.taskRepository.cancel( audioFileId, [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING], - account_id, - role.includes(ADMIN_ROLES.ADMIN) ? undefined : id, + user.account_id, + role.includes(ADMIN_ROLES.ADMIN) ? undefined : user.id, ); } catch (e) { this.logger.error(`error=${e}`); @@ -422,6 +433,47 @@ export class TasksService { makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); + } + + try { + // キャンセルしたタスクに自動ルーティングを行う + const { typistGroupIds, typistIds } = + await this.taskRepository.autoRouting( + audioFileId, + user.account_id, + user.author_id ?? undefined, + ); + + const groupMembers = + await this.userGroupsRepositoryService.getGroupMembersFromGroupIds( + typistGroupIds, + ); + + // 重複のない割り当て候補ユーザーID一覧を取得する + const distinctUserIds = [ + ...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]), + ]; + + // 割り当てられたユーザーがいない場合は通知不要 + if (distinctUserIds.length === 0) { + this.logger.log('No user assigned.'); + return; + } + + // タグを生成 + const tags = distinctUserIds.map((x) => `user_${x}`); + this.logger.log(`tags: ${tags}`); + + // タグ対象に通知送信 + await this.notificationhubService.notify( + context, + tags, + makeNotifyMessage('M000101'), + ); + } catch (e) { + // 処理の本筋はタスクキャンセルのため自動ルーティングに失敗してもエラーにしない + this.logger.error(`Automatic routing or notification failed.`); + this.logger.error(`error=${e}`); } finally { this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`); } diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 8ac71b6..bc0802a 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -957,17 +957,15 @@ export class TasksRepositoryService { } /** - * worktypeIdをもとにルーティングルールを取得し、タスクのチェックアウト権限を設定する + * ルーティングルールを取得し、タスクのチェックアウト権限を設定する * @param audioFileId * @param accountId - * @param worktypeId * @param [myAuthorId] * @returns typistIds: タイピストIDの一覧 / typistGroupIds: タイピストグループIDの一覧 */ async autoRouting( audioFileId: number, accountId: number, - worktypeId: string, // ユーザーが任意につけるworktypeId(DBのcustom_worktype_id) myAuthorId?: string, // API実行者のAuthorId ): Promise<{ typistIds: number[]; typistGroupIds: number[] }> { return await this.dataSource.transaction(async (entityManager) => { @@ -1008,17 +1006,20 @@ export class TasksRepositoryService { `user not found. authorId:${audioFile.author_id}, accountId:${accountId}`, ); } - // ユーザーが任意につけるworktypeIdをもとにworktypeを取得 + + // 音声ファイル上のworktypeIdをもとにworktypeを取得 const worktypeRepo = entityManager.getRepository(Worktype); const worktypeRecord = await worktypeRepo.findOne({ where: { - custom_worktype_id: worktypeId, + custom_worktype_id: audioFile.work_type_id, account_id: accountId, }, }); - if (!worktypeRecord) { + + // 音声ファイル上のworktypeIdが設定されているが、一致するworktypeが存在しない場合はエラーを出して終了 + if (!worktypeRecord && audioFile.work_type_id !== '') { throw new Error( - `worktype not found. worktype:${worktypeId}, accountId:${accountId}`, + `worktype not found. worktype:${audioFile.work_type_id}, accountId:${accountId}`, ); } @@ -1031,7 +1032,7 @@ export class TasksRepositoryService { where: { account_id: accountId, author_id: authorUser.id, - worktype_id: worktypeRecord.id, + worktype_id: worktypeRecord?.id ?? IsNull(), }, }); @@ -1071,14 +1072,14 @@ export class TasksRepositoryService { where: { account_id: accountId, author_id: myAuthorUser.id, - worktype_id: worktypeRecord.id, + worktype_id: worktypeRecord?.id ?? IsNull(), }, }); // API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得できない場合はエラーを出して終了 if (!defaultWorkflow) { throw new Error( - `workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktype:${worktypeId}`, + `workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktypeId:${worktypeRecord?.id}`, ); }