Merged PR 178: API実装(タスクチェックアウトAPI (Typist))

## 概要
[Task1996: API実装(タスクチェックアウトAPI (Typist))](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1996)

- タスクチェックアウトAPIのTypist用の処理を実装
- テスト実装

## レビューポイント
- DBアクセス処理に不足はないか
- テスト内容に不足はないか
- テストのチェック方法に問題はないか
  - 特に今回の「started_at」はcheckoutした日時を入れるが、それがいつなのかを完全一致でチェックするのは大変なため、checkout前とcheckout後で値が異なっていることを確認するまでのチェックとした
- changeCheckoutPermissionsのPathパラメータにつけたコメントについて
## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認
- テストが通ることを確認

## 補足
- 「タスク 1476: [Sp12-1]アクセストークンの寿命を2時間にする」も実施しています
This commit is contained in:
saito.k 2023-07-03 01:09:06 +00:00
parent f47a686bac
commit 1189e676b9
14 changed files with 592 additions and 38 deletions

View File

@ -6,7 +6,7 @@ DB_ROOT_PASS=omdsdbpass
DB_USERNAME=omdsdbuser
DB_PASSWORD=omdsdbpass
NO_COLOR=TRUE
ACCESS_TOKEN_LIFETIME_WEB=1600000
ACCESS_TOKEN_LIFETIME_WEB=7200000
REFRESH_TOKEN_LIFETIME_WEB=86400000
REFRESH_TOKEN_LIFETIME_DEFAULT=2592000000
TENANT_NAME=adb2codmsdev

View File

@ -1317,6 +1317,7 @@
"name": "audioFileId",
"required": true,
"in": "path",
"description": "ODMS Cloud上の音声ファイルID",
"schema": { "type": "number" }
}
],

View File

@ -34,5 +34,6 @@ export const ErrorCodes = [
'E010302', // authorId重複エラー
'E010401', // PONumber重複エラー
'E010501', // アカウント不在エラー
'E010601', // タスク変更不可エラー
'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
'E010602', // タスク変更権限不足エラー
] as const;

View File

@ -24,4 +24,5 @@ export const errors: Errors = {
E010401: 'This PoNumber already used Error',
E010501: 'Account not Found Error.',
E010601: 'Task is not Editable Error',
E010602: 'No task edit permissions Error',
};

View File

@ -0,0 +1,2 @@
// ロール不正エラー
export class InvalidRoleError extends Error {}

View File

