Merged PR 375: API実装(ワークタイプID追加API)

## 概要
[Task2516: API実装(ワークタイプID追加API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2516)

- ワークタイプ追加APIとテストを実装しました。
  - オプションアイテムも一緒に追加されるように実装しています。
  - ワークタイプの制限のためにカスタムバリデータを実装しています。

## レビューポイント
- 追加時のエラー処理は適切か
- バリデータは適切か

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-09-04 07:08:19 +00:00
parent 60269306c5
commit 0b7d979fae
16 changed files with 436 additions and 5 deletions

View File

@ -41,6 +41,7 @@ import { UserGroupsRepositoryModule } from './repositories/user_groups/user_grou
import { SortCriteriaRepositoryModule } from './repositories/sort_criteria/sort_criteria.repository.module';
import { TemplateFilesRepositoryModule } from './repositories/template_files/template_files.repository.module';
import { WorktypesRepositoryModule } from './repositories/worktypes/worktypes.repository.module';
import { OptionItemsRepositoryModule } from './repositories/option_items/option_items.repository.module';
@Module({
imports: [
@ -96,6 +97,7 @@ import { WorktypesRepositoryModule } from './repositories/worktypes/worktypes.re
AuthGuardsModule,
SortCriteriaRepositoryModule,
WorktypesRepositoryModule,
OptionItemsRepositoryModule,
],
controllers: [
HealthController,

View File

@ -49,4 +49,6 @@ export const ErrorCodes = [
'E010807', // ライセンス割り当て解除済みエラー
'E010808', // ライセンス注文キャンセル不可エラー
'E010908', // タイピストグループ不在エラー
'E011001', // ワークタイプ重複エラー
'E011002', // ワークタイプ登録上限超過エラー
] as const;

View File

@ -38,4 +38,6 @@ export const errors: Errors = {
E010807: 'License is already deallocated Error',
E010808: 'Order cancel failed Error',
E010908: 'Typist Group not exist Error',
E011001: 'Thiw WorkTypeID already used Error',
E011002: 'WorkTypeID create limit exceeded Error',
};

View File

@ -30,6 +30,7 @@ import { NotificationhubService } from '../../gateways/notificationhub/notificat
import { FilesService } from '../../features/files/files.service';
import { LicensesService } from '../../features/licenses/licenses.service';
import { TasksService } from '../../features/tasks/tasks.service';
import { OptionItemsRepositoryModule } from '../../repositories/option_items/option_items.repository.module';
export const makeTestingModule = async (
datasource: DataSource,
@ -65,6 +66,7 @@ export const makeTestingModule = async (
AuthGuardsModule,
SortCriteriaRepositoryModule,
WorktypesRepositoryModule,
OptionItemsRepositoryModule,
],
providers: [
AuthService,

View File

@ -0,0 +1,50 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
ValidationOptions,
registerDecorator,
} from 'class-validator';
@ValidatorConstraint()
export class IsWorktypeIdCharacters implements ValidatorConstraintInterface {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate(value: string) {
// 正規表現でWorktypeIDのチェックを行う
// 以下の禁則文字を除く半角英数記号
// \ (backslash)
// / (forward slash)
// : (colon)
// * (asterisk)
// ? (question mark)
// " (double quotation mark)
// < (less-than symbol)
// > (greater-than symbol)
// | (vertical bar)
// . (period)
const regex =
/^(?!.*\\)(?!.*\/)(?!.*:)(?!.*\*)(?!.*\?)(?!.*")(?!.*<)(?!.*>)(?!.*\|)(?!.*\.)[ -~]+$/;
return regex.test(value);
}
defaultMessage(args: ValidationArguments) {
return `WorktypeID rule not satisfied`;
}
}
/**
* WorktypeIDで使用できる文字列かをチェックする
* @param [validationOptions]
* @returns
*/
export function IsWorktypeId(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsWorktypeId',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: IsWorktypeIdCharacters,
});
};
}

View File

@ -223,3 +223,18 @@ export const TRIAL_LICENSE_EXPIRATION_DAYS = 30;
* @const {number}
*/
export const TRIAL_LICENSE_ISSUE_NUM = 100;
/**
* worktypeの最大登録数
* @const {number}
*/
export const WORKTYPE_MAX_COUNT = 20;
/**
* worktypeのDefault値の取りうる値
**/
export const OPTION_ITEM_VALUE_TYPE = {
DEFAULT: 'Default',
BLANK: 'Blank',
LAST_INPUT: 'LastInput',
} as const;

View File

@ -706,9 +706,12 @@ export class AccountsController {
const { userId } = jwt.decode(token, { json: true }) as AccessToken;
const context = makeContext(userId);
console.log(context.trackingId);
console.log(worktypeId);
console.log(description);
await this.accountService.createWorktype(
context,
userId,
worktypeId,
description,
);
return {};
}

View File

@ -17,6 +17,7 @@ import {
createLicenseOrder,
createLicenseSetExpiryDateAndStatus,
createWorktype,
getOptionItems,
getSortCriteria,
getTypistGroup,
getTypistGroupMember,
@ -34,7 +35,12 @@ import {
} from '../../common/test/utility';
import { AccountsService } from './accounts.service';
import { Context, makeContext } from '../../common/log';
import { TIERS, USER_ROLES } from '../../constants';
import {
OPTION_ITEM_VALUE_TYPE,
TIERS,
USER_ROLES,
WORKTYPE_MAX_COUNT,
} from '../../constants';
import { License } from '../../repositories/licenses/entity/license.entity';
import {
overrideAccountsRepositoryService,
@ -46,6 +52,7 @@ import { AdB2cService } from '../../gateways/adb2c/adb2c.service';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service';
import exp from 'constants';
describe('createAccount', () => {
let source: DataSource = null;
@ -3263,3 +3270,147 @@ describe('getWorktypes', () => {
}
});
});
describe('createWorktype', () => {
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('Worktypeを作成できる', async () => {
const module = await makeTestingModule(source);
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const service = module.get<AccountsService>(AccountsService);
const context = makeContext(admin.external_id);
// Worktypeが未登録であることを確認
{
const worktypes = await getWorktypes(source, account.id);
const optionItems = await getOptionItems(source);
expect(worktypes.length).toBe(0);
expect(optionItems.length).toBe(0);
}
await service.createWorktype(
context,
admin.external_id,
'worktype1',
'description1',
);
//実行結果を確認
{
const worktypes = await getWorktypes(source, account.id);
const optionItems = await getOptionItems(source, worktypes[0].id);
expect(worktypes.length).toBe(1);
expect(worktypes[0].custom_worktype_id).toBe('worktype1');
expect(worktypes[0].description).toBe('description1');
expect(optionItems.length).toBe(10);
expect(optionItems[0].item_label).toBe('');
expect(optionItems[0].default_value_type).toBe(
OPTION_ITEM_VALUE_TYPE.DEFAULT,
);
expect(optionItems[0].initial_value).toBe('');
}
});
it('WorktypeIDが登録済みのWorktypeIDと重複した場合、400エラーとなること', async () => {
const module = await makeTestingModule(source);
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const service = module.get<AccountsService>(AccountsService);
const context = makeContext(admin.external_id);
const worktypeId = 'worktype1';
await createWorktype(source, account.id, worktypeId);
//作成したデータを確認
{
const worktypes = await getWorktypes(source, account.id);
expect(worktypes.length).toBe(1);
expect(worktypes[0].custom_worktype_id).toBe(worktypeId);
}
try {
await service.createWorktype(context, admin.external_id, worktypeId);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E011001'));
} else {
fail();
}
}
});
it('WorktypeIDがすでに最大登録数20件まで登録されている場合、400エラーとなること', async () => {
const module = await makeTestingModule(source);
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const service = module.get<AccountsService>(AccountsService);
const context = makeContext(admin.external_id);
// あらかじめ最大登録数分のWorktypeを登録する
for (let i = 0; i < WORKTYPE_MAX_COUNT; i++) {
await createWorktype(source, account.id, `worktype${i + 1}`);
}
//作成したデータを確認
{
const worktypes = await getWorktypes(source, account.id);
expect(worktypes.length).toBe(WORKTYPE_MAX_COUNT);
}
try {
await service.createWorktype(context, admin.external_id, 'newWorktypeID');
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E011002'));
} else {
fail();
}
}
});
it('DBアクセスに失敗した場合、500エラーを返却する', async () => {
const module = await makeTestingModule(source);
// 第五階層のアカウント作成
const { admin } = await makeTestAccount(source, { tier: 5 });
const service = module.get<AccountsService>(AccountsService);
const context = makeContext(admin.external_id);
//DBアクセスに失敗するようにする
const worktypeService = module.get<WorktypesRepositoryService>(
WorktypesRepositoryService,
);
worktypeService.createWorktype = jest.fn().mockRejectedValue('DB failed');
try {
await service.createWorktype(context, admin.external_id, 'worktype1');
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR);
expect(e.getResponse()).toEqual(makeErrorResponse('E009999'));
} else {
fail();
}
}
});
});

