Merged PR 219: タスクチェックインAPI実装

## 概要
[Task2118: タスクチェックインAPI実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2118)

- チェックインAPIの処理を実装
- テスト実装

## レビューポイント
- 文字起こし担当であるかどうかをチェックする方法についてどちらが良いか
  - チェックアウト権限テーブルで、タスクに紐づく割り当て候補を確認する(チェックアウトした時点で個人指定のみとなっているはず)
  - タスク情報にあるtypist_user_idで確認する
- テストケースは足りているか

## UIの変更

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

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
saito.k 2023-07-12 02:57:53 +00:00
parent 9c0f457e9f
commit 7be4da29bb
8 changed files with 292 additions and 18 deletions

View File

@ -0,0 +1,6 @@
import { TASK_STATUS } from '../../../constants';
/**
* Token.roleに配置されうる文字列リテラル型
*/
export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];

View File

@ -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';

View File

@ -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<ChangeStatusResponse> {
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 {};
}

View File

@ -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>(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>(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>(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

@ -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<void> {
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[],

View File

@ -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(),

View File

@ -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 {}

View File

@ -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<void> {
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