Merged PR 560: ユーザー情報取得API実装

## 概要
[Task3036: ユーザー情報取得API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3036)

- ユーザー関連情報取得APIのIFについてレスポンスを修正しています。
  - AuthorIDを非必須のパラメータにしています。
- ユーザー関連情報取得APIの中身を実装しました。

## レビューポイント
- オプションアイテムのタイプを定数に置いたDictionaryで数値に変換していますが、定数の置き方として不自然ではないでしょうか。

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-11-14 01:47:27 +00:00
parent 35923f84e2
commit ee161a405f
8 changed files with 370 additions and 125 deletions

View File

@ -4180,7 +4180,7 @@
"properties": {
"authorId": {
"type": "string",
"description": "ログインしたユーザーのAuthorIDAuthorでない場合は空文字"
"description": "ログインしたユーザーのAuthorIDAuthorでない場合はundefined"
},
"authorIdList": {
"description": "属しているアカウントのAuthorID List(全て)",
@ -4215,7 +4215,6 @@
}
},
"required": [
"authorId",
"authorIdList",
"workTypeList",
"isEncrypted",

View File

@ -239,6 +239,27 @@ export const OPTION_ITEM_VALUE_TYPE = {
LAST_INPUT: 'LastInput',
} as const;
/**
*
**/
export const OPTION_ITEM_VALUE_TYPE_NUMBER: {
type: string;
value: number;
}[] = [
{
type: OPTION_ITEM_VALUE_TYPE.BLANK,
value: 1,
},
{
type: OPTION_ITEM_VALUE_TYPE.DEFAULT,
value: 2,
},
{
type: OPTION_ITEM_VALUE_TYPE.LAST_INPUT,
value: 3,
},
];
/**
* ADB2Cユーザのidentity.signInType
* @const {string[]}
@ -261,3 +282,9 @@ export const TERM_TYPE = {
EULA: 'EULA',
DPA: 'DPA',
} as const;
/**
*
* @const {string}
*/
export const USER_AUDIO_FORMAT = 'DS2(QP)';

View File

@ -144,9 +144,11 @@ export class OptionItemList {
export class GetRelationsResponse {
@ApiProperty({
description: 'ログインしたユーザーのAuthorIDAuthorでない場合は空文字',
required: false,
description:
'ログインしたユーザーのAuthorIDAuthorでない場合はundefined',
})
authorId: string;
authorId?: string;
@ApiProperty({ description: '属しているアカウントのAuthorID List(全て)' })
authorIdList: string[];
@ApiProperty({

View File

@ -22,6 +22,7 @@ import {
LICENSE_ALLOCATED_STATUS,
LICENSE_EXPIRATION_THRESHOLD_DAYS,
LICENSE_TYPE,
USER_AUDIO_FORMAT,
USER_LICENSE_STATUS,
USER_ROLES,
} from '../../constants';
@ -44,6 +45,8 @@ import {
makeTestUser,
} from '../../common/test/utility';
import { v4 as uuidv4 } from 'uuid';
import { createOptionItems, createWorktype } from '../accounts/test/utility';
import { createWorkflow, getWorkflows } from '../workflows/test/utility';
describe('UsersService.confirmUser', () => {
let source: DataSource | null = null;
@ -2666,3 +2669,187 @@ describe('UsersService.getUserName', () => {
}
});
});
describe('UsersService.getRelations', () => {
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true,
});
return source.initialize();
});
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it('ユーザー関連情報を取得できるAuthor', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const { account } = await makeTestAccount(source, {
tier: 5,
});
const { id: user1, external_id } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'AUTHOR_1',
encryption: true,
encryption_password: 'password',
prompt: true,
});
const { id: user2 } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'AUTHOR_2',
});
await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'AUTHOR_3',
email_verified: false,
});
const worktype1 = await createWorktype(
source,
account.id,
'worktype1',
undefined,
true,
);
await createOptionItems(source, worktype1.id);
const worktype2 = await createWorktype(source, account.id, 'worktype2');
await createOptionItems(source, worktype2.id);
await createWorkflow(source, account.id, user1, worktype1.id);
await createWorkflow(source, account.id, user1, worktype2.id);
await createWorkflow(source, account.id, user1);
await createWorkflow(source, account.id, user2, worktype1.id);
// 作成したデータを確認
{
const workflows = await getWorkflows(source, account.id);
expect(workflows.length).toBe(4);
expect(workflows[0].worktype_id).toBe(worktype1.id);
expect(workflows[0].author_id).toBe(user1);
expect(workflows[1].worktype_id).toBe(worktype2.id);
expect(workflows[1].author_id).toBe(user1);
expect(workflows[2].worktype_id).toBe(null);
expect(workflows[2].author_id).toBe(user1);
expect(workflows[3].worktype_id).toBe(worktype1.id);
expect(workflows[3].author_id).toBe(user2);
}
const context = makeContext(external_id);
const service = module.get<UsersService>(UsersService);
const relations = await service.getRelations(context, external_id);
// レスポンスを確認
{
expect(relations.authorId).toBe('AUTHOR_1');
expect(relations.authorIdList.length).toBe(2);
expect(relations.authorIdList[0]).toBe('AUTHOR_1');
expect(relations.authorIdList[1]).toBe('AUTHOR_2');
const workTypeList = relations.workTypeList;
expect(relations.workTypeList.length).toBe(2);
expect(workTypeList[0].workTypeId).toBe(worktype1.custom_worktype_id);
expect(workTypeList[0].optionItemList.length).toBe(10);
expect(workTypeList[0].optionItemList[0].label).toBe('');
expect(workTypeList[0].optionItemList[0].initialValueType).toBe(2);
expect(workTypeList[0].optionItemList[0].defaultValue).toBe('');
expect(workTypeList[1].workTypeId).toBe(worktype2.custom_worktype_id);
expect(workTypeList[1].optionItemList.length).toBe(10);
expect(relations.isEncrypted).toBe(true);
expect(relations.encryptionPassword).toBe('password');
expect(relations.activeWorktype).toBe(worktype1.custom_worktype_id);
expect(relations.audioFormat).toBe(USER_AUDIO_FORMAT);
expect(relations.prompt).toBe(true);
}
});
it('ユーザー関連情報を取得できるAuthor以外', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const { account } = await makeTestAccount(source, {
tier: 5,
});
const { external_id } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.TYPIST,
encryption: false,
prompt: false,
});
const { id: user2 } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'AUTHOR_2',
});
const worktype1 = await createWorktype(source, account.id, 'worktype1');
await createOptionItems(source, worktype1.id);
await createWorkflow(source, account.id, user2, worktype1.id);
// 作成したデータを確認
{
const workflows = await getWorkflows(source, account.id);
expect(workflows.length).toBe(1);
expect(workflows[0].worktype_id).toBe(worktype1.id);
expect(workflows[0].author_id).toBe(user2);
}
const context = makeContext(external_id);
const service = module.get<UsersService>(UsersService);
const relations = await service.getRelations(context, external_id);
// レスポンスを確認
{
expect(relations.authorId).toBe(undefined);
expect(relations.authorIdList.length).toBe(1);
expect(relations.authorIdList[0]).toBe('AUTHOR_2');
expect(relations.workTypeList.length).toBe(0);
expect(relations.isEncrypted).toBe(false);
expect(relations.encryptionPassword).toBe(undefined);
expect(relations.activeWorktype).toBe('');
expect(relations.audioFormat).toBe(USER_AUDIO_FORMAT);
expect(relations.prompt).toBe(false);
}
});
it('ユーザーが存在しない場合は、ユーザー未存在エラー', async () => {
if (!source) fail();
try {
const module = await makeTestingModule(source);
if (!module) fail();
const context = makeContext(uuidv4());
const service = module.get<UsersService>(UsersService);
await service.getRelations(context, 'external_id');
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E010204'));
} else {
fail();
}
}
});
});