@ -16,6 +16,7 @@ import {
ApiOperation,
ApiTags,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { ErrorResponse } from '../../common/error/types/types';
import { Request } from 'express';
@ -170,12 +171,19 @@ export class TasksController {
})
@ApiBearerAuth()
async checkout(
@Headers() headers,
@Param() params: ChangeStatusRequest,
@Req() req: Request,
@Param() param: ChangeStatusRequest,
): Promise<ChangeStatusResponse> {
const { audioFileId } = params;
console.log(audioFileId);
// AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない
const accessToken = retrieveAuthorizationToken(req);
const { role, userId } = jwt.decode(accessToken, {
json: true,
}) as AccessToken;
// RoleGuardでroleの文字列に想定外の文字列や重複がないことは担保されているためここでは型変換のみ行う
const roles = role.split(' ') as Roles[];
await this.taskService.checkout(param.audioFileId, roles, userId);
return {};
}
@ -420,6 +428,11 @@ export class TasksController {
operationId: 'changeCheckoutPermission',
description: '指定した文字起こしタスクのチェックアウト候補を変更します。',
})
@ApiParam({
name: 'audioFileId',
required: true,
description: 'ODMS Cloud上の音声ファイルID',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
@ -427,7 +440,9 @@ export class TasksController {
)
async changeCheckoutPermission(
@Req() req: Request,
@Param(`audioFileId`, ParseIntPipe) audioFileId: number,
//TODOcheckoutやcheckinと同じパスパラメータなので記述方法を統一したい
@Param(`audioFileId`, ParseIntPipe)
audioFileId: number,
@Body() body: PostCheckoutPermissionRequest,
): Promise<PostCheckoutPermissionResponse> {
const { assignees } = body;
@ -436,7 +451,7 @@ export class TasksController {
const { role, userId } = jwt.decode(accessToken, {
json: true,
}) as AccessToken;
// RoleGuardでroleの要素が正しい値であることは担保されているためここでは型変換のみ行う
// RoleGuardでroleの文字列に想定外の文字列や重複がないことは担保されているためここでは型変換のみ行う
const roles = role.split(' ') as Roles[];
await this.taskService.changeCheckoutPermission(

View File

@ -8,7 +8,15 @@ import { HttpException, HttpStatus } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { TasksService } from './tasks.service';
import { DataSource } from 'typeorm';
import { createAccount, createTask, createUser } from './test/utility';
import {
createAccount,
createCheckoutPermissions,
createTask,
createUser,
createUserGroup,
getCheckoutPermissions,
getTask,
} from './test/utility';
import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service';
import { makeTestingModule } from '../../common/test/modules';
import { TasksNotFoundError } from '../../repositories/tasks/errors/types';
@ -723,3 +731,280 @@ describe('changeCheckoutPermission', () => {
);
});
});
describe('checkout', () => {
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('ユーザーのRoleがTypistで、タスクのチェックアウト権限が個人指定である時、タスクをチェックアウトできる', 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',
);
const { userGroupId } = await createUserGroup(
source,
accountId,
'USER_GROUP_A',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
await createCheckoutPermissions(source, taskId, undefined, userGroupId);
const service = module.get<TasksService>(TasksService);
const initTask = await getTask(source, taskId);
await service.checkout(1, ['typist'], 'typist-user-external-id');
const { status, typist_user_id, started_at } = await getTask(
source,
taskId,
);
const permisions = await getCheckoutPermissions(source, taskId);
expect(status).toEqual('InProgress');
expect(typist_user_id).toEqual(typistUserId);
expect(started_at).not.toEqual(initTask.started_at);
expect(permisions.length).toEqual(1);
expect(permisions[0]).toEqual({
id: 3,
task_id: 1,
user_id: 1,
user_group_id: null,
});
}, 600000);
it('ユーザーのRoleがTypistで、タスクのチェックアウト権限がグループ指定である時、タスクをチェックアウトできる', 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',
);
const { userGroupId } = await createUserGroup(
source,
accountId,
'USER_GROUP_A',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
await createCheckoutPermissions(source, taskId, undefined, userGroupId);
const service = module.get<TasksService>(TasksService);
const initTask = await getTask(source, taskId);
await service.checkout(1, ['typist'], 'typist-user-external-id');
const { status, typist_user_id, started_at } = await getTask(
source,
taskId,
);
const permisions = await getCheckoutPermissions(source, taskId);
expect(status).toEqual('InProgress');
expect(typist_user_id).toEqual(typistUserId);
expect(started_at).not.toEqual(initTask.started_at);
expect(permisions.length).toEqual(1);
expect(permisions[0]).toEqual({
id: 3,
task_id: 1,
user_id: 1,
user_group_id: null,
});
});
it('ユーザーのRoleがTypistで、タスクのステータスがPendingである時、タスクをチェックアウトできる', 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',
'Pending',
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const initTask = await getTask(source, taskId);
await service.checkout(1, ['typist'], 'typist-user-external-id');
const { status, typist_user_id, started_at } = await getTask(
source,
taskId,
);
const permisions = await getCheckoutPermissions(source, taskId);
expect(status).toEqual('InProgress');
expect(typist_user_id).toEqual(typistUserId);
//タスクの元々のステータスがPending,Inprogressの場合、文字起こし開始時刻は更新されない
expect(started_at).toEqual(initTask.started_at);
expect(permisions.length).toEqual(1);
expect(permisions[0]).toEqual({
id: 2,
task_id: 1,
user_id: 1,
user_group_id: null,
});
});
it('ユーザーのRoleがTypistで、対象のタスクのStatus[Uploaded,Inprogress,Pending]以外の時、タスクをチェックアウトできない', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
await createUser(
source,
accountId,
'typist-user-external-id',
'typist',
'MY_AUTHOR_ID',
);
const { userId: authorUserId } = await createUser(
source,
accountId,
'author-user-external-id',
'author',
'MY_AUTHOR_ID',
);
await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Backup',
);
const service = module.get<TasksService>(TasksService);
await expect(
service.checkout(1, ['typist'], 'typist-user-external-id'),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST),
);
});
it('ユーザーのRoleがTypistで、チェックアウト権限が存在しない時、タスクをチェックアウトできない', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
await createUser(
source,
accountId,
'typist-user-external-id',
'typist',
'MY_AUTHOR_ID',
);
const { userId: authorUserId } = await createUser(
source,
accountId,
'author-user-external-id',
'author',
'MY_AUTHOR_ID',
);
await createTask(
source,
accountId,
authorUserId,
'MY_AUTHOR_ID',
'',
'01',
'00000001',
'Uploaded',
);
const service = module.get<TasksService>(TasksService);
await expect(
service.checkout(1, ['typist'], 'typist-user-external-id'),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010602'), HttpStatus.BAD_REQUEST),
);
});
it('ユーザーのRoleに[Typist,author]が設定されていない時、タスクをチェックアウトできない', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
await createUser(
source,
accountId,
'none-user-external-id',
'none',
'MY_AUTHOR_ID',
);
const service = module.get<TasksService>(TasksService);
await expect(
service.checkout(1, ['none'], 'none-user-external-id'),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010602'), HttpStatus.BAD_REQUEST),
);
});
});

