From 2812bc3d20ec187ce4267cfcb0f4e5bc41158bbe Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Fri, 8 Sep 2023 09:45:10 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20383:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=91=E3=83=BC=E3=83=88=E3=83=8A=E3=83=BC=E5=8F=96?= =?UTF-8?q?=E5=BE=97API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2540: API実装(パートナー取得API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2540) パートナー取得APIを実装しました。 ## レビューポイント ・データ取得方法が適切かどうか。 以下の優先順位を意識して作成したが適切か?また、意識できていない実装になっていないか? ①QueryBuilderを使用せずに処理する ②RDB、adb2cへのアクセス回数を最小限にする ## UIの変更 なし ## 動作確認状況 ローカルで動作確認済み、UT実施済み ## 補足 プライマリ、セカンダリ管理者IDがない場合のテストはUTでは実装せず、ローカルでの動作確認で正常に動作することを確認しました。 (プライマリ、セカンダリ管理者IDを指定してアカウントを作成するテストユーティリティを作成する必要があるが、あまり汎用的には思えず作成する手間が惜しかったため) --- dictation_server/src/common/test/overrides.ts | 11 ++ dictation_server/src/common/test/utility.ts | 9 +- dictation_server/src/constants/index.ts | 8 + .../features/accounts/accounts.controller.ts | 46 +----- .../accounts/accounts.service.spec.ts | 143 ++++++++++++++++++ .../src/features/accounts/accounts.service.ts | 84 +++++++++- .../src/features/accounts/types/types.ts | 10 ++ .../features/users/test/users.service.mock.ts | 7 +- .../src/features/users/users.service.ts | 3 +- .../src/gateways/adb2c/adb2c.service.ts | 3 +- .../accounts/accounts.repository.service.ts | 80 ++++++++++ 11 files changed, 358 insertions(+), 46 deletions(-) diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index ec2b531..014c7e1 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -9,6 +9,7 @@ import { UsersRepositoryService } from '../../repositories/users/users.repositor import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; import { Account } from '../../repositories/accounts/entity/account.entity'; +import { AdB2cUser } from '../../gateways/adb2c/types/types'; // ### ユニットテスト用コード以外では絶対に使用してはいけないダーティな手段を使用しているが、他の箇所では使用しないこと ### @@ -28,6 +29,10 @@ export const overrideAdB2cService = ( username: string, ) => Promise<{ sub: string } | ConflictError>; deleteUser?: (externalId: string, context: Context) => Promise; + getUsers?: ( + context: Context, + externalIds: string[], + ) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -44,6 +49,12 @@ export const overrideAdB2cService = ( writable: true, }); } + if (overrides.getUsers) { + Object.defineProperty(obj, obj.getUsers.name, { + value: overrides.getUsers, + writable: true, + }); + } }; /** diff --git a/dictation_server/src/common/test/utility.ts b/dictation_server/src/common/test/utility.ts index a2f1005..a5c998b 100644 --- a/dictation_server/src/common/test/utility.ts +++ b/dictation_server/src/common/test/utility.ts @@ -149,6 +149,8 @@ export const makeTestAccount = async ( datasource: DataSource, defaultAccountValue?: AccountDefault, defaultAdminUserValue?: UserDefault, + isPrimaryAdminNotExist?: boolean, + isSecondaryAdminNotExist?: boolean, ): Promise<{ account: Account; admin: User }> => { let accountId: number; let userId: number; @@ -198,10 +200,15 @@ export const makeTestAccount = async ( } // Accountの管理者を設定する + let secondaryAdminUserId = null; + if (isPrimaryAdminNotExist && !isSecondaryAdminNotExist) { + secondaryAdminUserId = userId; + } await datasource.getRepository(Account).update( { id: accountId }, { - primary_admin_user_id: userId, + primary_admin_user_id: isPrimaryAdminNotExist ? null : userId, + secondary_admin_user_id: secondaryAdminUserId, }, ); diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index ca38ec5..66471b9 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -238,3 +238,11 @@ export const OPTION_ITEM_VALUE_TYPE = { BLANK: 'Blank', LAST_INPUT: 'LastInput', } as const; + +/** + * ADB2Cユーザのidentity.signInType + * @const {string[]} + */ +export const ADB2C_SIGN_IN_TYPE = { + EAMILADDRESS: 'emailAddress', +} as const; diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 84c091b..7099620 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -957,45 +957,13 @@ export class AccountsController { const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - // TODO: パートナー取得APIで実装 - // await this.accountService.getPartners( - // context, - // body.limit, - // body.offset, - // ); + const response = await this.accountService.getPartners( + context, + userId, + limit, + offset, + ); - // 仮のreturn - return { - total: 1, - partners: [ - { - name: 'testA', - tier: 5, - accountId: 1, - country: 'US', - primaryAdmin: 'nameA', - email: 'aaa@example.com', - dealerManagement: true, - }, - { - name: 'testB', - tier: 5, - accountId: 2, - country: 'US', - primaryAdmin: 'nameB', - email: 'bbb@example.com', - dealerManagement: false, - }, - { - name: 'testC', - tier: 5, - accountId: 1, - country: 'US', - primaryAdmin: 'nothing', - email: 'nothing', - dealerManagement: false, - }, - ], - }; + return response; } } diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 618abf9..6932651 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -37,6 +37,7 @@ import { import { AccountsService } from './accounts.service'; import { Context, makeContext } from '../../common/log'; import { + ADB2C_SIGN_IN_TYPE, LICENSE_ALLOCATED_STATUS, LICENSE_ISSUE_STATUS, LICENSE_TYPE, @@ -61,6 +62,7 @@ import { selectOrderLicense, } from '../licenses/test/utility'; import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service'; +import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { Worktype } from '../../repositories/worktypes/entity/worktype.entity'; describe('createAccount', () => { @@ -4155,3 +4157,144 @@ describe('ライセンス発行キャンセル', () => { ); }); }); + +describe('パートナー一覧取得', () => { + 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('パートナー一覧を取得する', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const { tier1Accounts: tier1Accounts, tier2Accounts: tier2Accounts } = + await makeHierarchicalAccounts(source); + const tier1Difference = await makeTestAccount(source, { + tier: 1, + }); + const tier2_3 = await makeTestAccount( + source, + { + parent_account_id: tier1Accounts[0].account.id, + tier: 2, + }, + {}, + true, + false, + ); + const tier2_4 = await makeTestAccount( + source, + { + parent_account_id: tier1Accounts[0].account.id, + tier: 2, + }, + {}, + true, + true, + ); + + await makeTestAccount(source, { + parent_account_id: tier1Difference.account.id, + tier: 2, + }); + + const adb2cReturn = [ + { + id: tier2Accounts[0].users[0].external_id, + displayName: 'partner1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'partner1@example.com', + }, + ], + }, + { + id: tier2Accounts[1].users[0].external_id, + displayName: 'partner2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'partner2@example.com', + }, + ], + }, + ] as AdB2cUser[]; + + overrideAdB2cService(service, { + getUsers: async (_context: Context, _externalIds: string[]) => { + return adb2cReturn; + }, + }); + + const partners = await service.getPartners( + makeContext('trackingId'), + tier1Accounts[0].users[0].external_id, + 15, + 0, + ); + + // 違うアカウントのパートナーは取得していないこと + expect(partners.total).toBe(4); + // 会社名の昇順に取得できていること + expect(partners.partners[0].name).toBe( + tier2Accounts[1].account.company_name, + ); + expect(partners.partners[1].name).toBe( + tier2Accounts[0].account.company_name, + ); + expect(partners.partners[2].name).toBe(tier2_3.account.company_name); + expect(partners.partners[3].name).toBe(tier2_4.account.company_name); + expect(partners.partners[0].email).toBe('partner2@example.com'); + expect(partners.partners[1].email).toBe('partner1@example.com'); + expect(partners.partners[2].email).toBeUndefined; + expect(partners.partners[3].email).toBeUndefined; + + expect(partners.partners[0].tier).toBe(tier2Accounts[1].account.tier); + expect(partners.partners[0].country).toBe(tier2Accounts[1].account.country); + expect(partners.partners[0].accountId).toBe(tier2Accounts[1].account.id); + expect(partners.partners[0].tier).toBe(tier2Accounts[1].account.tier); + expect(partners.partners[0].primaryAdmin).toBe('partner2'); + expect(partners.partners[0].dealerManagement).toBe( + tier2Accounts[1].account.delegation_permission, + ); + }); + it('パートナー一覧を取得する(パートナーが0件の場合)', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const account = await makeTestAccount(source, { + tier: 1, + }); + + const adb2cReturn = [{}] as AdB2cUser[]; + + overrideAdB2cService(service, { + getUsers: async (_context: Context, _externalIds: string[]) => { + return adb2cReturn; + }, + }); + + const partners = await service.getPartners( + makeContext('trackingId'), + account.admin.external_id, + 15, + 0, + ); + + // 結果が0件で成功となること + expect(partners.total).toBe(0); + }); +}); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index bdfb545..23a9fc2 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -10,7 +10,7 @@ import { } from '../../gateways/adb2c/adb2c.service'; import { Account } from '../../repositories/accounts/entity/account.entity'; import { User } from '../../repositories/users/entity/user.entity'; -import { TIERS, USER_ROLES } from '../../constants'; +import { TIERS, USER_ROLES, ADB2C_SIGN_IN_TYPE } from '../../constants'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { TypistGroup, @@ -23,6 +23,7 @@ import { GetMyAccountResponse, GetTypistGroupResponse, GetWorktypesResponse, + GetPartnersResponse, } from './types/types'; import { DateWithZeroTime, @@ -1312,4 +1313,85 @@ export class AccountsService { ); } } + + /** + * パートナー一覧を取得します + * @param context + * @param externalId + * @param limit + * @param offset + * @returns GetPartnersResponse + */ + async getPartners( + context: Context, + externalId: string, + limit: number, + offset: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getPartners.name} | params: { ` + + `externalId: ${externalId}, ` + + `limit: ${limit}, ` + + `offset: ${offset}, };`, + ); + + try { + const { account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + const partners = await this.accountRepository.getPartners( + accountId, + limit, + offset, + ); + + // DBから取得したユーザーの外部IDをもとにADB2Cからユーザーを取得する + let externalIds = partners.partnersInfo.map( + (x) => x.primaryAccountExternalId, + ); + externalIds = externalIds.filter((item) => item !== undefined); + const adb2cUsers = await this.adB2cService.getUsers(context, externalIds); + + // DBから取得した情報とADB2Cから取得した情報をマージ + const response = partners.partnersInfo.map((db) => { + const adb2cUser = adb2cUsers.find( + (adb2c) => db.primaryAccountExternalId === adb2c.id, + ); + + let primaryAdmin = undefined; + let mail = undefined; + if (adb2cUser) { + primaryAdmin = adb2cUser.displayName; + mail = adb2cUser.identities.find( + (identity) => + identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + ).issuerAssignedId; + } + return { + name: db.name, + tier: db.tier, + accountId: db.accountId, + country: db.country, + primaryAdmin: primaryAdmin, + email: mail, + dealerManagement: db.dealerManagement, + }; + }); + + return { + total: partners.total, + partners: response, + }; + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } finally { + this.logger.log(`[OUT] [${context.trackingId}] ${this.getPartners.name}`); + } + } } diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index d63013c..216b3cb 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -480,3 +480,13 @@ export class GetPartnersResponse { @ApiProperty({ type: [Partner] }) partners: Partner[]; } + +// RepositoryからPartnerLicenseInfoに関する情報を取得する際の型 +export type PartnerInfoFromDb = { + name: string; + tier: number; + accountId: number; + country: string; + primaryAccountExternalId: string; + dealerManagement: boolean; +}; diff --git a/dictation_server/src/features/users/test/users.service.mock.ts b/dictation_server/src/features/users/test/users.service.mock.ts index 4882ce2..d874872 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -17,6 +17,7 @@ import { TaskListSortableAttribute, } from '../../../common/types/sort'; import { AdB2cUser } from '../../../gateways/adb2c/types/types'; +import { ADB2C_SIGN_IN_TYPE } from '../../../constants'; export type SortCriteriaRepositoryMockValue = { updateSortCriteria: SortCriteria | Error; @@ -403,7 +404,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test1', identities: [ { - signInType: 'emailAddress', + signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, issuer: 'issuer', issuerAssignedId: 'test1@mail.com', }, @@ -414,7 +415,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test2', identities: [ { - signInType: 'emailAddress', + signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, issuer: 'issuer', issuerAssignedId: 'test2@mail.com', }, @@ -425,7 +426,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test3', identities: [ { - signInType: 'emailAddress', + signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, issuer: 'issuer', issuerAssignedId: 'test3@mail.com', }, diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 0fc460c..112140b 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -33,6 +33,7 @@ import { UserNotFoundError, } from '../../repositories/users/errors/types'; import { + ADB2C_SIGN_IN_TYPE, LICENSE_EXPIRATION_THRESHOLD_DAYS, USER_LICENSE_STATUS, USER_ROLES, @@ -470,7 +471,7 @@ export class UsersService { // メールアドレスを取得する const mail = adb2cUser.identities.find( - (identity) => identity.signInType === 'emailAddress', + (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS, ).issuerAssignedId; let status = USER_LICENSE_STATUS.NORMAL; diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index 7d8c17b..d1000c5 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -7,6 +7,7 @@ import axios from 'axios'; import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token'; import { AdB2cResponse, AdB2cUser } from './types/types'; import { Context } from '../../common/log'; +import { ADB2C_SIGN_IN_TYPE } from '../../constants'; export type ConflictError = { reason: 'email'; @@ -74,7 +75,7 @@ export class AdB2cService { }, identities: [ { - signinType: 'emailAddress', + signinType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, issuer: `${this.tenantName}.onmicrosoft.com`, issuerAssignedId: email, }, diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index cdd882c..eb6523a 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -27,6 +27,7 @@ import { import { LicenseSummaryInfo, PartnerLicenseInfoForRepository, + PartnerInfoFromDb, } from '../../features/accounts/types/types'; import { AccountNotFoundError } from './errors/types'; import { @@ -685,4 +686,83 @@ export class AccountsRepositoryService { await licenseRepo.delete({ order_id: targetOrder.id }); }); } + + /** + * アカウントIDをもとに、パートナー一覧を取得する + * @param id + * @param limit + * @param offset + * @returns total: 総件数 + * @returns partners: DBから取得できるパートナー一覧情報 + */ + async getPartners( + id: number, + limit: number, + offset: number, + ): Promise<{ + total: number; + partnersInfo: PartnerInfoFromDb[]; + }> { + return await this.dataSource.transaction(async (entityManager) => { + const accountRepo = entityManager.getRepository(Account); + + // limit/offsetによらない総件数を取得する + const total = await accountRepo.count({ + where: { + parent_account_id: id, + }, + }); + + const partnerAccounts = await accountRepo.find({ + where: { + parent_account_id: id, + }, + order: { + company_name: 'ASC', + }, + take: limit, + skip: offset, + }); + + // ADB2Cから情報を取得するための外部ユーザIDを取得する(念のためプライマリ管理者IDが存在しない場合を考慮) + const primaryUserIds = partnerAccounts.map((x) => { + if (x.primary_admin_user_id) { + return x.primary_admin_user_id; + } else if (x.secondary_admin_user_id) { + return x.secondary_admin_user_id; + } + }); + const userRepo = entityManager.getRepository(User); + const primaryUsers = await userRepo.find({ + where: { + id: In(primaryUserIds), + }, + }); + + // アカウント情報とプライマリ管理者の外部ユーザIDをマージ + const partners = partnerAccounts.map((account) => { + const primaryUser = primaryUsers.find( + (user) => + user.id === account.primary_admin_user_id || + user.id === account.secondary_admin_user_id, + ); + const primaryAccountExternalId = primaryUser + ? primaryUser.external_id + : undefined; + return { + name: account.company_name, + tier: account.tier, + accountId: account.id, + country: account.country, + primaryAccountExternalId: primaryAccountExternalId, + dealerManagement: account.delegation_permission, + }; + }); + + return { + total: total, + partnersInfo: partners, + }; + }); + } }