From 14627ad7e951e9936cedfe014b62452605be13f2 Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Thu, 13 Jul 2023 06:55:12 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20231:=20=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=82=AD=E3=83=A3=E3=83=B3=E3=82=BB=E3=83=ABAPI?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2120: タスクキャンセルAPI実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2120) - タスクキャンセルAPIを実装 - テスト実装 ## レビューポイント - Adminの時とTypistの時の実行条件はあっているか - テストケースは足りているか ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 ## 動作確認状況 - ローカルで確認 ## 補足 - 中断、チェックインAPIの実装が入っていますが、それに関しては別PRでレビューしていただいているので対象外とさせてください --- .../src/features/tasks/tasks.controller.ts | 22 +- .../src/features/tasks/tasks.service.spec.ts | 281 +++++++++++++++++- .../src/features/tasks/tasks.service.ts | 53 +++- .../tasks/tasks.repository.service.ts | 73 ++++- 4 files changed, 419 insertions(+), 10 deletions(-) diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index 4a92061..e71fa42 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -242,7 +242,7 @@ export class TasksController { json: true, }) as AccessToken; - this.taskService.checkin(audioFileId, userId); + await this.taskService.checkin(audioFileId, userId); return {}; } @@ -277,14 +277,26 @@ export class TasksController { description: '指定した文字起こしタスクをキャンセルします(ステータスをUploadedにします)', }) + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN, USER_ROLES.TYPIST], + }), + ) @ApiBearerAuth() async cancel( - @Headers() headers, + @Req() req: Request, @Param() params: ChangeStatusRequest, ): Promise { const { audioFileId } = params; - console.log(audioFileId); - + // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない + const accessToken = retrieveAuthorizationToken(req); + const { userId, role } = jwt.decode(accessToken, { + json: true, + }) as AccessToken; + // RoleGuardでroleの文字列に想定外の文字列や重複がないことは担保されているためここでは型変換のみ行う + const roles = role.split(' ') as Roles[]; + await this.taskService.cancel(audioFileId, userId, roles); return {}; } @@ -337,7 +349,7 @@ export class TasksController { json: true, }) as AccessToken; - this.taskService.suspend(audioFileId, userId); + await this.taskService.suspend(audioFileId, userId); return {}; } diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index a899430..5b19496 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -1714,7 +1714,7 @@ describe('suspend', () => { '01', '00000001', 'InProgress', - // API実行者のタスクではないため、typist_user_idは設定しない + anotherTypistUserId, ); await createCheckoutPermissions(source, taskId, anotherTypistUserId); @@ -1745,3 +1745,282 @@ describe('suspend', () => { ); }); }); + +describe('cancel', () => { + 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('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルできる', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + const { userId: typistUserId } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'InProgress', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + + await service.cancel(1, 'typist-user-external-id', ['typist', 'standard']); + const { status, typist_user_id } = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(status).toEqual('Uploaded'); + expect(typist_user_id).toEqual(null); + expect(permisions.length).toEqual(0); + }); + + it('API実行者のRoleがTypistの場合、自身が文字起こし中断しているタスクをキャンセルできる', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + const { userId: typistUserId } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Pending', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + const service = module.get(TasksService); + + await service.cancel(1, 'typist-user-external-id', ['typist', 'standard']); + const { status, typist_user_id } = await getTask(source, taskId); + + const permisions = await getCheckoutPermissions(source, taskId); + + expect(status).toEqual('Uploaded'); + expect(typist_user_id).toEqual(null); + expect(permisions.length).toEqual(0); + }); + + it('API実行者のRoleがAdminの場合、文字起こし実行中のタスクをキャンセルできる', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + const { userId: typistUserId } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'InProgress', + typistUserId, + ); + + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + + await service.cancel(1, 'typist-user-external-id', ['admin', 'author']); + const { status, typist_user_id } = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(status).toEqual('Uploaded'); + expect(typist_user_id).toEqual(null); + expect(permisions.length).toEqual(0); + }); + + it('API実行者のRoleがAdminの場合、文字起こし中断しているタスクをキャンセルできる', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + const { userId: typistUserId } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Pending', + typistUserId, + ); + + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + + await service.cancel(1, 'typist-user-external-id', ['admin', 'author']); + const { status, typist_user_id } = await getTask(source, taskId); + const permisions = await getCheckoutPermissions(source, taskId); + + expect(status).toEqual('Uploaded'); + expect(typist_user_id).toEqual(null); + expect(permisions.length).toEqual(0); + }); + + it('タスクのステータスが[Inprogress,Pending]でない時、タスクをキャンセルできない', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + const { userId: typistUserId } = await createUser( + source, + accountId, + 'typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Uploaded', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + await expect( + service.cancel(1, 'typist-user-external-id', ['admin', 'author']), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('API実行者のRoleがTypistの場合、他人が文字起こし実行中のタスクをキャンセルできない', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + await createUser(source, accountId, 'typist-user-external-id', 'typist'); + const { userId: anotherTypistUserId } = await createUser( + source, + accountId, + 'another-typist-user-external-id', + 'typist', + ); + const { userId: authorUserId } = await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'InProgress', + anotherTypistUserId, + ); + await createCheckoutPermissions(source, taskId, anotherTypistUserId); + + const service = module.get(TasksService); + + await expect( + service.cancel(1, 'typist-user-external-id', ['typist', 'standard']), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('タスクがない時、タスクをキャンセルできない', async () => { + const module = await makeTestingModule(source); + const { accountId } = await createAccount(source); + await createUser(source, accountId, 'typist-user-external-id', 'typist'); + + await createUser( + source, + accountId, + 'author-user-external-id', + 'author', + 'MY_AUTHOR_ID', + ); + + const service = module.get(TasksService); + + await expect( + service.cancel(1, 'typist-user-external-id', ['typist', 'standard']), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010603'), HttpStatus.BAD_REQUEST), + ); + }); +}); diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index 6148903..24be74e 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -249,6 +249,57 @@ export class TasksService { ); } } + /** + * 指定した音声ファイルに紐づくタスクをキャンセルする + * @param audioFileId + * @param externalId + * @param role + * @returns cancel + */ + async cancel( + audioFileId: number, + externalId: string, + role: Roles[], + ): Promise { + try { + const { id, account_id } = + await this.usersRepository.findUserByExternalId(externalId); + + // roleにAdminが含まれていれば、文字起こし担当でなくてもキャンセルできるため、ユーザーIDは指定しない + return await this.taskRepository.cancel( + audioFileId, + [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING], + account_id, + role.includes(ADMIN_ROLES.ADMIN) ? undefined : id, + ); + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case TasksNotFoundError: + throw new HttpException( + makeErrorResponse('E010603'), + HttpStatus.BAD_REQUEST, + ); + case StatusNotMatchError: + case TypistUserNotMatchError: + throw new HttpException( + makeErrorResponse('E010601'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } /** * 指定した音声ファイルに紐づくタスクをsuspendする @@ -327,7 +378,7 @@ export class TasksService { return await this.adB2cService.getUsers(filteredExternalIds); } /** - * Changes checkout permission + * 文字起こし候補を変更する * @param audioFileId * @param assignees * @returns checkout permission diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index c5d7176..01809d6 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -168,7 +168,7 @@ export class TasksRepositoryService { // ステータスチェック if (!permittedSourceStatus.includes(task.status)) { throw new StatusNotMatchError( - `Unexpected task status. status:${task.status}`, + `Unexpected task status. audio_file_id:${audio_file_id}, status:${task.status}`, ); } @@ -266,12 +266,12 @@ export class TasksRepositoryService { } if (task.status !== permittedSourceStatus) { throw new StatusNotMatchError( - `Unexpected task status. status:${task.status}`, + `Unexpected task status. audio_file_id:${audio_file_id}, status:${task.status}`, ); } if (task.typist_user_id !== user_id) { throw new TypistUserNotMatchError( - `TypistUser not match. typist_user_id:${task.typist_user_id}, user_id:${user_id}`, + `TypistUser not match. audio_file_id:${audio_file_id}, typist_user_id:${task.typist_user_id}, user_id:${user_id}`, ); } @@ -286,6 +286,73 @@ export class TasksRepositoryService { }); } + /** + * 音声ファイルIDで指定したタスクをキャンセルする + * @param audio_file_id キャンセルするタスクの音声ファイルID + * @param permittedSourceStatus キャンセル可能なステータス + * @param account_id キャンセルするタスクのアカウントID + * @param user_id キャンセルするユーザーのID(API実行者がAdminのときは使用しない) + * @returns cancel + */ + async cancel( + audio_file_id: number, + permittedSourceStatus: TaskStatus[], + account_id: number, + user_id?: number | undefined, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const taskRepo = entityManager.getRepository(Task); + const task = await taskRepo.findOne({ + where: { + audio_file_id: audio_file_id, + }, + }); + if (!task) { + throw new TasksNotFoundError( + `task not found. audio_file_id:${audio_file_id}`, + ); + } + if (!isTaskStatus(task.status)) { + throw new Error('invalid task status'); + } + // ステータスチェック + if (!permittedSourceStatus.includes(task.status)) { + throw new StatusNotMatchError( + `Unexpected task status. audio_file_id:${audio_file_id}, status:${task.status}`, + ); + } + if (task.account_id !== account_id) { + throw new AccountNotMatchError( + `task account_id not match. audio_file_id:${audio_file_id}, account_id(Task):${task.account_id}, account_id:${account_id}`, + ); + } + if (user_id && task.typist_user_id !== user_id) { + throw new TypistUserNotMatchError( + `TypistUser not match. audio_file_id:${audio_file_id}, typist_user_id:${task.typist_user_id}, user_id:${user_id}`, + ); + } + + // 対象タスクの文字起こし担当をnull,ステータスをUploadedに更新 + await taskRepo.update( + { audio_file_id: audio_file_id }, + { + typist_user: null, + status: TASK_STATUS.UPLOADED, + }, + ); + + const checkoutPermissionRepo = + entityManager.getRepository(CheckoutPermission); + + // 対象タスクの文字起こし候補を削除 + /* 対象タスクがInprogress,Pendingの状態の場合、文字起こし担当者個人指定のレコードのみだが、ユーザーIDの指定はしない + (データの不整合があっても問題ないように)*/ + await checkoutPermissionRepo.delete({ + task_id: task.id, + }); + }); + } + /** * 音声ファイルIDで指定したタスクをsuspendする * @param audio_file_id 文字起こし中断するタスクの音声ファイルID