diff --git a/dictation_server/src/common/types/taskStatus/index.ts b/dictation_server/src/common/types/taskStatus/index.ts index 718541b..29d6f26 100644 --- a/dictation_server/src/common/types/taskStatus/index.ts +++ b/dictation_server/src/common/types/taskStatus/index.ts @@ -4,3 +4,11 @@ import { TASK_STATUS } from '../../../constants'; * Token.roleに配置されうる文字列リテラル型 */ export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; + +export const isTaskStatus = (arg: string): arg is TaskStatus => { + const param = arg as TaskStatus; + if (Object.values(TASK_STATUS).includes(param)) { + return true; + } + return false; +}; diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index 4789228..4a92061 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -320,13 +320,24 @@ export class TasksController { '指定した文字起こしタスクを一時中断します(ステータスをPendingにします)', }) @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [USER_ROLES.TYPIST], + }), + ) async suspend( - @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.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 27e041e..a899430 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -1598,3 +1598,150 @@ describe('checkin', () => { ); }); }); + +describe('suspend', () => { + 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, + ); + + const service = module.get(TasksService); + + await service.suspend(1, 'typist-user-external-id'); + const { status } = await getTask(source, taskId); + + expect(status).toEqual('Pending'); + }); + + 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.suspend(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 246f4a2..6148903 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -18,6 +18,7 @@ import { import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity'; import { + AccountNotMatchError, CheckoutPermissionNotFoundError, StatusNotMatchError, TaskAuthorIdNotMatchError, @@ -163,7 +164,11 @@ export class TasksService { } if (roles.includes(USER_ROLES.TYPIST)) { - return await this.taskRepository.checkout(audioFileId, account_id, id); + return await this.taskRepository.checkout(audioFileId, account_id, id, [ + TASK_STATUS.UPLOADED, + TASK_STATUS.PENDING, + TASK_STATUS.IN_PROGRESS, + ]); } throw new InvalidRoleError(`invalid roles: ${roles.join(',')}`); @@ -179,6 +184,8 @@ export class TasksService { HttpStatus.BAD_REQUEST, ); case TasksNotFoundError: + case AccountNotMatchError: + case StatusNotMatchError: throw new HttpException( makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST, @@ -243,6 +250,52 @@ export class TasksService { } } + /** + * 指定した音声ファイルに紐づくタスクをsuspendする + * @param audioFileId + * @param externalId + * @returns suspend + */ + async suspend(audioFileId: number, externalId: string): Promise { + try { + const { id } = await this.usersRepository.findUserByExternalId( + externalId, + ); + + return await this.taskRepository.suspend( + 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/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 8e5a2fd..e27e74d 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -174,7 +174,10 @@ export class AccountsRepositoryService { id: number, currentDate: Date, expiringSoonDate: Date, - ): Promise<{ licenseSummary: LicenseSummaryInfo; isStorageAvailable: boolean }> { + ): Promise<{ + licenseSummary: LicenseSummaryInfo; + isStorageAvailable: boolean; + }> { return await this.dataSource.transaction(async (entityManager) => { const license = entityManager.getRepository(License); const licenseOrder = entityManager.getRepository(LicenseOrder); diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 1182029..c5d7176 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -31,7 +31,7 @@ import { TypistUserNotMatchError, } from './errors/types'; import { Roles } from '../../common/types/role'; -import { TaskStatus } from '../../common/types/taskStatus'; +import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus'; @Injectable() export class TasksRepositoryService { @@ -88,13 +88,13 @@ export class TasksRepositoryService { /** * 音声ファイルIDに紐づいたTaskを取得する - * @param audioFileId + * @param audio_file_id * @param account_id * @param author_id * @returns task from author id */ async getTaskFromAudioFileId( - audioFileId: number, + audio_file_id: number, account_id: number, author_id: string, ): Promise { @@ -106,18 +106,23 @@ export class TasksRepositoryService { file: true, }, where: { - audio_file_id: audioFileId, - account_id: account_id, + audio_file_id: audio_file_id, }, }); if (!task) { throw new TasksNotFoundError( - `task not found. audio_file_id:${audioFileId}`, + `task not found. audio_file_id:${audio_file_id}`, + ); + } + // アカウントチェック + 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 (task.file?.author_id !== author_id) { throw new TaskAuthorIdNotMatchError( - `task authorId not match. audio_file_id:${audioFileId}, author_id:${author_id}, author_id(Task):${task.file?.author_id}`, + `task authorId not match. audio_file_id:${audio_file_id}, author_id:${author_id}, author_id(Task):${task.file?.author_id}`, ); } @@ -126,33 +131,44 @@ export class TasksRepositoryService { } /** * 音声ファイルIDに紐づいたTaskをCheckoutする - * @param audioFileId + * @param audio_file_id * @param account_id * @param user_id + * @param permittedSourceStatus * @returns checkout */ async checkout( - audioFileId: number, + audio_file_id: number, account_id: number, user_id: number, + permittedSourceStatus: TaskStatus[], ): Promise { await this.dataSource.transaction(async (entityManager) => { const taskRepo = entityManager.getRepository(Task); // 指定した音声ファイルIDに紐づくTaskの中でStatusが[Uploaded,Inprogress,Pending]であるものを取得 const task = await taskRepo.findOne({ where: { - audio_file_id: audioFileId, - account_id: account_id, - status: In([ - TASK_STATUS.UPLOADED, - TASK_STATUS.IN_PROGRESS, - TASK_STATUS.PENDING, - ]), + audio_file_id: audio_file_id, }, }); if (!task) { throw new TasksNotFoundError( - `task not found. audio_file_id:${audioFileId}`, + `task not found. audio_file_id:${audio_file_id}`, + ); + } + // アカウントチェック + 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 (!isTaskStatus(task.status)) { + throw new Error('invalid task status'); + } + // ステータスチェック + if (!permittedSourceStatus.includes(task.status)) { + throw new StatusNotMatchError( + `Unexpected task status. status:${task.status}`, ); } @@ -200,7 +216,7 @@ export class TasksRepositoryService { // 対象タスクの文字起こし開始日時を現在時刻に更新。割り当てユーザーを自身のユーザーIDに更新 // タスクのステータスがUploaded以外の場合、文字起こし開始時刻は更新しない await taskRepo.update( - { audio_file_id: audioFileId }, + { audio_file_id: audio_file_id }, { started_at: task.status === TASK_STATUS.UPLOADED @@ -225,14 +241,14 @@ export class TasksRepositoryService { } /** - * Params tasks repository service - * @param audioFileId チェックインするタスクの音声ファイルID + * 音声ファイルIDで指定したタスクをcheckinする + * @param audio_file_id チェックインするタスクの音声ファイルID * @param user_id チェックインするユーザーのID * @param permittedSourceStatus チェックイン可能なステータス * @returns checkin */ async checkin( - audioFileId: number, + audio_file_id: number, user_id: number, permittedSourceStatus: TaskStatus, ): Promise { @@ -240,12 +256,12 @@ export class TasksRepositoryService { const taskRepo = entityManager.getRepository(Task); const task = await taskRepo.findOne({ where: { - audio_file_id: audioFileId, + audio_file_id: audio_file_id, }, }); if (!task) { throw new TasksNotFoundError( - `task not found. audio_file_id:${audioFileId}`, + `task not found. audio_file_id:${audio_file_id}`, ); } if (task.status !== permittedSourceStatus) { @@ -261,7 +277,7 @@ export class TasksRepositoryService { // 対象タスクの文字起こし終了日時を現在時刻に更新。ステータスをFinishedに更新 await taskRepo.update( - { audio_file_id: audioFileId }, + { audio_file_id: audio_file_id }, { finished_at: new Date().toISOString(), status: TASK_STATUS.FINISHED, @@ -270,6 +286,51 @@ export class TasksRepositoryService { }); } + /** + * 音声ファイルIDで指定したタスクをsuspendする + * @param audio_file_id 文字起こし中断するタスクの音声ファイルID + * @param user_id 文字起こし中断するユーザーのID + * @param permittedSourceStatus 文字起こし中断可能なステータス + * @returns suspend + */ + async suspend( + audio_file_id: 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: audio_file_id, + }, + }); + if (!task) { + throw new TasksNotFoundError( + `task not found. audio_file_id:${audio_file_id}`, + ); + } + 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: audio_file_id }, + { + status: TASK_STATUS.PENDING, + }, + ); + }); + } + /** * 指定したアカウントIDに紐づくTask関連情報の一覧を取得します * @param account_id