Merged PR 224: タスク中断API実装

## 概要
[Task2119: タスク中断API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2119)

- タスク中断APIの処理を実装
- テスト実装
- チェックアウト処理のエラーチェックを修正
  - エラーが発生したときに何が原因なのかログに出力するように修正

## レビューポイント
- チェックインと同様の処理部分を切り出さずにそのまま実装したが、スマートに切り出せる方法はありそうか。
  - チェックインとサスペンドの取得処理だけ切り出してもあまりうれしくない(この2つ以外のところで使えなさそう)
    - キャンセルでは使えるかもだけど
  - チェックアウトやその他のメソッドのタスク取得とまとめようとすると、チェック内容や検索条件に差異がありきれいに切り出すことができなさそう。
    - まとめようとすると、引数が膨大でチェック項目も複雑になる

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認

## 補足
- コンフリクトが発生しているが、現在のdevelopを取り込むとエラーになるので解消し次第、取り込んで競合解決します
- チェックインAPIの実装も入っていますが、そこは別のPRでレビューを行っているので対象外となります
This commit is contained in:
saito.k 2023-07-12 08:27:46 +00:00
parent 7be4da29bb
commit e4ba5229df
6 changed files with 311 additions and 28 deletions

View File

@ -4,3 +4,11 @@ import { TASK_STATUS } from '../../../constants';
* Token.roleに配置されうる文字列リテラル型 * Token.roleに配置されうる文字列リテラル型
*/ */
export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; 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;
};

View File

@ -320,13 +320,24 @@ export class TasksController {
'指定した文字起こしタスクを一時中断しますステータスをPendingにします', '指定した文字起こしタスクを一時中断しますステータスをPendingにします',
}) })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({
roles: [USER_ROLES.TYPIST],
}),
)
async suspend( async suspend(
@Headers() headers, @Req() req: Request,
@Param() params: ChangeStatusRequest, @Param() params: ChangeStatusRequest,
): Promise<ChangeStatusResponse> { ): Promise<ChangeStatusResponse> {
const { audioFileId } = params; 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 {}; return {};
} }

View File

@ -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>(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>(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>(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>(TasksService);
await expect(service.checkin(1, 'typist-user-external-id')).rejects.toEqual(
new HttpException(makeErrorResponse('E010603'), HttpStatus.BAD_REQUEST),
);
});
});

View File