View File

@ -47,6 +47,10 @@ import {
TypistIdInvalidError,
} from '../../repositories/user_groups/errors/types';
import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service';
import {
WorktypeIdAlreadyExistsError,
WorktypeIdMaxCountError,
} from '../../repositories/worktypes/errors/types';
@Injectable()
export class AccountsService {
@ -1091,4 +1095,63 @@ export class AccountsService {
);
}
}
/**
*
* @param context
* @param externalId
* @param worktypeId
* @param [description]
* @returns worktype
*/
async createWorktype(
context: Context,
externalId: string,
worktypeId: string,
description?: string,
): Promise<void> {
this.logger.log(`[IN] [${context.trackingId}] ${this.createWorktype.name}`);
try {
// 外部IDをもとにユーザー情報を取得する
const { account_id: accountId } =
await this.usersRepository.findUserByExternalId(externalId);
await this.worktypesRepository.createWorktype(
accountId,
worktypeId,
description,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
// WorktypeIDが既に存在する場合は400エラーを返す
case WorktypeIdAlreadyExistsError:
throw new HttpException(
makeErrorResponse('E011001'),
HttpStatus.BAD_REQUEST,
);
// WorktypeIDが登録上限以上の場合は400エラーを返す
case WorktypeIdMaxCountError:
throw new HttpException(
makeErrorResponse('E011002'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.createWorktype.name}`,
);
}
}
}

View File

@ -7,6 +7,7 @@ import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_cr
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
import { Worktype } from '../../../repositories/worktypes/entity/worktype.entity';
import { OptionItem } from '../../../repositories/option_items/entity/option_item.entity';
/**
* ユーティリティ: すべてのソート条件を取得する
@ -142,3 +143,17 @@ export const getWorktypes = async (
},
});
};
// オプションアイテムを取得する
export const getOptionItems = async (
datasource: DataSource,
worktypeId?: number,
): Promise<OptionItem[]> => {
return worktypeId
? await datasource.getRepository(OptionItem).find({
where: {
worktype_id: worktypeId,
},
})
: await datasource.getRepository(OptionItem).find();
};

View File

@ -13,6 +13,7 @@ import {
import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator';
import { IsUnique } from '../../../common/validators/IsUnique.validator';
import { Type } from 'class-transformer';
import { IsWorktypeId } from '../../../common/validators/worktype.validator';
export class CreateAccountRequest {
@ApiProperty()
@ -350,8 +351,12 @@ export class GetWorktypesResponse {
export class CreateWorktypesRequest {
@ApiProperty({ minLength: 1, description: 'WorktypeID' })
@MinLength(1)
@MaxLength(255)
@IsWorktypeId()
worktypeId: string;
@ApiProperty({ description: 'Worktypeの説明', required: false })
@MaxLength(255)
@IsOptional()
description?: string;
}

View File

@ -0,0 +1,29 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
UpdateDateColumn,
CreateDateColumn,
} from 'typeorm';
@Entity({ name: 'option_items' })
export class OptionItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
worktype_id: number;
@Column()
item_label: string;
@Column()
default_value_type: string;
@Column()
initial_value: string;
@Column({ nullable: true })
created_by?: string;
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at?: Date;
@Column({ nullable: true })
updated_by?: string;
@UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at?: Date;
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OptionItem } from './entity/option_item.entity';
import { OptionItemsRepositoryService } from './option_items.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([OptionItem])],
providers: [OptionItemsRepositoryService],
exports: [OptionItemsRepositoryService],
})
export class OptionItemsRepositoryModule {}

View File

@ -0,0 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class OptionItemsRepositoryService {
constructor(private dataSource: DataSource) {}
}

View File

@ -1 +1,4 @@
// WorktypeID重複エラー
export class WorktypeIdAlreadyExistsError extends Error {}
// WorktypeID登録上限エラー
export class WorktypeIdMaxCountError extends Error {}

View File

@ -1,6 +1,16 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Worktype } from './entity/worktype.entity';
import {
OPTION_ITEM_NUM,
OPTION_ITEM_VALUE_TYPE,
WORKTYPE_MAX_COUNT,
} from '../../constants';
import {
WorktypeIdAlreadyExistsError,
WorktypeIdMaxCountError,
} from './errors/types';
import { OptionItem } from '../option_items/entity/option_item.entity';
@Injectable()
export class WorktypesRepositoryService {
@ -19,4 +29,65 @@ export class WorktypesRepositoryService {
return worktypes;
});
}
/**
*
* @param accountId
* @param worktypeId
* @param [description]
* @returns worktype
*/
async createWorktype(
accountId: number,
worktypeId: string,
description?: string,
): Promise<Worktype> {
return await this.dataSource.transaction(async (entityManager) => {
const worktypeRepo = entityManager.getRepository(Worktype);
const optionItemRepo = entityManager.getRepository(OptionItem);
const duplicatedWorktype = await worktypeRepo.findOne({
where: { account_id: accountId, custom_worktype_id: worktypeId },
});
// ワークタイプIDが重複している場合はエラー
if (duplicatedWorktype) {
throw new WorktypeIdAlreadyExistsError(
`WorktypeID is already exists. WorktypeID: ${worktypeId}`,
);
}
const worktypeCount = await worktypeRepo.count({
where: { account_id: accountId },
});
// ワークタイプの登録数が上限に達している場合はエラー
if (worktypeCount >= WORKTYPE_MAX_COUNT) {
throw new WorktypeIdMaxCountError(
`Number of worktype is exceeded the limit. MAX_COUNT: ${WORKTYPE_MAX_COUNT}, currentCount: ${worktypeCount}`,
);
}
// ワークタイプを作成
const worktype = await worktypeRepo.save({
account_id: accountId,
custom_worktype_id: worktypeId,
description: description,
});
// ワークタイプに紐づくオプションアイテムを10件作成
const newOptionItems = Array.from({ length: OPTION_ITEM_NUM }, () => {
const optionItem = new OptionItem();
optionItem.worktype_id = worktype.id;
optionItem.item_label = '';
optionItem.default_value_type = OPTION_ITEM_VALUE_TYPE.DEFAULT;
optionItem.initial_value = '';
return optionItem;
});
await optionItemRepo.save(newOptionItems);
return worktype;
});
}
}