Merged PR 280: ユーザー一覧API修正

## 概要
[Task2231: ユーザー一覧API修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2231)

- ユーザー一覧取得APIを修正しまし、テスト実装を修正しました。
  - グループ情報、ライセンス情報を一緒に取得するよう修正
  - ADB2Cからのユーザー取得を一括取得に修正
※ユーザー以外のテスト変更はユーザーテーブルのスキーム変更に伴うものです。

## レビューポイント
- 取得内容に問題はないか
  - グループとライセンスはDBからリレーションで一緒に取得しているが処理的に問題はないか
  - ADB2Cからのメールアドレス取得のためにidentitiesパラメータを追加したが問題はないか
- テスト項目に問題はないか

## UIの変更
なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-07-31 08:24:51 +00:00
parent dc32b5ac67
commit 7ac578d4cc
19 changed files with 1059 additions and 527 deletions

View File

@ -214,9 +214,9 @@ export class LicenseOrder {
@ApiProperty({ description: '注文数' })
numberOfOrder: number;
@ApiProperty({ description: 'POナンバー' })
poNumber: String;
poNumber: string;
@ApiProperty({ description: '注文状態' })
status: String;
status: string;
}
export class GetOrderHistoriesResponce {
@ApiProperty({ description: '合計件数' })

View File

@ -134,6 +134,8 @@ export const makeDefaultUsersRepositoryMockValue =
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
account: {
id: 2,
parent_account_id: 2,

View File

@ -77,6 +77,8 @@ export const createUser = async (
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',

View File

@ -121,6 +121,8 @@ export const makeDefaultUsersRepositoryMockValue =
user1.auto_renew = false;
user1.license_alert = false;
user1.notification = false;
user1.encryption = false;
user1.prompt = false;
user1.deleted_at = undefined;
user1.created_by = 'test';
user1.created_at = new Date();

View File

@ -44,6 +44,8 @@ export const createUser = async (
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',

View File

@ -428,6 +428,8 @@ const defaultTasksRepositoryMockValue: {
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test',
created_at: new Date(),
updated_by: 'test',

View File

@ -139,6 +139,8 @@ export const createUser = async (
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',

View File

@ -15,6 +15,7 @@ import {
SortDirection,
TaskListSortableAttribute,
} from '../../../common/types/sort';
import { AdB2cUser } from '../../../gateways/adb2c/types/types';
export type SortCriteriaRepositoryMockValue = {
updateSortCriteria: SortCriteria | Error;
@ -36,6 +37,7 @@ export type AdB2cMockValue = {
changePassword: { sub: string } | Error;
createUser: string | ConflictError | Error;
getUser: Aadb2cUser | Error;
getUsers: AdB2cUser[] | Error;
};
export type SendGridMockValue = {
@ -138,8 +140,14 @@ export const makeSendGridServiceMock = (value: SendGridMockValue) => {
};
export const makeAdB2cServiceMock = (value: AdB2cMockValue) => {
const { getMetaData, getSignKeySets, changePassword, createUser, getUser } =
value;
const {
getMetaData,
getSignKeySets,
changePassword,
createUser,
getUser,
getUsers,
} = value;
return {
getMetaData:
@ -172,6 +180,10 @@ export const makeAdB2cServiceMock = (value: AdB2cMockValue) => {
: jest
.fn<Promise<Aadb2cUser | undefined>, []>()
.mockResolvedValue(getUser),
getUsers:
getUsers instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(getUsers)
: jest.fn<Promise<AdB2cUser[]>, []>().mockResolvedValue(getUsers),
};
};
@ -313,6 +325,7 @@ export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => {
displayName: 'Hanako Sato',
mail: 'hanako@sample.com',
},
getUsers: AdB2cMockUsers,
};
};
@ -333,6 +346,8 @@ export const makeDefaultUsersRepositoryMockValue =
user1.auto_renew = false;
user1.license_alert = false;
user1.notification = false;
user1.encryption = false;
user1.prompt = false;
user1.deleted_at = null;
user1.created_by = 'test';
user1.created_at = new Date();
@ -350,6 +365,8 @@ export const makeDefaultUsersRepositoryMockValue =
user2.auto_renew = false;
user2.license_alert = false;
user2.notification = false;
user2.encryption = false;
user2.prompt = false;
user2.deleted_at = null;
user2.created_by = 'test';
user2.created_at = new Date();
@ -365,3 +382,39 @@ export const makeDefaultUsersRepositoryMockValue =
existsAuthorId: false,
};
};
const AdB2cMockUsers: AdB2cUser[] = [
{
id: 'external_id1',
displayName: 'test1',
identities: [
{
signInType: 'emailAddress',
issuer: 'issuer',
issuerAssignedId: 'test1@mail.com',
},
],
},
{
id: 'external_id2',
displayName: 'test2',
identities: [
{
signInType: 'emailAddress',
issuer: 'issuer',
issuerAssignedId: 'test2@mail.com',
},
],
},
{
id: 'external_id3',
displayName: 'test3',
identities: [
{
signInType: 'emailAddress',
issuer: 'issuer',
issuerAssignedId: 'test3@mail.com',
},
],
},
];

View File

@ -0,0 +1,205 @@
import { DataSource } from 'typeorm';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { UserGroupsRepositoryModule } from '../../../repositories/user_groups/user_groups.repository.module';
import { TasksRepositoryModule } from '../../../repositories/tasks/tasks.repository.module';
import { AuthModule } from '../../../features/auth/auth.module';
import { AdB2cModule } from '../../../gateways/adb2c/adb2c.module';
import { AccountsModule } from '../../../features/accounts/accounts.module';
import { UsersModule } from '../../../features/users/users.module';
import { FilesModule } from '../../../features/files/files.module';
import { TasksModule } from '../../../features/tasks/tasks.module';
import { SendGridModule } from '../../../features/../gateways/sendgrid/sendgrid.module';
import { LicensesModule } from '../../../features/licenses/licenses.module';
import { AccountsRepositoryModule } from '../../../repositories/accounts/accounts.repository.module';
import { UsersRepositoryModule } from '../../../repositories/users/users.repository.module';
import { LicensesRepositoryModule } from '../../../repositories/licenses/licenses.repository.module';
import { AudioFilesRepositoryModule } from '../../../repositories/audio_files/audio_files.repository.module';
import { AudioOptionItemsRepositoryModule } from '../../../repositories/audio_option_items/audio_option_items.repository.module';
import { CheckoutPermissionsRepositoryModule } from '../../../repositories/checkout_permissions/checkout_permissions.repository.module';
import { NotificationModule } from '../../../features//notification/notification.module';
import { NotificationhubModule } from '../../../gateways/notificationhub/notificationhub.module';
import { BlobstorageModule } from '../../../gateways/blobstorage/blobstorage.module';
import { AuthGuardsModule } from '../../../common/guards/auth/authguards.module';
import { SortCriteriaRepositoryModule } from '../../../repositories/sort_criteria/sort_criteria.repository.module';
import { AuthService } from '../../../features/auth/auth.service';
import { AccountsService } from '../../../features/accounts/accounts.service';
import { UsersService } from '../../../features/users/users.service';
import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service';
import { FilesService } from '../../../features/files/files.service';
import { LicensesService } from '../../../features/licenses/licenses.service';
import { TasksService } from '../../../features/tasks/tasks.service';
import { User } from '../../../repositories/users/entity/user.entity';
import { Account } from '../../../repositories/accounts/entity/account.entity';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
import { License } from '../../../repositories/licenses/entity/license.entity';
import { AdB2cMockValue, makeAdB2cServiceMock } from './users.service.mock';
import { AdB2cService } from '../../../gateways/adb2c/adb2c.service';
import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../../constants';
export const createAccount = async (
datasource: DataSource,
): Promise<{ accountId: number }> => {
const { identifiers } = await datasource.getRepository(Account).insert({
tier: 1,
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 = identifiers.pop() as Account;
return { accountId: account.id };
};
export const createUser = async (
datasource: DataSource,
accountId: number,
external_id: string,
role: string,
author_id?: string | undefined,
auto_renew?: boolean,
): Promise<{ userId: number; externalId: string }> => {
const { identifiers } = await datasource.getRepository(User).insert({
account_id: accountId,
external_id: external_id,
role: role,
accepted_terms_version: '1.0',
author_id: author_id,
email_verified: true,
auto_renew: auto_renew,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const user = identifiers.pop() as User;
return { userId: user.id, externalId: external_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;
const userGroupMenber = user_id.map((id) => {
return {
user_group_id: userGroup.id,
user_id: id,
created_by: 'test',
updated_by: 'test',
};
});
await datasource.getRepository(UserGroupMember).save(userGroupMenber);
return { userGroupId: userGroup.id };
};
export const createLicense = async (
datasource: DataSource,
accountId: number,
userId?: number,
expiry_date?: Date,
): Promise<void> => {
const { identifiers } = await datasource.getRepository(License).insert({
account_id: accountId,
type: LICENSE_TYPE.NORMAL,
status: LICENSE_ALLOCATED_STATUS.ALLOCATED,
allocated_user_id: userId,
expiry_date: expiry_date,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
identifiers.pop() as License;
};
export const makeTestingModuleWithAdb2c = async (
datasource: DataSource,
adB2cMockValue: AdB2cMockValue,
): Promise<TestingModule> => {
try {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.local', '.env'],
isGlobal: true,
}),
AuthModule,
AdB2cModule,
AccountsModule,
UsersModule,
FilesModule,
TasksModule,
UsersModule,
SendGridModule,
LicensesModule,
AccountsRepositoryModule,
UsersRepositoryModule,
LicensesRepositoryModule,
AudioFilesRepositoryModule,
AudioOptionItemsRepositoryModule,
TasksRepositoryModule,
CheckoutPermissionsRepositoryModule,
UserGroupsRepositoryModule,
UserGroupsRepositoryModule,
NotificationModule,
NotificationhubModule,
BlobstorageModule,
AuthGuardsModule,
SortCriteriaRepositoryModule,
],
providers: [
AuthService,
AccountsService,
UsersService,
NotificationhubService,
FilesService,
TasksService,
LicensesService,
],
})
.useMocker(async (token) => {
switch (token) {
case DataSource:
return datasource;
}
})
.overrideProvider(AdB2cService)
.useValue(makeAdB2cServiceMock(adB2cMockValue))
.compile();
return module;
} catch (e) {
console.log(e);
}
};

View File

@ -115,12 +115,10 @@ export class UsersController {
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@Get()
async getUsers(@Req() req: Request): Promise<GetUsersResponse> {
console.log(req.header('Authorization'));
const accessToken = retrieveAuthorizationToken(req);
const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken;
const users = await this.usersService.getUsers(decodedToken);
const users = await this.usersService.getUsers(decodedToken.userId);
return { users };
}

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,11 @@ import { User as EntityUser } from '../../repositories/users/entity/user.entity'
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { GetRelationsResponse, User } from './types/types';
import { EmailAlreadyVerifiedError } from '../../repositories/users/errors/types';
import { USER_LICENSE_STATUS } from '../../constants';
import {
LICENSE_EXPIRATION_THRESHOLD_DAYS,
USER_LICENSE_STATUS,
} from '../../constants';
import { DateWithZeroTime } from '../licenses/types/types';
@Injectable()
export class UsersService {
@ -293,41 +297,79 @@ export class UsersService {
* @param accessToken
* @returns users
*/
async getUsers(accessToken: AccessToken): Promise<User[]> {
async getUsers(externalId: string): Promise<User[]> {
this.logger.log(`[IN] ${this.getUsers.name}`);
try {
// DBから同一アカウントのユーザ一覧を取得する
const dbUsers = await this.usersRepository.findSameAccountUsers(
accessToken.userId,
externalId,
);
// 値をマージして定義されたレスポンス通りに返す
const users: User[] = [];
// TODO [Task2002] 膨大なループが発生することが見込まれ商用には耐えないので、本実装時に修正予定
for (let i = 0; i < dbUsers.length; i++) {
// Azure AD B2Cからユーザーを取得する
const aadb2cUser = await this.adB2cService.getUser(
dbUsers[i].external_id,
);
// DBから取得したユーザーの外部IDをもとにADB2Cからユーザーを取得する
const externalIds = dbUsers.map((x) => x.external_id);
const adb2cUsers = await this.adB2cService.getUsers(externalIds);
// DBから取得した各ユーザーをもとにADB2C情報をマージしライセンス情報を算出
const users = dbUsers.map((x) => {
// ユーザーの所属グループ名を取得する
const groupNames =
x.userGroupMembers?.map((group) => group.userGroup?.name) ?? [];
const adb2cUser = adb2cUsers.find((user) => user.id === x.external_id);
// メールアドレスを取得する
const mail = adb2cUser.identities.find(
(identity) => identity.signInType === 'emailAddress',
).issuerAssignedId;
let status = USER_LICENSE_STATUS.NORMAL;
// ライセンスの有効期限と残日数は、ライセンスが存在する場合のみ算出する
// ライセンスが存在しない場合は、undefinedのままとする
let expiration: string | undefined = undefined;
let remaining: number | undefined = undefined;
if (x.license) {
// 有効期限日付 YYYY/MM/DD
const expiry_date = x.license.expiry_date;
expiration = `${expiry_date.getFullYear()}/${
expiry_date.getMonth() + 1
}/${expiry_date.getDate()}`;
const currentDate = new DateWithZeroTime();
// 有効期限までの日数
remaining = Math.floor(
(expiry_date.getTime() - currentDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (remaining <= LICENSE_EXPIRATION_THRESHOLD_DAYS) {
status = x.auto_renew
? USER_LICENSE_STATUS.RENEW
: USER_LICENSE_STATUS.ALERT;
}
} else {
status = USER_LICENSE_STATUS.NO_LICENSE;
}
return {
name: adb2cUser.displayName,
role: x.role,
authorId: x.author_id ?? undefined,
typistGroupName: groupNames,
email: mail,
emailVerified: x.email_verified,
autoRenew: x.auto_renew,
licenseAlert: x.license_alert,
notification: x.notification,
encryption: x.encryption,
prompt: x.prompt,
expiration: expiration,
remaining: remaining,
licenseStatus: status,
};
});
const user = new User();
user.name = aadb2cUser.displayName;
user.role = dbUsers[i].role;
user.authorId = dbUsers[i].author_id;
// TODO [Task2247] 将来的にはDBから取得できるようになる想定のため暫定的にtypistGroupNameに仮の値を設定
user.typistGroupName = [''];
user.email = aadb2cUser.mail;
user.emailVerified = dbUsers[i].email_verified;
user.autoRenew = dbUsers[i].auto_renew;
user.licenseAlert = dbUsers[i].license_alert;
user.notification = dbUsers[i].notification;
// TODO: 仮の値を入れておく
user.encryption = false;
user.prompt = false;
user.licenseStatus = USER_LICENSE_STATUS.NO_LICENSE;
users.push(user);
}
return users;
} catch (e) {
this.logger.error(`error=${e}`);

View File

@ -206,9 +206,10 @@ export class AdB2cService {
const element = chunkExternalIds[index];
const res: AdB2cResponse = await this.graphClient
.api(`users/`)
.select(['id', 'displayName'])
.select(['id', 'displayName', 'identities'])
.filter(`id in (${element.map((y) => `'${y}'`).join(',')})`)
.get();
b2cUsers.push(...res.value);
}

View File

@ -2,4 +2,14 @@ export type AdB2cResponse = {
'@odata.context': string;
value: AdB2cUser[];
};
export type AdB2cUser = { id: string; displayName: string };
export type AdB2cUser = {
id: string;
displayName: string;
identities?: UserIdentity[];
};
export type UserIdentity = {
signInType: string;
issuer: string;
issuerAssignedId: string;
};

View File

@ -4,7 +4,10 @@ import {
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entity/user.entity';
@Entity({ name: 'license_orders' })
export class LicenseOrder {
@ -88,6 +91,10 @@ export class License {
@UpdateDateColumn()
updated_at: Date;
@OneToOne(() => User, (user) => user.license)
@JoinColumn({ name: 'allocated_user_id' })
user?: User;
}
@Entity({ name: 'licenses_history' })
export class LicenseHistory {

View File

@ -1,4 +1,5 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { UserGroupMember } from './user_group_member.entity';
@Entity({ name: 'user_group' })
export class UserGroup {
@ -25,4 +26,10 @@ export class UserGroup {
@Column({ nullable: true })
updated_at?: Date;
@OneToMany(
() => UserGroupMember,
(userGroupMember) => userGroupMember.userGroup,
)
userGroupMembers?: UserGroupMember[];
}

View File

@ -3,9 +3,10 @@ import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToOne,
JoinColumn,
ManyToOne,
} from 'typeorm';
import { UserGroup } from './user_group.entity';
@Entity({ name: 'user_group_member' })
export class UserGroupMember {
@ -33,7 +34,11 @@ export class UserGroupMember {
@Column({ nullable: true })
updated_at?: Date;
@OneToOne(() => User, (user) => user.id)
@ManyToOne(() => User, (user) => user.id)
@JoinColumn({ name: 'user_id' })
user?: User;
@ManyToOne(() => UserGroup, (userGroup) => userGroup.id)
@JoinColumn({ name: 'user_group_id' })
userGroup?: UserGroup;
}

View File

@ -7,7 +7,11 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToOne,
OneToMany,
} from 'typeorm';
import { License } from '../../licenses/entity/license.entity';
import { UserGroupMember } from '../../user_groups/entity/user_group_member.entity';
@Entity({ name: 'users' })
export class User {
@ -41,6 +45,12 @@ export class User {
@Column()
notification: boolean;
@Column()
encryption: boolean;
@Column()
prompt: boolean;
@Column({ nullable: true })
deleted_at?: Date;
@ -59,4 +69,10 @@ export class User {
@ManyToOne(() => Account, (account) => account.user)
@JoinColumn({ name: 'account_id' })
account?: Account;
@OneToOne(() => License, (license) => license.user)
license?: License;
@OneToMany(() => UserGroupMember, (userGroupMember) => userGroupMember.user)
userGroupMembers?: UserGroupMember[];
}

View File

@ -203,21 +203,28 @@ export class UsersRepositoryService {
/**
* IDを持つユーザーを探す
* @param criteria
* @param externalId
* @returns User[]
*/
async findSameAccountUsers(criteria: string): Promise<User[]> {
const dbUser = await this.dataSource.getRepository(User).findOne({
where: {
external_id: criteria,
},
async findSameAccountUsers(external_id: string): Promise<User[]> {
return await this.dataSource.transaction(async (entityManager) => {
const repo = entityManager.getRepository(User);
const accountId = (await repo.findOne({ where: { external_id } }))
.account_id;
const dbUsers = await this.dataSource.getRepository(User).find({
relations: {
userGroupMembers: {
userGroup: true,
},
license: true,
},
where: { account_id: accountId },
});
return dbUsers;
});
const dbUsers = await this.dataSource
.getRepository(User)
.find({ where: { account_id: dbUser.account_id } });
return dbUsers;
}
/**