Merged PR 165: タイピスト割り当て変更API実装

## 概要
[Task1932: タイピスト割り当て変更API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1932)

- タイピスト割り当て変更APIを実装
- テスト実装

## レビューポイント
- IFのバリデーションを実装したがチェック内容はこれでよさそうか
- DBのデータ取得・更新処理は問題ないか
  - DBへアクセスする回数は問題ない程度か
- パスパラメータのバリデーションは問題ないか

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

## 動作確認状況
- ローカルで確認(swaggerUI,Postman)

## 補足
- 別途sqliteを用いたテストを実装する予定
This commit is contained in:
saito.k 2023-06-23 04:04:01 +00:00
parent a2c5c436e1
commit 6a1226c62e
24 changed files with 307 additions and 58 deletions

View File

@ -19,6 +19,6 @@
"editor.insertSpaces": false,
"editor.renderLineHighlight": "all",
"prettier.prettierPath": "./node_modules/prettier",
"typescript.preferences.importModuleSpecifier": "relative"
"typescript.preferences.importModuleSpecifier": "relative"
}

View File

@ -34,4 +34,5 @@ export const ErrorCodes = [
'E010302', // authorId重複エラー
'E010401', // PONumber重複エラー
'E010501', // アカウント不在エラー
'E010601', // タスク変更不可エラー
] as const;

View File

@ -23,4 +23,5 @@ export const errors: Errors = {
E010302: 'This AuthorId already used Error',
E010401: 'This PoNumber already used Error',
E010501: 'Account not Found Error.',
E010601: 'Task is not Editable Error',
};

View File

@ -0,0 +1,43 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
import { Assignee } from '../../features/tasks/types/types';
/**
* Validations options
* @param [validationOptions]
* @returns
*/
export const IsAssignees = (validationOptions?: ValidationOptions) => {
return (object: any, propertyName: string) => {
registerDecorator({
name: 'IsAssignees',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate: (values: Assignee[], args: ValidationArguments) => {
return values.every((value) => {
const { typistUserId, typistGroupId, typistName } = value;
if (typistUserId === undefined && typistGroupId === undefined) {
return false;
}
if (typistUserId !== undefined && typistGroupId !== undefined) {
return false;
}
if (!typistName) {
return false;
}
return true;
});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
defaultMessage: (args?: ValidationArguments): string => {
return 'Request body is invalid format';
},
},
});
};
};

View File