@ -18,6 +18,7 @@ import {
import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { AdB2cUser } from '../../gateways/adb2c/types/types';
import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity'; import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity';
import { import {
AccountNotMatchError,
CheckoutPermissionNotFoundError, CheckoutPermissionNotFoundError,
StatusNotMatchError, StatusNotMatchError,
TaskAuthorIdNotMatchError, TaskAuthorIdNotMatchError,
@ -163,7 +164,11 @@ export class TasksService {
} }
if (roles.includes(USER_ROLES.TYPIST)) { 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(',')}`); throw new InvalidRoleError(`invalid roles: ${roles.join(',')}`);
@ -179,6 +184,8 @@ export class TasksService {
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
case TasksNotFoundError: case TasksNotFoundError:
case AccountNotMatchError:
case StatusNotMatchError:
throw new HttpException( throw new HttpException(
makeErrorResponse('E010601'), makeErrorResponse('E010601'),
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
@ -243,6 +250,52 @@ export class TasksService {
} }
} }
/**
* suspendする
* @param audioFileId
* @param externalId
* @returns suspend
*/
async suspend(audioFileId: number, externalId: string): Promise<void> {
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( private async getB2cUsers(
tasks: TaskEntity[], tasks: TaskEntity[],
permissions: CheckoutPermission[], permissions: CheckoutPermission[],

View File

@ -174,7 +174,10 @@ export class AccountsRepositoryService {
id: number, id: number,
currentDate: Date, currentDate: Date,
expiringSoonDate: Date, expiringSoonDate: Date,
): Promise<{ licenseSummary: LicenseSummaryInfo; isStorageAvailable: boolean }> { ): Promise<{
licenseSummary: LicenseSummaryInfo;
isStorageAvailable: boolean;
}> {
return await this.dataSource.transaction(async (entityManager) => { return await this.dataSource.transaction(async (entityManager) => {
const license = entityManager.getRepository(License); const license = entityManager.getRepository(License);
const licenseOrder = entityManager.getRepository(LicenseOrder); const licenseOrder = entityManager.getRepository(LicenseOrder);

View File

@ -31,7 +31,7 @@ import {
TypistUserNotMatchError, TypistUserNotMatchError,
} from './errors/types'; } from './errors/types';
import { Roles } from '../../common/types/role'; import { Roles } from '../../common/types/role';
import { TaskStatus } from '../../common/types/taskStatus'; import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus';
@Injectable() @Injectable()
export class TasksRepositoryService { export class TasksRepositoryService {
@ -88,13 +88,13 @@ export class TasksRepositoryService {
/** /**
* IDに紐づいたTaskを取得する * IDに紐づいたTaskを取得する
* @param audioFileId * @param audio_file_id
* @param account_id * @param account_id
* @param author_id * @param author_id
* @returns task from author id * @returns task from author id
*/ */
async getTaskFromAudioFileId( async getTaskFromAudioFileId(
audioFileId: number, audio_file_id: number,
account_id: number, account_id: number,
author_id: string, author_id: string,
): Promise<Task> { ): Promise<Task> {
@ -106,18 +106,23 @@ export class TasksRepositoryService {
file: true, file: true,
}, },
where: { where: {
audio_file_id: audioFileId, audio_file_id: audio_file_id,
account_id: account_id,
}, },
}); });
if (!task) { if (!task) {
throw new TasksNotFoundError( 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) { if (task.file?.author_id !== author_id) {
throw new TaskAuthorIdNotMatchError( 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する * IDに紐づいたTaskをCheckoutする
* @param audioFileId * @param audio_file_id
* @param account_id * @param account_id
* @param user_id * @param user_id
* @param permittedSourceStatus
* @returns checkout * @returns checkout
*/ */
async checkout( async checkout(
audioFileId: number, audio_file_id: number,
account_id: number, account_id: number,
user_id: number, user_id: number,
permittedSourceStatus: TaskStatus[],
): Promise<void> { ): Promise<void> {
await this.dataSource.transaction(async (entityManager) => { await this.dataSource.transaction(async (entityManager) => {
const taskRepo = entityManager.getRepository(Task); const taskRepo = entityManager.getRepository(Task);
// 指定した音声ファイルIDに紐づくTaskの中でStatusが[Uploaded,Inprogress,Pending]であるものを取得 // 指定した音声ファイルIDに紐づくTaskの中でStatusが[Uploaded,Inprogress,Pending]であるものを取得
const task = await taskRepo.findOne({ const task = await taskRepo.findOne({
where: { where: {
audio_file_id: audioFileId, audio_file_id: audio_file_id,
account_id: account_id,
status: In([
TASK_STATUS.UPLOADED,
TASK_STATUS.IN_PROGRESS,
TASK_STATUS.PENDING,
]),
}, },
}); });
if (!task) { if (!task) {
throw new TasksNotFoundError( 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に更新 // 対象タスクの文字起こし開始日時を現在時刻に更新。割り当てユーザーを自身のユーザーIDに更新
// タスクのステータスがUploaded以外の場合、文字起こし開始時刻は更新しない // タスクのステータスがUploaded以外の場合、文字起こし開始時刻は更新しない
await taskRepo.update( await taskRepo.update(
{ audio_file_id: audioFileId }, { audio_file_id: audio_file_id },
{ {
started_at: started_at:
task.status === TASK_STATUS.UPLOADED task.status === TASK_STATUS.UPLOADED
@ -225,14 +241,14 @@ export class TasksRepositoryService {
} }
/** /**
* Params tasks repository service * IDで指定したタスクをcheckinする
* @param audioFileId ID * @param audio_file_id ID
* @param user_id ID * @param user_id ID
* @param permittedSourceStatus * @param permittedSourceStatus
* @returns checkin * @returns checkin
*/ */
async checkin( async checkin(
audioFileId: number, audio_file_id: number,
user_id: number, user_id: number,
permittedSourceStatus: TaskStatus, permittedSourceStatus: TaskStatus,
): Promise<void> { ): Promise<void> {
@ -240,12 +256,12 @@ export class TasksRepositoryService {
const taskRepo = entityManager.getRepository(Task); const taskRepo = entityManager.getRepository(Task);
const task = await taskRepo.findOne({ const task = await taskRepo.findOne({
where: { where: {
audio_file_id: audioFileId, audio_file_id: audio_file_id,
}, },
}); });
if (!task) { if (!task) {
throw new TasksNotFoundError( throw new TasksNotFoundError(
`task not found. audio_file_id:${audioFileId}`, `task not found. audio_file_id:${audio_file_id}`,
); );
} }
if (task.status !== permittedSourceStatus) { if (task.status !== permittedSourceStatus) {
@ -261,7 +277,7 @@ export class TasksRepositoryService {
// 対象タスクの文字起こし終了日時を現在時刻に更新。ステータスをFinishedに更新 // 対象タスクの文字起こし終了日時を現在時刻に更新。ステータスをFinishedに更新
await taskRepo.update( await taskRepo.update(
{ audio_file_id: audioFileId }, { audio_file_id: audio_file_id },
{ {
finished_at: new Date().toISOString(), finished_at: new Date().toISOString(),
status: TASK_STATUS.FINISHED, 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<void> {
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関連情報の一覧を取得します * IDに紐づくTask関連情報の一覧を取得します
* @param account_id * @param account_id