View File

@ -36,6 +36,8 @@ import {
ADB2C_SIGN_IN_TYPE,
LICENSE_EXPIRATION_THRESHOLD_DAYS,
MANUAL_RECOVERY_REQUIRED,
OPTION_ITEM_VALUE_TYPE_NUMBER,
USER_AUDIO_FORMAT,
USER_LICENSE_STATUS,
USER_ROLES,
} from '../../constants';
@ -663,133 +665,68 @@ export class UsersService {
): Promise<GetRelationsResponse> {
this.logger.log(`[IN] [${context.trackingId}] ${this.getRelations.name}`);
try {
const user = await this.usersRepository.findUserByExternalId(userId);
const { id } = await this.usersRepository.findUserByExternalId(userId);
// ユーザー関連情報を取得
const { user, authors, worktypes, activeWorktype } =
await this.usersRepository.getUserRelations(id);
// AuthorIDのリストを作成
const authorIds = authors.flatMap((author) =>
author.author_id ? [author.author_id] : [],
);
const workTypeList = worktypes?.map((worktype) => {
return {
workTypeId: worktype.custom_worktype_id,
optionItemList: worktype.option_items.map((optionItem) => {
const initialValueType = OPTION_ITEM_VALUE_TYPE_NUMBER.find(
(x) => x.type === optionItem.default_value_type,
)?.value;
if (!initialValueType) {
throw new Error(
`invalid default_value_type ${optionItem.default_value_type}`,
);
}
return {
label: optionItem.item_label,
initialValueType,
defaultValue: optionItem.initial_value,
};
}),
};
});
// TODO: PBI2105 本実装時に修正すること
return {
authorId: user.author_id ?? '',
authorIdList: [user.author_id ?? '', 'XXX'],
isEncrypted: true,
encryptionPassword: 'abcd@123?dcba',
audioFormat: 'DS2(QP)',
prompt: true,
workTypeList: [
{
workTypeId: 'workType1',
optionItemList: [
{
label: 'optionItem11',
initialValueType: 2,
defaultValue: 'default11',
},
{
label: 'optionItem12',
initialValueType: 2,
defaultValue: 'default12',
},
{
label: 'optionItem13',
initialValueType: 2,
defaultValue: 'default13',
},
{
label: 'optionItem14',
initialValueType: 2,
defaultValue: 'default14',
},
{
label: 'optionItem15',
initialValueType: 2,
defaultValue: 'default15',
},
{
label: 'optionItem16',
initialValueType: 2,
defaultValue: 'default16',
},
{
label: 'optionItem17',
initialValueType: 2,
defaultValue: 'default17',
},
{
label: 'optionItem18',
initialValueType: 2,
defaultValue: 'default18',
},
{
label: 'optionItem19',
initialValueType: 1,
defaultValue: '',
},
{
label: 'optionItem110',
initialValueType: 3,
defaultValue: '',
},
],
},
{
workTypeId: 'workType2',
optionItemList: [
{
label: 'optionItem21',
initialValueType: 2,
defaultValue: 'default21',
},
{
label: 'optionItem22',
initialValueType: 2,
defaultValue: 'default22',
},
{
label: 'optionItem23',
initialValueType: 2,
defaultValue: 'defaul23',
},
{
label: 'optionItem24',
initialValueType: 2,
defaultValue: 'default24',
},
{
label: 'optionItem25',
initialValueType: 2,
defaultValue: 'default25',
},
{
label: 'optionItem26',
initialValueType: 2,
defaultValue: 'default26',
},
{
label: 'optionItem27',
initialValueType: 2,
defaultValue: 'default27',
},
{
label: 'optionItem28',
initialValueType: 2,
defaultValue: 'default28',
},
{
label: 'optionItem29',
initialValueType: 1,
defaultValue: '',
},
{
label: 'optionItem210',
initialValueType: 3,
defaultValue: '',
},
],
},
],
activeWorktype: 'workType1',
authorId: user.author_id ?? undefined,
authorIdList: authorIds,
workTypeList,
isEncrypted: user.encryption,
encryptionPassword: user.encryption_password ?? undefined,
activeWorktype: activeWorktype?.custom_worktype_id ?? '',
audioFormat: USER_AUDIO_FORMAT,
prompt: user.prompt,
};
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case UserNotFoundError:
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

@ -33,6 +33,8 @@ import {
AdminUserNotFoundError,
} from '../accounts/errors/types';
import { Account } from '../accounts/entity/account.entity';
import { Workflow } from '../workflows/entity/workflow.entity';
import { Worktype } from '../worktypes/entity/worktype.entity';
@Injectable()
export class UsersRepositoryService {
@ -644,4 +646,83 @@ export class UsersRepositoryService {
return originAccount.delegation_permission;
});
}
/**
*
* @param userId
* @returns user relations
*/
async getUserRelations(userId: number): Promise<{
user: User;
authors: User[];
worktypes: Worktype[];
activeWorktype: Worktype | undefined;
}> {
return await this.dataSource.transaction(async (entityManager) => {
const userRepo = entityManager.getRepository(User);
const user = await userRepo.findOne({
where: { id: userId },
relations: { account: true },
});
if (!user) {
throw new UserNotFoundError(`User is Not Found. id: ${userId}`);
}
// 運用上、アカウントがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理
if (!user.account) {
throw new AccountNotFoundError(
`Account is Not Found. user.id: ${userId}`,
);
}
// ユーザーの所属するアカウント内のすべてのメール認証済みAuthorユーザーを取得する
const authors = await userRepo.find({
where: {
account_id: user.account_id,
role: USER_ROLES.AUTHOR,
email_verified: true,
},
});
// ユーザーの所属するアカウント内のアクティブワークタイプを取得する
const worktypeRepo = entityManager.getRepository(Worktype);
let activeWorktype: Worktype | undefined = undefined;
const activeWorktypeId = user.account.active_worktype_id;
if (activeWorktypeId !== null) {
activeWorktype =
(await worktypeRepo.findOne({
where: {
account_id: user.account_id,
id: activeWorktypeId,
},
})) ?? undefined;
}
let worktypes: Worktype[] = [];
// ユーザーのロールがAuthorの場合はルーティングルールに紐づいたワークタイプを取得する
if (user.role === USER_ROLES.AUTHOR) {
const workflowRepo = entityManager.getRepository(Workflow);
const workflows = await workflowRepo.find({
where: {
account_id: user.account_id,
author_id: user.id,
worktype_id: Not(IsNull()),
},
relations: {
worktype: {
option_items: true,
},
},
});
worktypes = workflows.flatMap((workflow) =>
workflow.worktype ? [workflow.worktype] : [],
);
}
return { user, authors, worktypes, activeWorktype };
});
}
}

View File

@ -4,7 +4,10 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Worktype } from './worktype.entity';
@Entity({ name: 'option_items' })
export class OptionItem {
@ -32,4 +35,8 @@ export class OptionItem {
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date | null;
@ManyToOne(() => Worktype, (worktype) => worktype.id)
@JoinColumn({ name: 'worktype_id' })
worktype: Worktype;
}

View File

@ -5,7 +5,9 @@ import {
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { OptionItem } from './option_item.entity';
@Entity({ name: 'worktypes' })
export class Worktype {
@ -41,4 +43,7 @@ export class Worktype {
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToMany(() => OptionItem, (optionItem) => optionItem.worktype)
option_items: OptionItem[];
}