@ -1,10 +1,7 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SendGridService } from '../../gateways/sendgrid/sendgrid.service';
import {
UsersRepositoryService,
UserNotFoundError,
} from '../../repositories/users/users.repository.service';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service';
import {
AdB2cService,
@ -18,6 +15,7 @@ import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { TypistGroup } from './types/types';
import { GetLicenseSummaryResponse, Typist } from './types/types';
import { AccessToken } from '../../common/token';
import { UserNotFoundError } from '../../repositories/users/errors/types';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
@Injectable()

View File

@ -8,7 +8,7 @@ import {
} from './test/liscense.service.mock';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { HttpException, HttpStatus } from '@nestjs/common';
import { PoNumberAlreadyExistError } from '../../repositories/licenses/licenses.repository.service';
import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types';
describe('LicensesService', () => {
it('ライセンス注文が完了する', async () => {

View File

@ -1,18 +1,12 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { AccessToken } from '../../common/token';
import {
UsersRepositoryService,
UserNotFoundError,
} from '../../repositories/users/users.repository.service';
import {
AccountsRepositoryService,
AccountNotFoundError,
} from '../../repositories/accounts/accounts.repository.service';
import {
LicensesRepositoryService,
PoNumberAlreadyExistError,
} from '../../repositories/licenses/licenses.repository.service';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service';
import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types';
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { UserNotFoundError } from '../../repositories/users/errors/types';
@Injectable()
export class LicensesService {

View File

@ -5,6 +5,7 @@ import {
Headers,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
Req,
@ -425,13 +426,11 @@ export class TasksController {
)
async changeCheckoutPermission(
@Req() req: Request,
@Param(`audioFileId`) audioFileId: number,
@Param(`audioFileId`, ParseIntPipe) audioFileId: number,
@Body() body: PostCheckoutPermissionRequest,
): Promise<PostCheckoutPermissionResponse> {
const { assignees } = body;
console.log(req.header('Authorization'));
console.log(audioFileId);
console.log(assignees);
await this.taskService.changeCheckoutPermission(audioFileId, assignees);
return {};
}

View File

@ -7,6 +7,8 @@ import {
import { HttpException, HttpStatus } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service';
import { UserNotFoundError } from '../../repositories/users/errors/types';
import { TasksNotFoundError } from '../../repositories/tasks/errors/types';
describe('TasksService', () => {
it('タスク一覧を取得できるadmin', async () => {
@ -500,3 +502,46 @@ describe('TasksService', () => {
);
});
});
describe('TasksService', () => {
// TODO sqliteを用いたテストを別途実装予定
/*
Uploadedでない場合
*/
it('タスクのチェックアウト権限を変更できる', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
expect(await service.tasksService.changeCheckoutPermission(1, [])).toEqual(
undefined,
);
});
it('ユーザーが存在しない場合、タスクのチェックアウト権限を変更できない', async () => {
const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue();
tasksRepositoryMockValue.changeCheckoutPermission =
new TasksNotFoundError();
const service = await makeTasksServiceMock(
tasksRepositoryMockValue,
usersRepositoryMockValue,
adb2cServiceMockValue,
);
await expect(
service.tasksService.changeCheckoutPermission(1, []),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST),
);
});
});

View File

@ -1,7 +1,7 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { AccessToken } from '../../common/token';
import { Task } from './types/types';
import { Assignee, Task } from './types/types';
import { Task as TaskEntity } from '../../repositories/tasks/entity/task.entity';
import { createTasks } from './types/convert';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
@ -17,6 +17,11 @@ import {
} from '../../gateways/adb2c/adb2c.service';
import { AdB2cUser } from '../../gateways/adb2c/types/types';
import { CheckoutPermission } from '../../repositories/checkout_permissions/entity/checkout_permission.entity';
import { UserNotFoundError } from '../../repositories/users/errors/types';
import {
TasksNotFoundError,
TypistUserGroupNotFoundError,
} from '../../repositories/tasks/errors/types';
@Injectable()
export class TasksService {
@ -156,4 +161,47 @@ export class TasksService {
// B2Cからユーザー名を取得する
return await this.adB2cService.getUsers(filteredExternalIds);
}
/**
* Changes checkout permission
* @param audioFileId
* @param assignees
* @returns checkout permission
*/
async changeCheckoutPermission(
audioFileId: number,
assignees: Assignee[],
): Promise<void> {
try {
await this.taskRepository.changeCheckoutPermission(
audioFileId,
assignees,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case UserNotFoundError:
case TypistUserGroupNotFoundError:
throw new HttpException(
makeErrorResponse('E010204'),
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,
);
}
}
}

View File

@ -11,6 +11,7 @@ import {
} from '../../../common/types/sort';
import { AdB2cService } from '../../../gateways/adb2c/adb2c.service';
import { AdB2cUser } from '../../../gateways/adb2c/types/types';
import { Assignee } from '../types/types';
export type TasksRepositoryMockValue = {
getTasksFromAccountId:
@ -34,6 +35,7 @@ export type TasksRepositoryMockValue = {
count: number;
}
| Error;
changeCheckoutPermission: void | Error;
};
export type AdB2CServiceMockValue = {
@ -78,6 +80,7 @@ export const makeTasksRepositoryMock = (value: TasksRepositoryMockValue) => {
getTasksFromAccountId,
getTasksFromAuthorIdAndAccountId,
getTasksFromTypistRelations,
changeCheckoutPermission,
} = value;
return {
getTasksFromAccountId:
@ -145,6 +148,14 @@ export const makeTasksRepositoryMock = (value: TasksRepositoryMockValue) => {
[]
>()
.mockResolvedValue(getTasksFromTypistRelations),
changeCheckoutPermission:
changeCheckoutPermission instanceof Error
? jest
.fn<Promise<void>, []>()
.mockRejectedValue(changeCheckoutPermission)
: jest
.fn<Promise<void>, [number, Assignee[], number]>()
.mockResolvedValue(changeCheckoutPermission),
};
};
@ -165,6 +176,7 @@ export const makeDefaultTasksRepositoryMockValue =
getTasksFromAccountId: defaultTasksRepositoryMockValue,
getTasksFromAuthorIdAndAccountId: defaultTasksRepositoryMockValue,
getTasksFromTypistRelations: defaultTasksRepositoryMockValue,
changeCheckoutPermission: undefined,
};
};

View File

@ -1,10 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { AudioOptionItem } from '../../../features/files/types/types';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import {
IsArray,
IsIn,
IsInt,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
import { TASK_LIST_SORTABLE_ATTRIBUTES } from '../../../constants';
import { IsStatus } from '../../../common/validators/status.validator';
import { Typist } from '../../../features/accounts/types/types';
import { IsAssignees } from '../../../common/validators/assignees.validator';
export class TasksRequest {
@ApiProperty({
@ -66,11 +74,17 @@ export class Assignee {
required: false,
description: 'TypistIDTypistIDかTypistGroupIDのどちらかに値が入る',
})
@IsInt()
@Min(1)
@IsOptional()
typistUserId?: number | undefined;
@ApiProperty({
required: false,
description: 'TypistGroupIDTypistGroupIDかTypistIDのどちらかに値が入る',
})
@IsInt()
@Min(1)
@IsOptional()
typistGroupId?: number | undefined;
@ApiProperty({ description: 'Typist名 / TypistGroup名' })
typistName: string;
@ -200,6 +214,10 @@ export class PostCheckoutPermissionRequest {
description:
'文字起こしに着手可能(チェックアウト可能)にしたい、グループ個人の一覧',
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => Assignee)
@IsAssignees()
assignees: Assignee[];
}

View File

@ -16,10 +16,6 @@ import {
TaskListSortableAttribute,
} from '../../../common/types/sort';
export type CryptoMockValue = {
getPublicKey: string | Error;
};
export type SortCriteriaRepositoryMockValue = {
updateSortCriteria: SortCriteria | Error;
getSortCriteria: SortCriteria | Error;
@ -54,6 +50,10 @@ export type SendGridMockValue = {
sendMail: undefined | Error;
};
export type ConfigMockValue = {
get: string | Error;
};
export const makeUsersServiceMock = async (
usersRepositoryMockValue: UsersRepositoryMockValue,
adB2cMockValue: AdB2cMockValue,
@ -175,21 +175,6 @@ export const makeAdB2cServiceMock = (value: AdB2cMockValue) => {
};
};
export type ConfigMockValue = {
get: string | Error;
};
export const makeCryptoServiceMock = (value: CryptoMockValue) => {
const { getPublicKey } = value;
return {
getPublicKey:
getPublicKey instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getPublicKey)
: jest.fn<Promise<string>, []>().mockResolvedValue(getPublicKey),
};
};
class authorIdError extends Error {
constructor(public code: string, e?: string) {
super(e);

View File

@ -2,7 +2,6 @@ import { HttpException, HttpStatus } from '@nestjs/common';
import { AccessToken } from '../../common/token';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { User as EntityUser } from '../../repositories/users/entity/user.entity';
import { EmailAlreadyVerifiedError } from '../../repositories/users/users.repository.service';
import {
makeDefaultAdB2cMockValue,
makeDefaultConfigValue,
@ -12,6 +11,7 @@ import {
makeUsersServiceMock,
} from './test/users.service.mock';
import { User } from './types/types';
import { EmailAlreadyVerifiedError } from '../../repositories/users/errors/types';
describe('UsersService', () => {
it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになる', async () => {

View File

@ -19,11 +19,9 @@ import {
import { SendGridService } from '../../gateways/sendgrid/sendgrid.service';
import { SortCriteriaRepositoryService } from '../../repositories/sort_criteria/sort_criteria.repository.service';
import { User as EntityUser } from '../../repositories/users/entity/user.entity';
import {
EmailAlreadyVerifiedError,
UsersRepositoryService,
} from '../../repositories/users/users.repository.service';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { User } from './types/types';
import { EmailAlreadyVerifiedError } from '../../repositories/users/errors/types';
@Injectable()
export class UsersService {

View File

@ -22,8 +22,7 @@ import {
LICENSE_STATUS_ISSUE_REQUESTING,
} from '../../constants';
import { LicenseSummaryInfo } from '../../features/accounts/types/types';
export class AccountNotFoundError extends Error {}
import { AccountNotFoundError } from './errors/types';
@Injectable()
export class AccountsRepositoryService {

View File

@ -0,0 +1,2 @@
// アカウント未発見エラー
export class AccountNotFoundError extends Error {}

View File

@ -0,0 +1,2 @@
// POナンバーがすでに存在するエラー
export class PoNumberAlreadyExistError extends Error {}

View File

@ -5,8 +5,7 @@ import {
LICENSE_STATUS_ISSUE_REQUESTING,
LICENSE_STATUS_ISSUED,
} from '../../constants';
export class PoNumberAlreadyExistError extends Error {}
import { PoNumberAlreadyExistError } from './errors/types';
@Injectable()
export class LicensesRepositoryService {

View File

@ -6,7 +6,6 @@ import {
SortDirection,
} from '../../common/types/sort';
export class SortCriteriaNotFoundError extends Error {}
@Injectable()
export class SortCriteriaRepositoryService {
constructor(private dataSource: DataSource) {}

View File

@ -0,0 +1,6 @@
// タイピストグループ未発見エラー
export class TypistUserGroupNotFoundError extends Error {}
// タイピストユーザー未発見エラー
export class TypistUserNotFoundError extends Error {}
// タスク未発見エラー
export class TasksNotFoundError extends Error {}

View File

@ -4,6 +4,7 @@ import {
FindOptionsOrder,
FindOptionsOrderValue,
In,
IsNull,
} from 'typeorm';
import { Task } from './entity/task.entity';
import { TASK_STATUS } from '../../constants';
@ -16,6 +17,14 @@ import {
TaskListSortableAttribute,
} from '../../common/types/sort';
import { UserGroupMember } from '../user_groups/entity/user_group_member.entity';
import { Assignee } from '../../features/tasks/types/types';
import { UserGroup } from '../user_groups/entity/user_group.entity';
import { User } from '../users/entity/user.entity';
import {
TasksNotFoundError,
TypistUserGroupNotFoundError,
TypistUserNotFoundError,
} from './errors/types';
@Injectable()
export class TasksRepositoryService {
@ -369,6 +378,97 @@ export class TasksRepositoryService {
);
return createdEntity;
}
/**
* Changes checkout permission
* @param audioFileId
* @param assignees
* @param accountId
* @returns checkout permission
*/
async changeCheckoutPermission(
audioFileId: number,
assignees: Assignee[],
): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
// UserGroupの取得/存在確認
const userGroupIds = assignees
.filter((x) => x.typistGroupId !== undefined)
.map((y) => {
return y.typistGroupId;
});
const groupRepo = entityManager.getRepository(UserGroup);
const groupRecords = await groupRepo.find({
where: {
id: In(userGroupIds),
deleted_at: IsNull(),
},
});
// idはユニークであるため取得件数の一致でグループの存在を確認
if (userGroupIds.length !== groupRecords.length) {
throw new TypistUserGroupNotFoundError(
`Group not exists Error. reqUserGroupId:${userGroupIds}; resUserGroupId:${groupRecords.map(
(x) => x.id,
)}`,
);
}
// Userの取得/存在確認
const typistUserIds = assignees
.filter((x) => x.typistUserId !== undefined)
.map((y) => {
return y.typistUserId;
});
const userRepo = entityManager.getRepository(User);
const userRecords = await userRepo.find({
where: {
id: In(typistUserIds),
deleted_at: IsNull(),
},
});
// idはユニークであるため取得件数の一致でユーザーの存在を確認
if (typistUserIds.length !== userRecords.length) {
throw new TypistUserNotFoundError(
`User not exists Error. reqUserId:${typistUserIds}; resUserId:${userRecords.map(
(x) => x.id,
)}`,
);
}
// 引数audioFileIdを使ってTaskレコードを特定し、そのステータスを取得/存在確認
const taskRepo = entityManager.getRepository(Task);
const taskRecord = await taskRepo.findOne({
where: { audio_file_id: audioFileId, status: TASK_STATUS.UPLOADED },
});
//タスクが存在しない or ステータスがUploadedでなければエラー
if (!taskRecord) {
throw new TasksNotFoundError(
`Task not found Error. audio_file_id:${audioFileId}`,
);
}
// 当該タスクに紐づく既存checkoutPermissionをdelete
const checkoutPermissionRepo =
entityManager.getRepository(CheckoutPermission);
await checkoutPermissionRepo.delete({
task_id: taskRecord.id,
});
// 当該タスクに紐づく新規checkoutPermissionをinsert
const checkoutPermissions: CheckoutPermission[] = assignees.map(
(assignee) => {
const checkoutPermission = new CheckoutPermission();
checkoutPermission.task_id = taskRecord.id;
checkoutPermission.user_id = assignee.typistUserId;
checkoutPermission.user_group_id = assignee.typistGroupId;
return checkoutPermission;
},
);
return await checkoutPermissionRepo.save(checkoutPermissions);
});
}
}
// ソート用オブジェクトを生成する

View File

@ -0,0 +1,4 @@
// Email検証済みエラー
export class EmailAlreadyVerifiedError extends Error {}
// ユーザー未発見エラー
export class UserNotFoundError extends Error {}

View File

@ -6,13 +6,9 @@ import {
getDirection,
getTaskListSortableAttribute,
} from '../../common/types/sort/util';
import { UserNotFoundError, EmailAlreadyVerifiedError } from './errors/types';
import { USER_ROLES } from '../../constants';
// UsersRepositoryServiceで発生するエラーを定義
export class EmailAlreadyVerifiedError extends Error {}
export class UserNotFoundError extends Error {}
export class AuthorIdAlreadyExistError extends Error {}
@Injectable()
export class UsersRepositoryService {
constructor(private dataSource: DataSource) {}