From d08c6c99af0ba0d2374874d61f5d464caf844a8f Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 16 Jan 2024 07:55:38 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20681:=20=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E5=89=8A=E9=99=A4API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task3457: タスク削除API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3457) - タスク削除APIとUTを実装しました。 ## レビューポイント - テストケースは適切でしょうか? - リポジトリでの削除処理は適切でしょうか? - エラー時のコード使い分けは適切でしょうか? ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_client/src/pages/AuthPage/index.tsx | 8 +- .../db/migrations/052-add-task-index.sql | 5 + dictation_server/src/common/test/overrides.ts | 12 + .../src/features/tasks/tasks.controller.ts | 3 +- .../src/features/tasks/tasks.module.ts | 2 + .../src/features/tasks/tasks.service.spec.ts | 532 ++++++++++++++++++ .../src/features/tasks/tasks.service.ts | 112 +++- .../features/tasks/test/tasks.service.mock.ts | 3 + .../src/features/tasks/test/utility.ts | 75 +++ .../blobstorage/blobstorage.service.ts | 54 ++ .../tasks/tasks.repository.service.ts | 122 +++- 11 files changed, 918 insertions(+), 10 deletions(-) create mode 100644 dictation_server/db/migrations/052-add-task-index.sql diff --git a/dictation_client/src/pages/AuthPage/index.tsx b/dictation_client/src/pages/AuthPage/index.tsx index a17880d..5a66e4c 100644 --- a/dictation_client/src/pages/AuthPage/index.tsx +++ b/dictation_client/src/pages/AuthPage/index.tsx @@ -66,12 +66,10 @@ const AuthPage: React.FC = (): JSX.Element => { clearToken(); return; } - const loginResult = await instance.handleRedirectPromise(); // eslint-disable-next-line console.log({ loginResult }); // TODO:loading画面から遷移できない事象の調査用ログ。事象解消後削除(eslint-disable含めて)する。 - if (loginResult && loginResult.account) { const { homeAccountId, idTokenClaims } = loginResult.account; if (idTokenClaims && idTokenClaims.aud) { @@ -85,11 +83,11 @@ const AuthPage: React.FC = (): JSX.Element => { localStorageKeyforIdToken, }) ); - - // トークン取得と設定を行う - navigate("/login"); } } + // ログインページに遷移し、トークン取得と設定を行う + // 何らかの原因で、loginResultがnullの場合でも、ログイン画面に遷移する(ログイン画面でトップページに戻る) + navigate("/login"); } catch (e) { // eslint-disable-next-line console.log({ e }); // TODO:loading画面から遷移できない事象の調査用ログ。事象解消後削除(eslint-disable含めて)する。 diff --git a/dictation_server/db/migrations/052-add-task-index.sql b/dictation_server/db/migrations/052-add-task-index.sql new file mode 100644 index 0000000..459a54f --- /dev/null +++ b/dictation_server/db/migrations/052-add-task-index.sql @@ -0,0 +1,5 @@ +-- +migrate Up +ALTER TABLE `tasks` ADD INDEX `idx_account_id_and_audio_file_id` (account_id,audio_file_id); + +-- +migrate Down +ALTER TABLE `tasks` DROP INDEX `idx_account_id_and_audio_file_id`; \ No newline at end of file diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 146d00a..e9081f6 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -158,6 +158,12 @@ export const overrideBlobstorageService = ( accountId: number, country: string, ) => Promise; + deleteFile?: ( + context: Context, + accountId: number, + country: string, + fileName: string, + ) => Promise; containerExists?: ( context: Context, accountId: number, @@ -189,6 +195,12 @@ export const overrideBlobstorageService = ( writable: true, }); } + if (overrides.deleteFile) { + Object.defineProperty(obj, obj.deleteFile.name, { + value: overrides.deleteFile, + writable: true, + }); + } if (overrides.containerExists) { Object.defineProperty(obj, obj.containerExists.name, { value: overrides.containerExists, diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index df8afcb..7855c34 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -828,8 +828,7 @@ export class TasksController { const context = makeContext(userId, requestId); this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`); - // TODO: Task削除処理を実装する - console.log(audioFileId); + await this.taskService.deleteTask(context, userId, audioFileId); return {}; } } diff --git a/dictation_server/src/features/tasks/tasks.module.ts b/dictation_server/src/features/tasks/tasks.module.ts index c3e10e0..5a44eb9 100644 --- a/dictation_server/src/features/tasks/tasks.module.ts +++ b/dictation_server/src/features/tasks/tasks.module.ts @@ -8,6 +8,7 @@ import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_ import { NotificationhubModule } from '../../gateways/notificationhub/notificationhub.module'; import { SendGridModule } from '../../gateways/sendgrid/sendgrid.module'; import { AccountsRepositoryModule } from '../../repositories/accounts/accounts.repository.module'; +import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { AccountsRepositoryModule } from '../../repositories/accounts/accounts.r AdB2cModule, NotificationhubModule, SendGridModule, + BlobstorageModule, ], providers: [TasksService], controllers: [TasksController], diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index eaf6e56..ad85be7 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -14,6 +14,8 @@ import { createCheckoutPermissions, createTask, createUserGroup, + getAudioFile, + getAudioOptionItems, getCheckoutPermissions, getTask, makeTaskTestingModuleWithNotificaiton, @@ -37,6 +39,8 @@ import { createTemplateFile } from '../templates/test/utility'; import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; import { Roles } from '../../common/types/role'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; +import { overrideBlobstorageService } from '../../common/test/overrides'; +import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; describe('TasksService', () => { it('タスク一覧を取得できる(admin)', async () => { @@ -3775,3 +3779,531 @@ describe('getNextTask', () => { } }); }); + +describe('deleteTask', () => { + let source: DataSource | null = 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 () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('管理者として、アカウント内のタスクを削除できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const authorId = 'AUTHOR_ID'; + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId, + authorId, + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.UPLOADED); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + const blobStorageService = + module.get(BlobstorageService); + const context = makeContext(admin.external_id, 'requestId'); + + overrideBlobstorageService(service, { + deleteFile: jest.fn(), + }); + + await service.deleteTask(context, admin.external_id, audioFileId); + + // 実行結果が正しいか確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task).toBe(null); + expect(audioFile).toBe(null); + expect(checkoutPermissions.length).toBe(0); + expect(optionItems.length).toBe(0); + + // Blob削除メソッドが呼ばれているか確認 + expect(blobStorageService.deleteFile).toBeCalledWith( + context, + account.id, + account.country, + 'x.zip', + ); + } + }); + it('Authorとして、自身が追加したタスクを削除できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const authorId = 'AUTHOR_ID'; + const { id: authorUserId, external_id: authorExternalId } = + await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId, + authorId, + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.UPLOADED); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + const blobStorageService = + module.get(BlobstorageService); + const context = makeContext(authorExternalId, 'requestId'); + + overrideBlobstorageService(service, { + deleteFile: jest.fn(), + }); + + await service.deleteTask(context, authorExternalId, audioFileId); + + // 実行結果が正しいか確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task).toBe(null); + expect(audioFile).toBe(null); + expect(checkoutPermissions.length).toBe(0); + expect(optionItems.length).toBe(0); + + // Blob削除メソッドが呼ばれているか確認 + expect(blobStorageService.deleteFile).toBeCalledWith( + context, + account.id, + account.country, + 'x.zip', + ); + } + }); + it('ステータスがInProgressのタスクを削除しようとした場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const authorId = 'AUTHOR_ID'; + const { id: authorUserId, external_id: authorExternalId } = + await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId, + authorId, + '', + '01', + '00000001', + TASK_STATUS.IN_PROGRESS, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.IN_PROGRESS); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + const context = makeContext(authorExternalId, 'requestId'); + + overrideBlobstorageService(service, { + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteFile: async () => {}, + }); + + try { + await service.deleteTask(context, authorExternalId, audioFileId); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010601')); + } else { + fail(); + } + } + }); + it('Authorが自身が作成したタスク以外を削除しようとした場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const authorId1 = 'AUTHOR_ID1'; + const authorId2 = 'AUTHOR_ID2'; + + const { id: authorUserId1 } = await makeTestUser(source, { + account_id: account.id, + author_id: authorId1, + external_id: 'author-user-external-id1', + role: USER_ROLES.AUTHOR, + }); + const { external_id: authorExternalId2 } = await makeTestUser(source, { + account_id: account.id, + author_id: authorId2, + external_id: 'author-user-external-id2', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId1, + authorId1, + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.UPLOADED); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId1); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + const context = makeContext(authorExternalId2, 'requestId'); + + overrideBlobstorageService(service, { + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteFile: async () => {}, + }); + + try { + await service.deleteTask(context, authorExternalId2, audioFileId); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010602')); + } else { + fail(); + } + } + }); + it('削除対象タスクが存在しない場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id, 'requestId'); + + overrideBlobstorageService(service, { + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteFile: async () => {}, + }); + + try { + await service.deleteTask(context, admin.external_id, 1); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010603')); + } else { + fail(); + } + } + }); + it('タスクのDB削除に失敗した場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const authorId = 'AUTHOR_ID'; + const { id: authorUserId, external_id: authorExternalId } = + await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId, + authorId, + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.UPLOADED); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + const context = makeContext(authorExternalId, 'requestId'); + + // DBアクセスに失敗するようにする + const tasksRepositoryService = module.get( + TasksRepositoryService, + ); + tasksRepositoryService.deleteTask = jest + .fn() + .mockRejectedValue('DB failed'); + + overrideBlobstorageService(service, { + // eslint-disable-next-line @typescript-eslint/no-empty-function + deleteFile: async () => {}, + }); + + try { + await service.deleteTask(context, authorExternalId, audioFileId); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); + it('blobストレージからの音声ファイル削除に失敗した場合でも、エラーとならないこと', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const authorId = 'AUTHOR_ID'; + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const { taskId, audioFileId } = await createTask( + source, + account.id, + authorUserId, + authorId, + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + // 作成したデータを確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task?.id).toBe(taskId); + expect(task?.status).toBe(TASK_STATUS.UPLOADED); + expect(task?.audio_file_id).toBe(audioFileId); + + expect(audioFile?.id).toBe(audioFileId); + expect(audioFile?.file_name).toBe('x.zip'); + expect(audioFile?.author_id).toBe(authorId); + + expect(checkoutPermissions.length).toBe(1); + expect(checkoutPermissions[0].user_id).toBe(typistUserId); + + expect(optionItems.length).toBe(10); + } + + const service = module.get(TasksService); + + const context = makeContext(admin.external_id, 'requestId'); + + overrideBlobstorageService(service, { + deleteFile: async () => { + throw new Error('blob failed'); + }, + }); + + await service.deleteTask(context, admin.external_id, audioFileId); + + // 実行結果が正しいか確認 + { + const task = await getTask(source, taskId); + const audioFile = await getAudioFile(source, audioFileId); + const checkoutPermissions = await getCheckoutPermissions(source, taskId); + const optionItems = await getAudioOptionItems(source, taskId); + + expect(task).toBe(null); + expect(audioFile).toBe(null); + expect(checkoutPermissions.length).toBe(0); + expect(optionItems.length).toBe(0); + } + }); +}); diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index d56055b..4b1b5d5 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -1,6 +1,6 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; -import { Assignee, Task } from './types/types'; +import { Assignee, PostDeleteTaskRequest, Task } from './types/types'; import { Task as TaskEntity } from '../../repositories/tasks/entity/task.entity'; import { createTasks } from './types/convert'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; @@ -9,7 +9,12 @@ import { SortDirection, TaskListSortableAttribute, } from '../../common/types/sort'; -import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; +import { + ADMIN_ROLES, + MANUAL_RECOVERY_REQUIRED, + TASK_STATUS, + USER_ROLES, +} from '../../constants'; import { AdB2cService, Adb2cTooManyRequestsError, @@ -36,6 +41,7 @@ import { User } from '../../repositories/users/entity/user.entity'; import { SendGridService } from '../../gateways/sendgrid/sendgrid.service'; import { getUserNameAndMailAddress } from '../../gateways/adb2c/utils/utils'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; +import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; @Injectable() export class TasksService { @@ -48,6 +54,7 @@ export class TasksService { private readonly adB2cService: AdB2cService, private readonly sendgridService: SendGridService, private readonly notificationhubService: NotificationhubService, + private readonly blobStorageService: BlobstorageService, ) {} async getTasks( @@ -848,6 +855,107 @@ export class TasksService { } } + /** + * 指定した音声ファイルに紐づくタスクを削除します + * @param context + * @param externalId 実行ユーザーの外部ID + * @param audioFileId 削除対象のタスクのaudio_file_id + * @returns task + */ + async deleteTask( + context: Context, + externalId: string, + audioFileId: number, + ): Promise { + try { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${ + this.deleteTask.name + } | params: { externalId: ${externalId}, audioFileId: ${audioFileId} };`, + ); + + // 実行ユーザーの情報を取得する + const user = await this.usersRepository.findUserByExternalId( + context, + externalId, + ); + + const account = user.account; + if (!account) { + throw new Error(`account not found. externalId: ${externalId}`); + } + + // 削除対象の音声ファイル情報を取得する + const task = await this.taskRepository.getTaskAndAudioFile( + context, + audioFileId, + user.account_id, + Object.values(TASK_STATUS), + ); + + const targetFileName = task.file?.file_name; + if (!targetFileName) { + throw new Error(`target file not found. audioFileId: ${audioFileId}`); + } + + // DBからタスクと紐づくデータを削除する + await this.taskRepository.deleteTask(context, user.id, audioFileId); + + // Blob削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行するため、try-catchで囲む + try { + // BlobStorageから音声ファイルを削除する + await this.blobStorageService.deleteFile( + context, + account.id, + account.country, + targetFileName, + ); + } catch (e) { + // Blob削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行 + this.logger.log(`[${context.getTrackingId()}] ${e}`); + this.logger.log( + `${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete Blob: accountId: ${ + account.id + }, fileName: ${targetFileName}`, + ); + } + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case StatusNotMatchError: + throw new HttpException( + makeErrorResponse('E010601'), + HttpStatus.BAD_REQUEST, + ); + case TaskAuthorIdNotMatchError: + throw new HttpException( + makeErrorResponse('E010602'), + HttpStatus.BAD_REQUEST, + ); + case TasksNotFoundError: + throw new HttpException( + makeErrorResponse('E010603'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.deleteTask.name}`, + ); + } + } + // 通知を送信するプライベートメソッド private async sendNotify( context: Context, diff --git a/dictation_server/src/features/tasks/test/tasks.service.mock.ts b/dictation_server/src/features/tasks/test/tasks.service.mock.ts index 70dc66a..282691b 100644 --- a/dictation_server/src/features/tasks/test/tasks.service.mock.ts +++ b/dictation_server/src/features/tasks/test/tasks.service.mock.ts @@ -17,6 +17,7 @@ import { NotificationhubService } from '../../../gateways/notificationhub/notifi import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service'; import { AccountsRepositoryService } from '../../../repositories/accounts/accounts.repository.service'; import { SendGridService } from '../../../gateways/sendgrid/sendgrid.service'; +import { BlobstorageService } from '../../../gateways/blobstorage/blobstorage.service'; export type TasksRepositoryMockValue = { getTasksFromAccountId: @@ -92,6 +93,8 @@ export const makeTasksServiceMock = async ( // メール送信でしか利用しておらず、テストする必要がないが、依存関係解決のため空オブジェクトを定義しておく。 case SendGridService: return {}; + case BlobstorageService: + return {}; } }) .compile(); diff --git a/dictation_server/src/features/tasks/test/utility.ts b/dictation_server/src/features/tasks/test/utility.ts index b4de930..c691934 100644 --- a/dictation_server/src/features/tasks/test/utility.ts +++ b/dictation_server/src/features/tasks/test/utility.ts @@ -38,6 +38,7 @@ import { NotificationhubServiceMockValue, makeNotificationhubServiceMock, } from './tasks.service.mock'; +import { AudioOptionItem } from '../../../repositories/audio_option_items/entity/audio_option_item.entity'; export const makeTaskTestingModuleWithNotificaiton = async ( datasource: DataSource, @@ -145,6 +146,60 @@ export const createTask = async ( created_at: new Date(), }); const task = taskIdentifiers.pop() as Task; + + await datasource.getRepository(AudioOptionItem).insert([ + { + audio_file_id: audioFile.id, + label: 'label01', + value: 'value01', + }, + { + audio_file_id: audioFile.id, + label: 'label02', + value: 'value02', + }, + { + audio_file_id: audioFile.id, + label: 'label03', + value: 'value03', + }, + { + audio_file_id: audioFile.id, + label: 'label04', + value: 'value04', + }, + { + audio_file_id: audioFile.id, + label: 'label05', + value: 'value05', + }, + { + audio_file_id: audioFile.id, + label: 'label06', + value: 'value06', + }, + { + audio_file_id: audioFile.id, + label: 'label07', + value: 'value07', + }, + { + audio_file_id: audioFile.id, + label: 'label08', + value: 'value08', + }, + { + audio_file_id: audioFile.id, + label: 'label09', + value: 'value09', + }, + { + audio_file_id: audioFile.id, + label: 'label10', + value: 'value10', + }, + ]); + return { taskId: task.id, audioFileId: audioFile.id }; }; /** @@ -229,3 +284,23 @@ export const getCheckoutPermissions = async ( }); return permissions; }; + +export const getAudioFile = async ( + datasource: DataSource, + audio_file_id: number, +): Promise => { + const audioFile = await datasource.getRepository(AudioFile).findOne({ + where: { id: audio_file_id }, + }); + return audioFile; +}; + +export const getAudioOptionItems = async ( + datasource: DataSource, + audio_file_id: number, +): Promise => { + const audioOptionItems = await datasource + .getRepository(AudioOptionItem) + .find({ where: { audio_file_id: audio_file_id } }); + return audioOptionItems; +}; diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts index 2381148..9199ad9 100644 --- a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts +++ b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts @@ -142,6 +142,60 @@ export class BlobstorageService { } } + /** + * 指定されたファイルを削除します。 + * @param context + * @param accountId + * @param country + * @param fileName + * @returns file + */ + async deleteFile( + context: Context, + accountId: number, + country: string, + fileName: string, + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${this.deleteFile.name} | params: { ` + + `accountId: ${accountId} ` + + `country: ${country} ` + + `fileName: ${fileName} };`, + ); + + try { + // 国に応じたリージョンでコンテナ名を指定してClientを取得 + const containerClient = this.getContainerClient( + context, + accountId, + country, + ); + // コンテナ内のBlobパス名を指定してClientを取得 + const blobClient = containerClient.getBlobClient(fileName); + + const { succeeded, errorCode, date } = await blobClient.deleteIfExists(); + this.logger.log( + `[${context.getTrackingId()}] succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`, + ); + + // 失敗時、Blobが存在しない場合以外はエラーとして例外をスローする + // Blob不在の場合のエラーコードは「BlobNotFound」以下を参照 + // https://learn.microsoft.com/ja-jp/rest/api/storageservices/blob-service-error-codes + if (!succeeded && errorCode !== 'BlobNotFound') { + throw new Error( + `delete blob failed. succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`, + ); + } + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + throw e; + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.deleteFile.name}`, + ); + } + } + /** * Containers exists * @param country diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 9ac0e48..c721f18 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -10,7 +10,12 @@ import { Repository, } from 'typeorm'; import { Task } from './entity/task.entity'; -import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; +import { + ADMIN_ROLES, + NODE_ENV_TEST, + TASK_STATUS, + USER_ROLES, +} from '../../constants'; import { AudioOptionItem as ParamOptionItem } from '../../features/files/types/types'; import { AudioFile } from '../audio_files/entity/audio_file.entity'; import { AudioOptionItem } from '../audio_option_items/entity/audio_option_item.entity'; @@ -1280,6 +1285,121 @@ export class TasksRepositoryService { ); }); } + /** + * Deletes task + * @param context + * @param userId 削除を行うユーザーID + * @param audioFileId 削除を行うタスクの音声ファイルID + * @returns task + */ + async deleteTask( + context: Context, + userId: number, + audioFileId: number, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + + // 削除を行うユーザーとアカウントを取得 + const user = await userRepo.findOne({ + where: { id: userId }, + relations: { account: true }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); + + // 運用上はあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!user) { + throw new Error(`user not found. userId:${userId}`); + } + const account = user.account; + if (!account) { + throw new Error(`account not found. userId:${userId}`); + } + + // ユーザーがアカウントの管理者であるかを確認 + const isAdmin = + account.primary_admin_user_id === userId || + account.secondary_admin_user_id === userId; + + // 削除を行うタスクを取得 + const taskRepo = entityManager.getRepository(Task); + const task = await taskRepo.findOne({ + where: { + account_id: account.id, + audio_file_id: audioFileId, + }, + relations: { file: true }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + // テスト環境の場合はロックを行わない(sqliteがlockに対応していないため) + lock: + process.env.NODE_ENV !== NODE_ENV_TEST + ? { mode: 'pessimistic_write' } + : undefined, + }); + if (!task) { + throw new TasksNotFoundError( + `task not found. audio_file_id:${audioFileId}`, + ); + } + if (!task.file) { + throw new Error(`audio file not found. audio_file_id:${audioFileId}`); + } + + // ユーザーが管理者でない場合は、タスクのAuthorIdとユーザーのAuthorIdが一致するかを確認 + if (!isAdmin) { + // ユーザーがAuthorである場合 + if (user.role === USER_ROLES.AUTHOR) { + if (task.file.author_id !== user.author_id) { + throw new TaskAuthorIdNotMatchError( + `Task authorId not match. userId:${userId}, authorId:${user.author_id}`, + ); + } + } else { + // ユーザーが管理者でもAuthorでもない場合はエラー + throw new Error(`The user is not admin or author. userId:${userId}`); + } + } + + // タスクのステータスがInProgressの場合はエラー + if (task.status === TASK_STATUS.IN_PROGRESS) { + throw new StatusNotMatchError( + `task status is InProgress. audio_file_id:${audioFileId}`, + ); + } + + // タスクに紐づくオプションアイテムを削除 + const optionItemRepo = entityManager.getRepository(AudioOptionItem); + await deleteEntity( + optionItemRepo, + { audio_file_id: task.audio_file_id }, + this.isCommentOut, + context, + ); + // タスクに紐づくチェックアウト候補を削除 + const checkoutRepo = entityManager.getRepository(CheckoutPermission); + await deleteEntity( + checkoutRepo, + { task_id: task.id }, + this.isCommentOut, + context, + ); + // タスクを削除 + await deleteEntity( + taskRepo, + { audio_file_id: audioFileId }, + this.isCommentOut, + context, + ); + // タスクに紐づく音声ファイル情報を削除 + const audioFileRepo = entityManager.getRepository(AudioFile); + await deleteEntity( + audioFileRepo, + { id: audioFileId }, + this.isCommentOut, + context, + ); + }); + } /** * workflowに紐づけられているタイピスト・タイピストグループで、タスクのチェックアウト権限を設定