View File

@ -18,11 +18,13 @@ import {
import { AdB2cUser } from '../../gateways/adb2c/types/types';
import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity';
import {
CheckoutPermissionNotFoundError,
TasksNotFoundError,
TypistUserGroupNotFoundError,
TypistUserNotFoundError,
} from '../../repositories/tasks/errors/types';
import { Roles } from '../../common/types/role';
import { InvalidRoleError } from './errors/types';
@Injectable()
export class TasksService {
@ -132,6 +134,59 @@ export class TasksService {
);
}
}
/**
* checkoutする
* @param audioFileId
* @param roles
* @param externalId
* @returns checkout
*/
async checkout(
audioFileId: number,
roles: Roles[],
externalId: string,
): Promise<void> {
try {
const { id, account_id } =
await this.usersRepository.findUserByExternalId(externalId);
// TODO authorの処理は別タスクで対応
if (roles.includes(USER_ROLES.AUTHOR)) {
}
if (roles.includes(USER_ROLES.TYPIST)) {
return await this.taskRepository.checkout(audioFileId, account_id, id);
}
throw new InvalidRoleError(`invalid roles: ${roles.join(',')}`);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case CheckoutPermissionNotFoundError:
case InvalidRoleError:
throw new HttpException(
makeErrorResponse('E010602'),
HttpStatus.BAD_REQUEST,
);
case TasksNotFoundError:
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

@ -3,6 +3,9 @@ import { User } from '../../../repositories/users/entity/user.entity';
import { Account } from '../../../repositories/accounts/entity/account.entity';
import { Task } from '../../../repositories/tasks/entity/task.entity';
import { AudioFile } from '../../../repositories/audio_files/entity/audio_file.entity';
import { CheckoutPermission } from '../../../repositories/checkout_permissions/entity/checkout_permission.entity';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
export const createAccount = async (
datasource: DataSource,
@ -59,31 +62,114 @@ export const createTask = async (
priority: string,
jobNumber: string,
status: string,
): Promise<{ taskId: number }> => {
const { identifiers: audioFileIdentifiers } = await datasource
.getRepository(AudioFile)
.insert({
account_id: account_id,
owner_user_id: owner_user_id,
url: '',
file_name: 'x.zip',
author_id: author_id,
work_type_id: work_type_id,
started_at: new Date(),
duration: '100000',
finished_at: new Date(),
uploaded_at: new Date(),
file_size: 10000,
priority: priority,
audio_format: 'audio_format',
is_encrypted: true,
});
const audioFile = audioFileIdentifiers.pop() as AudioFile;
const { identifiers: taskIdentifiers } = await datasource
.getRepository(Task)
.insert({
job_number: jobNumber,
account_id: account_id,
is_job_number_enabled: true,
audio_file_id: audioFile.id,
status: status,
priority: priority,
started_at: new Date().toISOString(),
created_at: new Date(),
});
const task = taskIdentifiers.pop() as Task;
return { taskId: task.id };
};
/**
*
* @param datasource
* @param task_id
* @param user_id
* @param user_group_id
*
*/
export const createCheckoutPermissions = async (
datasource: DataSource,
task_id: number,
user_id?: number,
user_group_id?: number,
): Promise<void> => {
const { identifiers } = await datasource.getRepository(AudioFile).insert({
account_id: account_id,
owner_user_id: owner_user_id,
url: '',
file_name: 'x.zip',
author_id: author_id,
work_type_id: work_type_id,
started_at: new Date(),
duration: '100000',
finished_at: new Date(),
uploaded_at: new Date(),
file_size: 10000,
priority: priority,
audio_format: 'audio_format',
is_encrypted: true,
});
const audioFile = identifiers.pop() as AudioFile;
await datasource.getRepository(Task).insert({
job_number: jobNumber,
account_id: account_id,
is_job_number_enabled: true,
audio_file_id: audioFile.id,
status: status,
priority: priority,
created_at: new Date(),
await datasource.getRepository(CheckoutPermission).insert({
task_id: task_id,
user_id: user_id,
user_group_id: user_group_id,
});
};
/**
*
* @param datasource
* @param account_id
* @param user_group_name
* @param user_id
* @returns
*/
export const createUserGroup = async (
datasource: DataSource,
account_id: number,
user_group_name: string,
user_id: number,
): Promise<{ userGroupId: number }> => {
const { identifiers: userGroupIdentifiers } = await datasource
.getRepository(UserGroup)
.insert({
account_id: account_id,
name: user_group_name,
created_by: 'test',
updated_by: 'test',
});
const userGroup = userGroupIdentifiers.pop() as UserGroup;
await datasource.getRepository(UserGroupMember).insert({
user_group_id: userGroup.id,
user_id: user_id,
created_by: 'test',
updated_by: 'test',
});
return { userGroupId: userGroup.id };
};
export const getTask = async (
datasource: DataSource,
task_id: number,
): Promise<Task> => {
const task = await datasource.getRepository(Task).findOne({
where: {
id: task_id,
},
});
return task;
};
export const getCheckoutPermissions = async (
datasource: DataSource,
task_id: number,
): Promise<CheckoutPermission[]> => {
const permissions = await datasource.getRepository(CheckoutPermission).find({
where: {
task_id: task_id,
},
});
return permissions;
};

View File

@ -203,6 +203,9 @@ export class AudioNextResponse {
export class ChangeStatusRequest {
@ApiProperty({ description: 'ODMS Cloud上の音声ファイルID' })
@Type(() => Number)
@IsInt()
@Min(1)
audioFileId: number;
}

View File

@ -15,13 +15,13 @@ export class CheckoutPermission {
@PrimaryGeneratedColumn()
id: number;
@Column()
@Column({})
task_id: number;
@Column()
@Column({ nullable: true })
user_id?: number;
@Column()
@Column({ nullable: true })
user_group_id?: number;
@OneToOne(() => User, (user) => user.id)

View File

@ -4,3 +4,5 @@ export class TypistUserGroupNotFoundError extends Error {}
export class TypistUserNotFoundError extends Error {}
// タスク未発見エラー
export class TasksNotFoundError extends Error {}
// チェックアウト権限未発見エラー
export class CheckoutPermissionNotFoundError extends Error {}

View File

@ -21,6 +21,7 @@ import { Assignee } from '../../features/tasks/types/types';
import { UserGroup } from '../user_groups/entity/user_group.entity';
import { User } from '../users/entity/user.entity';
import {
CheckoutPermissionNotFoundError,
TasksNotFoundError,
TypistUserGroupNotFoundError,
TypistUserNotFoundError,
@ -30,6 +31,108 @@ import { Roles } from '../../common/types/role';
@Injectable()
export class TasksRepositoryService {
constructor(private dataSource: DataSource) {}
async checkout(
audioFileId: number,
account_id: number,
user_id: number,
): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
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,
status: In([
TASK_STATUS.UPLOADED,
TASK_STATUS.IN_PROGRESS,
TASK_STATUS.PENDING,
]),
},
});
if (!task) {
throw new TasksNotFoundError(
`task not found. audio_file_id:${audioFileId}`,
);
}
const groupMemberRepo = entityManager.getRepository(UserGroupMember);
// ユーザーの所属するすべてのグループを列挙
const groups = await groupMemberRepo.find({
relations: {
user: true,
},
where: {
user: {
id: user_id,
},
},
});
// ユーザーの所属するすべてのグループIDを列挙
const groupIds = groups.map((member) => member.user_group_id);
const checkoutRepo = entityManager.getRepository(CheckoutPermission);
// 対象タスクに紐づくユーザーが含まれるチェックアウト権限を取得する
const related = await checkoutRepo.find({
relations: {
task: true,
},
where: [
{
task_id: task.id,
// ユーザーがチェックアウト可能である
user: {
id: user_id,
},
},
{
task_id: task.id,
// ユーザーの所属するユーザーグループがチェックアウト可能である
user_group_id: In(groupIds),
},
],
});
//チェックアウト権限がなければエラー
if (related.length === 0) {
throw new CheckoutPermissionNotFoundError(
`Checkout Permission not found. task_id:${task.id}, user_id:${user_id}, user_group_id:${groupIds}`,
);
}
// 対象タスクの文字起こし開始日時を現在時刻に更新。割り当てユーザーを自身のユーザーIDに更新
// タスクのステータスがUploaded以外の場合、文字起こし開始時刻は更新しない
await taskRepo.update(
{ audio_file_id: audioFileId },
{
started_at:
task.status === TASK_STATUS.UPLOADED
? new Date().toISOString()
: undefined,
typist_user_id: user_id,
status: TASK_STATUS.IN_PROGRESS,
},
);
//対象のタスクに紐づくチェックアウト権限レコードを削除
await checkoutRepo.delete({
task_id: task.id,
});
//対象のタスクチェックアウト権限を自身のユーザーIDで作成
await checkoutRepo.save({
task_id: task.id,
user_id: user_id,
});
});
}
/**
* IDに紐づくTask関連情報の一覧を取得します
* @param account_id

View File

@ -16,7 +16,7 @@ export class UserGroupMember {
user_group_id: number;
@Column()
user_id: string;
user_id: number;
@Column({ nullable: true })
deleted_at?: Date;