From 7be4da29bb4564c6b0c953f19d5a90e5a6ae835d Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Wed, 12 Jul 2023 02:57:53 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20219:=20=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=82=A4=E3=83=B3?= =?UTF-8?q?API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2118: タスクチェックインAPI実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2118) - チェックインAPIの処理を実装 - テスト実装 ## レビューポイント - 文字起こし担当であるかどうかをチェックする方法についてどちらが良いか - チェックアウト権限テーブルで、タスクに紐づく割り当て候補を確認する(チェックアウトした時点で個人指定のみとなっているはず) - タスク情報にあるtypist_user_idで確認する - テストケースは足りているか ## UIの変更 ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- .../src/common/types/taskStatus/index.ts | 6 + .../src/features/files/files.service.ts | 4 +- .../src/features/tasks/tasks.controller.ts | 21 ++- .../src/features/tasks/tasks.service.spec.ts | 151 ++++++++++++++++++ .../src/features/tasks/tasks.service.ts | 50 +++++- .../src/features/tasks/test/utility.ts | 2 + .../src/repositories/tasks/errors/types.ts | 6 + .../tasks/tasks.repository.service.ts | 70 ++++++-- 8 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 dictation_server/src/common/types/taskStatus/index.ts diff --git a/dictation_server/src/common/types/taskStatus/index.ts b/dictation_server/src/common/types/taskStatus/index.ts new file mode 100644 index 0000000..718541b --- /dev/null +++ b/dictation_server/src/common/types/taskStatus/index.ts @@ -0,0 +1,6 @@ +import { TASK_STATUS } from '../../../constants'; + +/** + * Token.roleに配置されうる文字列リテラル型 + */ +export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 5d2a04e..92f79c7 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -12,13 +12,13 @@ import { } from '../../constants/index'; import { User } from '../../repositories/users/entity/user.entity'; import { - AccountNotMatchError, AudioFileNotFoundError, AuthorUserNotMatchError, - StatusNotMatchError, TemplateFileNotFoundError, } from './errors/types'; import { + AccountNotMatchError, + StatusNotMatchError, TasksNotFoundError, TypistUserNotFoundError, } from '../../repositories/tasks/errors/types'; diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index 38fcf6a..4789228 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -170,6 +170,12 @@ export class TasksController { '指定した文字起こしタスクをチェックアウトします(ステータスをInprogressにします)', }) @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [USER_ROLES.AUTHOR, USER_ROLES.TYPIST], + }), + ) async checkout( @Req() req: Request, @Param() param: ChangeStatusRequest, @@ -219,13 +225,24 @@ export class TasksController { '指定した文字起こしタスクをチェックインします(ステータスをFinishedにします)', }) @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [USER_ROLES.TYPIST], + }), + ) async checkin( - @Headers() headers, + @Req() req: Request, @Param() params: ChangeStatusRequest, ): Promise { const { audioFileId } = params; - console.log(audioFileId); + // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない + const accessToken = retrieveAuthorizationToken(req); + const { userId } = jwt.decode(accessToken, { + json: true, + }) as AccessToken; + this.taskService.checkin(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 67c6d70..27e041e 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -1447,3 +1447,154 @@ describe('checkout', () => { ); }); }); + +describe('checkin', () => { + 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実行者が文字起こし実行中のタスクである場合、タスクをチェックインできる', 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); + + const initTask = await getTask(source, taskId); + + await service.checkin(1, 'typist-user-external-id'); + const { status, finished_at } = await getTask(source, taskId); + + expect(status).toEqual('Finished'); + expect(finished_at).not.toEqual(initTask.finished_at); + }); + + it('タスクのステータスがInprogressでない時、タスクをチェックインできない', 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.checkin(1, 'typist-user-external-id')).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('API実行者が文字起こし実行中のタスクでない場合、タスクをチェックインできない', 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', + // API実行者のタスクではないため、typist_user_idは設定しない + ); + await createCheckoutPermissions(source, taskId, anotherTypistUserId); + + const service = module.get(TasksService); + + await expect(service.checkin(1, 'typist-user-external-id')).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.checkin(1, 'typist-user-external-id')).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 ee05cb6..246f4a2 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -10,7 +10,7 @@ import { SortDirection, TaskListSortableAttribute, } from '../../common/types/sort'; -import { ADMIN_ROLES, USER_ROLES } from '../../constants'; +import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; import { AdB2cService, Adb2cTooManyRequestsError, @@ -19,10 +19,12 @@ import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity'; import { CheckoutPermissionNotFoundError, + StatusNotMatchError, TaskAuthorIdNotMatchError, TasksNotFoundError, TypistUserGroupNotFoundError, TypistUserNotFoundError, + TypistUserNotMatchError, } from '../../repositories/tasks/errors/types'; import { Roles } from '../../common/types/role'; import { InvalidRoleError } from './errors/types'; @@ -195,6 +197,52 @@ export class TasksService { } } + /** + * 指定した音声ファイルに紐づくタスクをcheckinする + * @param audioFileId + * @param externalId + * @returns checkin + */ + async checkin(audioFileId: number, externalId: string): Promise { + try { + const { id } = await this.usersRepository.findUserByExternalId( + externalId, + ); + + return await this.taskRepository.checkin( + audioFileId, + id, + TASK_STATUS.IN_PROGRESS, + ); + } 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, + ); + } + } + private async getB2cUsers( tasks: TaskEntity[], permissions: CheckoutPermission[], diff --git a/dictation_server/src/features/tasks/test/utility.ts b/dictation_server/src/features/tasks/test/utility.ts index c46dd4e..5c48d7b 100644 --- a/dictation_server/src/features/tasks/test/utility.ts +++ b/dictation_server/src/features/tasks/test/utility.ts @@ -62,6 +62,7 @@ export const createTask = async ( priority: string, jobNumber: string, status: string, + typist_user_id?: number | undefined, ): Promise<{ taskId: number }> => { const { identifiers: audioFileIdentifiers } = await datasource .getRepository(AudioFile) @@ -90,6 +91,7 @@ export const createTask = async ( is_job_number_enabled: true, audio_file_id: audioFile.id, status: status, + typist_user_id: typist_user_id, priority: priority, started_at: new Date().toISOString(), created_at: new Date(), diff --git a/dictation_server/src/repositories/tasks/errors/types.ts b/dictation_server/src/repositories/tasks/errors/types.ts index ea2ee17..3d2b2d4 100644 --- a/dictation_server/src/repositories/tasks/errors/types.ts +++ b/dictation_server/src/repositories/tasks/errors/types.ts @@ -8,3 +8,9 @@ export class TasksNotFoundError extends Error {} export class TaskAuthorIdNotMatchError extends Error {} // チェックアウト権限未発見エラー export class CheckoutPermissionNotFoundError extends Error {} +// Status不一致エラー +export class StatusNotMatchError extends Error {} +// TypistUser不一致エラー +export class TypistUserNotMatchError extends Error {} +// Account不一致エラー +export class AccountNotMatchError extends Error {} diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 1b9e2db..1182029 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -21,17 +21,17 @@ import { Assignee } from '../../features/tasks/types/types'; import { UserGroup } from '../user_groups/entity/user_group.entity'; import { User } from '../users/entity/user.entity'; import { + AccountNotMatchError, CheckoutPermissionNotFoundError, + StatusNotMatchError, TaskAuthorIdNotMatchError, TasksNotFoundError, TypistUserGroupNotFoundError, TypistUserNotFoundError, + TypistUserNotMatchError, } from './errors/types'; import { Roles } from '../../common/types/role'; -import { - AccountNotMatchError, - StatusNotMatchError, -} from '../../features/files/errors/types'; +import { TaskStatus } from '../../common/types/taskStatus'; @Injectable() export class TasksRepositoryService { @@ -124,7 +124,13 @@ export class TasksRepositoryService { return task; }); } - + /** + * 音声ファイルIDに紐づいたTaskをCheckoutする + * @param audioFileId + * @param account_id + * @param user_id + * @returns checkout + */ async checkout( audioFileId: number, account_id: number, @@ -134,11 +140,6 @@ export class TasksRepositoryService { const taskRepo = entityManager.getRepository(Task); // 指定した音声ファイルIDに紐づくTaskの中でStatusが[Uploaded,Inprogress,Pending]であるものを取得 const task = await taskRepo.findOne({ - relations: { - file: true, - option_items: true, - typist_user: true, - }, where: { audio_file_id: audioFileId, account_id: account_id, @@ -173,9 +174,6 @@ export class TasksRepositoryService { const checkoutRepo = entityManager.getRepository(CheckoutPermission); // 対象タスクに紐づくユーザーが含まれるチェックアウト権限を取得する const related = await checkoutRepo.find({ - relations: { - task: true, - }, where: [ { task_id: task.id, @@ -226,6 +224,52 @@ export class TasksRepositoryService { }); } + /** + * Params tasks repository service + * @param audioFileId チェックインするタスクの音声ファイルID + * @param user_id チェックインするユーザーのID + * @param permittedSourceStatus チェックイン可能なステータス + * @returns checkin + */ + async checkin( + audioFileId: number, + user_id: number, + permittedSourceStatus: TaskStatus, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const taskRepo = entityManager.getRepository(Task); + const task = await taskRepo.findOne({ + where: { + audio_file_id: audioFileId, + }, + }); + if (!task) { + throw new TasksNotFoundError( + `task not found. audio_file_id:${audioFileId}`, + ); + } + if (task.status !== permittedSourceStatus) { + throw new StatusNotMatchError( + `Unexpected task status. 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}`, + ); + } + + // 対象タスクの文字起こし終了日時を現在時刻に更新。ステータスをFinishedに更新 + await taskRepo.update( + { audio_file_id: audioFileId }, + { + finished_at: new Date().toISOString(), + status: TASK_STATUS.FINISHED, + }, + ); + }); + } + /** * 指定したアカウントIDに紐づくTask関連情報の一覧を取得します * @param account_id