Merged PR 352: API実装(TypistGroup追加API)

## 概要
[Task2428: API実装(TypistGroup追加API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2428)

- IFを修正
  - typistIdsの要素が数値であることをOpenAPIに明記する
  - typistIdsのチェックバリデータを追加・修正
- entity修正
- typistGuroup作成処理を実装
- テスト作成

## レビューポイント
- テストケースは足りているか
- entityの修正に問題はないか
- typistIdsのチェック処理で漏れているものはないか

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

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

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
saito.k 2023-08-25 07:11:09 +00:00
parent e1693a7323
commit d5c756184b
15 changed files with 510 additions and 25 deletions

View File

@ -0,0 +1,36 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
ValidationOptions,
registerDecorator,
} from 'class-validator';
@ValidatorConstraint()
export class IsUniqueArray implements ValidatorConstraintInterface {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate(arr: any[], args: ValidationArguments) {
return arr.length === new Set(arr).size;
}
defaultMessage(args: ValidationArguments) {
return `${args.property} should be an array of unique values`;
}
}
/**
*
* @param [validationOptions]
* @returns
*/
export function IsUnique(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isUnique',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: IsUniqueArray,
});
};
}

View File

@ -1,5 +1,6 @@
import { registerDecorator, ValidationOptions } from 'class-validator'; import { registerDecorator, ValidationOptions } from 'class-validator';
// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する
export const IsAdminPasswordvalid = (validationOptions?: ValidationOptions) => { export const IsAdminPasswordvalid = (validationOptions?: ValidationOptions) => {
return (object: any, propertyName: string) => { return (object: any, propertyName: string) => {
registerDecorator({ registerDecorator({

View File

@ -4,6 +4,7 @@ import {
ValidationArguments, ValidationArguments,
} from 'class-validator'; } from 'class-validator';
import { Assignee } from '../../features/tasks/types/types'; import { Assignee } from '../../features/tasks/types/types';
// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する
/** /**
* Validations options * Validations options
* @param [validationOptions] * @param [validationOptions]

View File

@ -4,7 +4,7 @@ import {
ValidationOptions, ValidationOptions,
} from 'class-validator'; } from 'class-validator';
import { SignupRequest } from '../../features/users/types/types'; import { SignupRequest } from '../../features/users/types/types';
// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する
export const IsPasswordvalid = (validationOptions?: ValidationOptions) => { export const IsPasswordvalid = (validationOptions?: ValidationOptions) => {
return (object: any, propertyName: string) => { return (object: any, propertyName: string) => {
registerDecorator({ registerDecorator({
@ -35,7 +35,7 @@ export const IsPasswordvalid = (validationOptions?: ValidationOptions) => {
}); });
}; };
}; };
// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する
export const IsEncryptionPasswordPresent = ( export const IsEncryptionPasswordPresent = (
validationOptions?: ValidationOptions, validationOptions?: ValidationOptions,
) => { ) => {

View File

@ -9,6 +9,7 @@ import {
} from '../../features/users/types/types'; } from '../../features/users/types/types';
import { USER_ROLES } from '../../constants'; import { USER_ROLES } from '../../constants';
// TODO タスク 2502: バリデータをクラスを使用した記述に統一するで修正する
export const IsRoleAuthorDataValid = < export const IsRoleAuthorDataValid = <
T extends SignupRequest | PostUpdateUserRequest, T extends SignupRequest | PostUpdateUserRequest,
>( >(

View File

@ -326,10 +326,17 @@ export class AccountsController {
@Req() req: Request, @Req() req: Request,
@Body() body: CreateTypistGroupRequest, @Body() body: CreateTypistGroupRequest,
): Promise<CreateTypistGroupResponse> { ): Promise<CreateTypistGroupResponse> {
const { typistGroupName, typistIds } = body;
// アクセストークン取得 // アクセストークン取得
const accessToken = retrieveAuthorizationToken(req); const accessToken = retrieveAuthorizationToken(req);
const payload = jwt.decode(accessToken, { json: true }) as AccessToken; const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken;
const context = makeContext(userId);
await this.accountService.createTypistGroup(
context,
userId,
typistGroupName,
typistIds,
);
return {}; return {};
} }

View File

@ -23,6 +23,9 @@ import {
getUsers, getUsers,
getSortCriteria, getSortCriteria,
createAccountAndAdminUser, createAccountAndAdminUser,
createAccountAndAdminUserForTier5,
getTypistGroup,
getTypistGroupMember,
} from './test/utility'; } from './test/utility';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { makeTestingModule } from '../../common/test/modules'; import { makeTestingModule } from '../../common/test/modules';
@ -38,6 +41,7 @@ import {
} from '../../common/test/overrides'; } from '../../common/test/overrides';
import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
describe('createAccount', () => { describe('createAccount', () => {
let source: DataSource = null; let source: DataSource = null;
@ -2288,3 +2292,232 @@ describe('getDealers', () => {
}); });
}); });
}); });
describe('createTypistGroup', () => {
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('TypistGroupを作成できる', async () => {
const module = await makeTestingModule(source);
const adminExternalId = 'admin-external-id';
// 第五階層のアカウント作成
const { accountId } = await createAccountAndAdminUserForTier5(
source,
adminExternalId,
);
// 作成したアカウントにユーザーを3名追加する
const typiptUserExternalIds = [
'typist-user-external-id1',
'typist-user-external-id2',
'typist-user-external-id3',
];
const userIds: number[] = [];
for (const typiptUserExternalId of typiptUserExternalIds) {
const { userId } = await createUser(
source,
accountId,
typiptUserExternalId,
'typist',
);
userIds.push(userId);
}
//作成したデータを確認
{
const accounts = await getAccounts(source);
expect(accounts.length).toBe(1);
expect(accounts[0].id).toBe(accountId);
const users = await getUsers(source);
expect(users.length).toBe(4);
}
const service = module.get<AccountsService>(AccountsService);
const typistGroupName = 'typist-group-name';
const typistUserIds = userIds;
const context = makeContext(adminExternalId);
await service.createTypistGroup(
context,
adminExternalId,
typistGroupName,
typistUserIds,
);
//実行結果を確認
{
const typistGroups = await getTypistGroup(source, accountId);
expect(typistGroups.length).toBe(1);
expect(typistGroups[0].name).toBe(typistGroupName);
const typistGroupUsers = await getTypistGroupMember(
source,
typistGroups[0].id,
);
expect(typistGroupUsers.length).toBe(3);
expect(typistGroupUsers.map((user) => user.user_id)).toEqual(userIds);
}
});
it('typistIdsにRole:typist以外のユーザーが含まれていた場合、400エラーを返却する', async () => {
const module = await makeTestingModule(source);
const adminExternalId = 'admin-external-id';
// 第五階層のアカウント作成
const { accountId } = await createAccountAndAdminUserForTier5(
source,
adminExternalId,
);
// 作成したアカウントにユーザーを3名追加する
const typiptUserExternalIds = [
'typist-user-external-id1',
'typist-user-external-id2',
'typist-user-external-id3',
];
const userIds: number[] = [];
for (const typiptUserExternalId of typiptUserExternalIds) {
const { userId } = await createUser(
source,
accountId,
typiptUserExternalId,
typiptUserExternalId === 'typist-user-external-id3' ? 'none' : 'typist', //typist-user-external-id3のみRole:none
);
userIds.push(userId);
}
//作成したデータを確認
{
const accounts = await getAccounts(source);
expect(accounts.length).toBe(1);
expect(accounts[0].id).toBe(accountId);
const users = await getUsers(source);
expect(users.length).toBe(4);
expect(users.filter((user) => user.role === 'typist').length).toBe(2);
}
const service = module.get<AccountsService>(AccountsService);
const typistGroupName = 'typist-group-name';
const typistUserIds = userIds;
const context = makeContext(adminExternalId);
await expect(
service.createTypistGroup(
context,
adminExternalId,
typistGroupName,
typistUserIds,
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010204'), HttpStatus.BAD_REQUEST),
);
});
it('typistIdsに存在しないユーザーが含まれていた場合、400エラーを返却する', async () => {
const module = await makeTestingModule(source);
const adminExternalId = 'admin-external-id';
// 第五階層のアカウント作成
const { accountId } = await createAccountAndAdminUserForTier5(
source,
adminExternalId,
);
// 作成したアカウントにユーザーを3名追加する
const typiptUserExternalIds = [
'typist-user-external-id1',
'typist-user-external-id2',
'typist-user-external-id3',
];
const userIds: number[] = [];
for (const typiptUserExternalId of typiptUserExternalIds) {
const { userId } = await createUser(
source,
accountId,
typiptUserExternalId,
'typist',
);
userIds.push(userId);
}
//作成したデータを確認
{
const accounts = await getAccounts(source);
expect(accounts.length).toBe(1);
expect(accounts[0].id).toBe(accountId);
const users = await getUsers(source);
expect(users.length).toBe(4);
}
const service = module.get<AccountsService>(AccountsService);
const typistGroupName = 'typist-group-name';
const typistUserIds = [...userIds, 9999]; //存在しないユーザーIDを追加
const context = makeContext(adminExternalId);
await expect(
service.createTypistGroup(
context,
adminExternalId,
typistGroupName,
typistUserIds,
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010204'), HttpStatus.BAD_REQUEST),
);
});
it('DBアクセスに失敗した場合、500エラーを返却する', async () => {
const module = await makeTestingModule(source);
const adminExternalId = 'admin-external-id';
// 第五階層のアカウント作成
const { accountId } = await createAccountAndAdminUserForTier5(
source,
adminExternalId,
);
// 作成したアカウントにユーザーを3名追加する
const typiptUserExternalIds = [
'typist-user-external-id1',
'typist-user-external-id2',
'typist-user-external-id3',
];
const userIds: number[] = [];
for (const typiptUserExternalId of typiptUserExternalIds) {
const { userId } = await createUser(
source,
accountId,
typiptUserExternalId,
'typist',
);
userIds.push(userId);
}
//作成したデータを確認
{
const accounts = await getAccounts(source);
expect(accounts.length).toBe(1);
expect(accounts[0].id).toBe(accountId);
const users = await getUsers(source);
expect(users.length).toBe(4);
}
const service = module.get<AccountsService>(AccountsService);
const typistGroupName = 'typist-group-name';
const typistUserIds = userIds;
const context = makeContext(adminExternalId);
//DBアクセスに失敗するようにする
const typistGroupService = module.get<UserGroupsRepositoryService>(
UserGroupsRepositoryService,
);
typistGroupService.createTypistGroup = jest
.fn()
.mockRejectedValue('DB failed');
await expect(
service.createTypistGroup(
context,
adminExternalId,
typistGroupName,
typistUserIds,
),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
});
});

View File

@ -40,6 +40,7 @@ import {
OrderNotFoundError, OrderNotFoundError,
} from '../../repositories/licenses/errors/types'; } from '../../repositories/licenses/errors/types';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { TypistIdInvalidError } from '../../repositories/user_groups/errors/types';
@Injectable() @Injectable()
export class AccountsService { export class AccountsService {
@ -870,4 +871,57 @@ export class AccountsService {
this.logger.log(`[OUT] ${this.getDealers.name}`); this.logger.log(`[OUT] ${this.getDealers.name}`);
} }
} }
/**
*
* @param context
* @param externalId
* @param typistGroupName
* @param typistIds
* @returns createTypistGroupResponse
**/
async createTypistGroup(
context: Context,
externalId: string,
typistGroupName: string,
typistIds: number[],
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.createTypistGroup.name} | params: { ` +
`externalId: ${externalId}, ` +
`typistGroupName: ${typistGroupName}, ` +
`typistIds: ${typistIds} };`,
);
try {
// 外部IDをもとにユーザー情報を取得する
const { account_id } = await this.usersRepository.findUserByExternalId(
externalId,
);
// API実行ユーザーのアカウントIDでタイピストグループを作成し、タイピストグループとtypistIdsのユーザーを紐付ける
await this.userGroupsRepository.createTypistGroup(
typistGroupName,
typistIds,
account_id,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case TypistIdInvalidError:
throw new HttpException(
makeErrorResponse('E010204'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} }

View File

@ -6,6 +6,8 @@ import {
LicenseOrder, LicenseOrder,
} from '../../../repositories/licenses/entity/license.entity'; } from '../../../repositories/licenses/entity/license.entity';
import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity'; import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
// TODO: [PBI 2379] 他のUtilityからコピペしてきたもの。後日整理される前提。 // TODO: [PBI 2379] 他のUtilityからコピペしてきたもの。後日整理される前提。
export const createAccountAndAdminUser = async ( export const createAccountAndAdminUser = async (
@ -269,3 +271,93 @@ export const createUser = async (
const user = identifiers.pop() as User; const user = identifiers.pop() as User;
return { userId: user.id, externalId: external_id, authorId: author_id }; return { userId: user.id, externalId: external_id, authorId: author_id };
}; };
// TODO: [PBI 2379] 第五階層のアカウント・管理者を作成する。後日整理される前提。
export const createAccountAndAdminUserForTier5 = async (
datasource: DataSource,
adminExternalId: string,
): Promise<{
accountId: number;
adminId: number;
role: string;
tier: number;
}> => {
const { identifiers: account_idf } = await datasource
.getRepository(Account)
.insert({
tier: 5,
country: 'JP',
delegation_permission: false,
locked: false,
company_name: 'test inc.',
verified: true,
deleted_at: '',
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const account = account_idf.pop() as Account;
const { identifiers: user_idf } = await datasource
.getRepository(User)
.insert({
account_id: account.id,
external_id: adminExternalId,
role: 'none',
accepted_terms_version: '1.0',
email_verified: true,
auto_renew: true,
license_alert: true,
notification: true,
encryption: true,
encryption_password: 'password',
prompt: true,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const user = user_idf.pop() as User;
// Accountの管理者を設定する
await datasource.getRepository(Account).update(
{ id: user.account_id },
{
primary_admin_user_id: user.id,
},
);
const accountResult = await getAccount(datasource, account.id);
const userResult = await getUser(datasource, user.id);
return {
accountId: account.id,
adminId: user.id,
role: userResult.role,
tier: accountResult.tier,
};
};
// タイピストグループを取得する
export const getTypistGroup = async (
datasource: DataSource,
accountId: number,
): Promise<UserGroup[]> => {
return await datasource.getRepository(UserGroup).find({
where: {
account_id: accountId,
},
});
};
// タイピストグループメンバーを取得する
export const getTypistGroupMember = async (
datasource: DataSource,
userGroupId: number,
): Promise<UserGroupMember[]> => {
return await datasource.getRepository(UserGroupMember).find({
where: {
user_group_id: userGroupId,
},
});
};

View File

@ -11,6 +11,7 @@ import {
IsArray, IsArray,
} from 'class-validator'; } from 'class-validator';
import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator'; import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator';
import { IsUnique } from '../../../common/validators/IsUnique.validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class CreateAccountRequest { export class CreateAccountRequest {
@ -155,6 +156,9 @@ export class CreateTypistGroupRequest {
@ApiProperty({ minItems: 1, isArray: true, type: 'integer' }) @ApiProperty({ minItems: 1, isArray: true, type: 'integer' })
@ArrayMinSize(1) @ArrayMinSize(1)
@IsArray() @IsArray()
@IsInt({ each: true })
@Min(0, { each: true })
@IsUnique()
typistIds: number[]; typistIds: number[];
} }

View File

@ -1,4 +1,11 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserGroupMember } from './user_group_member.entity'; import { UserGroupMember } from './user_group_member.entity';
@Entity({ name: 'user_group' }) @Entity({ name: 'user_group' })
@ -15,16 +22,16 @@ export class UserGroup {
@Column({ nullable: true }) @Column({ nullable: true })
deleted_at?: Date; deleted_at?: Date;
@Column()
created_by: string;
@Column({ nullable: true }) @Column({ nullable: true })
created_by?: string;
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at?: Date; created_at?: Date;
@Column()
updated_by: string;
@Column({ nullable: true }) @Column({ nullable: true })
updated_by?: string;
@UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at?: Date; updated_at?: Date;
@OneToMany( @OneToMany(

View File

@ -5,6 +5,8 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { UserGroup } from './user_group.entity'; import { UserGroup } from './user_group.entity';
@ -22,16 +24,16 @@ export class UserGroupMember {
@Column({ nullable: true }) @Column({ nullable: true })
deleted_at?: Date; deleted_at?: Date;
@Column()
created_by: string;
@Column({ nullable: true }) @Column({ nullable: true })
created_by?: string;
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at?: Date; created_at?: Date;
@Column()
updated_by: string;
@Column({ nullable: true }) @Column({ nullable: true })
updated_by?: string;
@UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at?: Date; updated_at?: Date;
@ManyToOne(() => User, (user) => user.id) @ManyToOne(() => User, (user) => user.id)

View File

@ -0,0 +1,2 @@
// typistIdが不正な場合のエラー
export class TypistIdInvalidError extends Error {}

View File

@ -2,6 +2,9 @@ import { Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm'; import { DataSource, In, IsNull } from 'typeorm';
import { UserGroup } from './entity/user_group.entity'; import { UserGroup } from './entity/user_group.entity';
import { UserGroupMember } from './entity/user_group_member.entity'; import { UserGroupMember } from './entity/user_group_member.entity';
import { User } from '../users/entity/user.entity';
import { TypistIdInvalidError } from './errors/types';
import { USER_ROLES } from '../../constants';
@Injectable() @Injectable()
export class UserGroupsRepositoryService { export class UserGroupsRepositoryService {
@ -43,4 +46,53 @@ export class UserGroupsRepositoryService {
return groupMembers; return groupMembers;
}); });
} }
/**
* IDでタイピストグループを作成しtypistIdsのユーザーを紐付ける
* @param accountId
* @param name
* @param typistIds
* @returns createdTypistGroup
*/
async createTypistGroup(
name: string,
typistIds: number[],
accountId: number,
): Promise<UserGroup> {
return await this.dataSource.transaction(async (entityManager) => {
const userGroupRepo = entityManager.getRepository(UserGroup);
const userGroupMemberRepo = entityManager.getRepository(UserGroupMember);
// typistIdsのidを持つユーザーが、account_idのアカウントに所属していて、かつ、roleがtypistであることを確認する
const userRepo = entityManager.getRepository(User);
const userRecords = await userRepo.find({
where: {
id: In(typistIds),
account_id: accountId,
role: USER_ROLES.TYPIST,
},
});
if (userRecords.length !== typistIds.length) {
throw new TypistIdInvalidError(
`Typist user not exists Error. typistIds:${typistIds}; typistIds(DB):${userRecords.map(
(x) => x.id,
)}`,
);
}
// userGroupをDBに保存する
const userGroup = await userGroupRepo.save({
account_id: accountId,
name,
});
const userGroupMembers = userRecords.map((user) => {
return {
user_group_id: userGroup.id,
user_id: user.id,
};
});
// userGroupMembersをDBに保存する
await userGroupMemberRepo.save(userGroupMembers);
return userGroup;
});
}
} }

View File

@ -317,14 +317,7 @@ export class UsersRepositoryService {
.createQueryBuilder() .createQueryBuilder()
.insert() .insert()
.into(License) .into(License)
.values( .values(licenses)
licenses.map((value) => ({
expiry_date: value.expiry_date,
account_id: value.account_id,
type: value.type,
status: value.status,
})),
)
.execute(); .execute();
}); });
} }