Merged PR 846: パートナーアカウント削除API実装

## 概要
[Task3834: パートナーアカウント削除API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3834)

- パートナーアカウント削除APIとUTを実装しました。

## レビューポイント
- 削除対象データは適切でしょうか?
- テストケースに不足はないでしょうか?

## UIの変更
- なし

## クエリの変更
- なし

## 動作確認状況
- ローカルで確認
  - テストとローカルで実行確認
- 行った修正がデグレを発生させていないことを確認できるか
  - 既存処理への変更なし
This commit is contained in:
makabe.t 2024-03-22 06:12:47 +00:00
parent ac3d523c0e
commit 6e93a5be79
10 changed files with 1682 additions and 3 deletions

View File

@ -86,4 +86,5 @@ export const ErrorCodes = [
'E017002', // 親アカウント変更不可エラー(階層関係が不正)
'E017003', // 親アカウント変更不可エラー(リージョンが同一でない)
'E017004', // 親アカウント変更不可エラー(国が同一でない)
'E018001', // パートナーアカウント削除エラー(削除条件を満たしていない)
] as const;

View File

@ -76,4 +76,5 @@ export const errors: Errors = {
E017002: 'Parent account switch failed Error: hierarchy mismatch',
E017003: 'Parent account switch failed Error: region mismatch',
E017004: 'Parent account switch failed Error: country mismatch',
E018001: 'Partner account delete failed Error: not satisfied conditions',
};

View File

@ -2471,8 +2471,11 @@ export class AccountsController {
const context = makeContext(userId, requestId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
// TODO:service層を呼び出す。本実装時に以下は削除する。
// await this.accountService.deletePartnerAccount(context, userId, targetAccountId);
await this.accountService.deletePartnerAccount(
context,
userId,
targetAccountId,
);
return {};
}

File diff suppressed because it is too large Load Diff

View File

@ -54,6 +54,7 @@ import {
DealerAccountNotFoundError,
HierarchyMismatchError,
RegionMismatchError,
PartnerAccountDeletionError,
} from '../../repositories/accounts/errors/types';
import { Context } from '../../common/log';
import {
@ -2833,4 +2834,151 @@ export class AccountsService {
throw new Error(`Invalid country. country=${country}`);
}
}
/**
* IDのパートナーアカウントを削除する
* @param context
* @param externalId
* @param targetAccountId ID
* @returns partner account
*/
async deletePartnerAccount(
context: Context,
externalId: string,
targetAccountId: number,
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.deletePartnerAccount.name
} | params: { ` +
`externalId: ${externalId}, ` +
`targetAccountId: ${targetAccountId},};`,
);
try {
// 外部IDをもとにユーザー情報を取得する
const { account: parentAccount } =
await this.usersRepository.findUserByExternalId(context, externalId);
if (parentAccount === null) {
throw new AccountNotFoundError(
`account not found. externalId: ${externalId}`,
);
}
// 削除対象のパートナーアカウントを取得する
const targetAccount = await this.accountRepository.findAccountById(
context,
targetAccountId,
);
if (targetAccount === null) {
throw new AccountNotFoundError(
`Account not found. targetAccountId: ${targetAccountId}`,
);
}
// メール送信に必要な情報を取得する
if (!targetAccount.primary_admin_user_id) {
throw new Error(
`primary_admin_user_id not found. accountId: ${targetAccountId}`,
);
}
const primaryAdminUser = await this.usersRepository.findUserById(
context,
targetAccount.primary_admin_user_id,
);
const adb2cAdmin = await this.adB2cService.getUser(
context,
primaryAdminUser.external_id,
);
const {
displayName: targetPrimaryAdminName,
emailAddress: targetPrimaryAdminEmail,
} = getUserNameAndMailAddress(adb2cAdmin);
if (!targetPrimaryAdminEmail) {
throw new Error(
`adb2c user mail not found. externalId: ${primaryAdminUser.external_id}`,
);
}
// アカウント削除処理(DB)
const targetUsers = await this.accountRepository.deletePartnerAccount(
context,
parentAccount.id,
targetAccountId,
);
// アカウント削除処理(Azure AD B2C)
try {
// 削除対象アカウント内のADB2Cユーザーをすべて削除する
await this.adB2cService.deleteUsers(
targetUsers.map((x) => x.external_id),
context,
);
this.logger.log(
`[${context.getTrackingId()}] delete ADB2C users: ${targetAccountId}, users_id: ${targetUsers.map(
(x) => x.external_id,
)}`,
);
} catch (e) {
// ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行
this.logger.log(`[${context.getTrackingId()}] ${e}`);
this.logger.error(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete ADB2C users: ${targetAccountId}, users_id: ${targetUsers.map(
(x) => x.external_id,
)}`,
);
}
// アカウント削除処理(Blob Storage)
await this.deleteBlobContainer(
targetAccountId,
targetAccount.country,
context,
);
// メール送信処理
try {
const { companyName: parentCompanyName, adminEmails: parentEmails } =
await this.getAccountInformation(context, parentAccount.id);
await this.sendgridService.sendMailWithU123(
context,
targetAccount.company_name,
targetPrimaryAdminName,
targetPrimaryAdminEmail,
parentCompanyName,
parentEmails,
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
// メール送信に関する例外はログだけ出して握りつぶす
}
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case PartnerAccountDeletionError:
throw new HttpException(
makeErrorResponse('E018001'),
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.getTrackingId()}] ${this.deletePartnerAccount.name}`,
);
}
}
}

View File

@ -87,6 +87,8 @@ export class SendGridService {
private readonly templateU122Text: string;
private readonly templateU122NoParentHtml: string;
private readonly templateU122NoParentText: string;
private readonly templateU123Html: string;
private readonly templateU123Text: string;
constructor(private readonly configService: ConfigService) {
this.appDomain = this.configService.getOrThrow<string>('APP_DOMAIN');
@ -328,6 +330,14 @@ export class SendGridService {
path.resolve(__dirname, `../../templates/template_U_122_no_parent.txt`),
'utf-8',
);
this.templateU123Html = readFileSync(
path.resolve(__dirname, `../../templates/template_U_123.html`),
'utf-8',
);
this.templateU123Text = readFileSync(
path.resolve(__dirname, `../../templates/template_U_123.txt`),
'utf-8',
);
}
}
@ -1428,6 +1438,56 @@ export class SendGridService {
}
}
/**
* U-123使
* @param context
* @param partnerAccountName
* @param partnerPrimaryName
* @param partnerPrimaryMail
* @param dealerAccountName
* @param dealerEmails
* @returns mail with u123
*/
async sendMailWithU123(
context: Context,
partnerAccountName: string,
partnerPrimaryName: string,
partnerPrimaryMail: string,
dealerAccountName: string,
dealerEmails: string[],
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${this.sendMailWithU123.name}`,
);
try {
const subject = 'パートナーアカウント情報消去完了通知 [U-123]';
const html = this.templateU123Html
.replaceAll(CUSTOMER_NAME, partnerAccountName)
.replaceAll(PRIMARY_ADMIN_NAME, partnerPrimaryName)
.replaceAll(DEALER_NAME, dealerAccountName);
const text = this.templateU123Text
.replaceAll(CUSTOMER_NAME, partnerAccountName)
.replaceAll(PRIMARY_ADMIN_NAME, partnerPrimaryName)
.replaceAll(DEALER_NAME, dealerAccountName);
// メールを送信する
await this.sendMail(
context,
[partnerPrimaryMail],
dealerEmails,
this.mailFrom,
subject,
text,
html,
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.sendMailWithU123.name}`,
);
}
}
/**
*
* @param context

View File

@ -35,6 +35,7 @@ import {
AccountNotFoundError,
AdminUserNotFoundError,
DealerAccountNotFoundError,
PartnerAccountDeletionError,
} from './errors/types';
import {
AlreadyLicenseAllocatedError,
@ -1547,4 +1548,224 @@ export class AccountsRepositoryService {
);
});
}
/**
*
* @param context
* @param parentAccountId
* @param targetAccountId
* @returns partner account
*/
async deletePartnerAccount(
context: Context,
parentAccountId: number,
targetAccountId: number,
): Promise<User[]> {
return await this.dataSource.transaction(async (entityManager) => {
// 削除対象のユーザーを取得
const userRepo = entityManager.getRepository(User);
const users = await userRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
const accountRepo = entityManager.getRepository(Account);
// 対象アカウントが存在するかチェック
const targetAccount = await accountRepo.findOne({
where: { id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
if (!targetAccount) {
throw new AccountNotFoundError(
`Account is not found. id: ${targetAccountId}`,
);
}
// 実行者のアカウントが対象アカウントの親アカウントでない場合はエラー
if (targetAccount.parent_account_id !== parentAccountId) {
throw new PartnerAccountDeletionError(
`Target account is not child account. parentAccountId: ${parentAccountId}, targetAccountId: ${targetAccountId}`,
);
}
// 対象アカウントに子アカウントが存在する場合はエラー
const childrenAccounts = await accountRepo.find({
where: { parent_account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
// 子アカウントが存在する場合はエラー
if (childrenAccounts.length > 0) {
throw new PartnerAccountDeletionError(
`Target account has children account. targetAccountId: ${targetAccountId}`,
);
}
// ユーザテーブルのレコードを削除する
await deleteEntity(
userRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// ソート条件のテーブルのレコードを削除する
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
await deleteEntity(
sortCriteriaRepo,
{ user_id: In(users.map((user) => user.id)) },
this.isCommentOut,
context,
);
// アカウントを削除
await deleteEntity(
accountRepo,
{ id: targetAccountId },
this.isCommentOut,
context,
);
// ライセンス系(card_license_issue以外)のテーブルのレコードを削除する
const orderRepo = entityManager.getRepository(LicenseOrder);
await deleteEntity(
orderRepo,
{ from_account_id: targetAccountId },
this.isCommentOut,
context,
);
const licenseRepo = entityManager.getRepository(License);
const targetLicenses = await licenseRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
const cardLicenseRepo = entityManager.getRepository(CardLicense);
await deleteEntity(
cardLicenseRepo,
{ license_id: In(targetLicenses.map((license) => license.id)) },
this.isCommentOut,
context,
);
await deleteEntity(
licenseRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
const licenseAllocationHistoryRepo = entityManager.getRepository(
LicenseAllocationHistory,
);
await deleteEntity(
licenseAllocationHistoryRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// ユーザーグループ系のテーブルのレコードを削除する
const userGroupRepo = entityManager.getRepository(UserGroup);
const targetUserGroup = await userGroupRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
const userGroupMemberRepo = entityManager.getRepository(UserGroupMember);
await deleteEntity(
userGroupMemberRepo,
{
user_group_id: In(targetUserGroup.map((userGroup) => userGroup.id)),
},
this.isCommentOut,
context,
);
await deleteEntity(
userGroupRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// ワークタイプ系のテーブルのレコードを削除する
const worktypeRepo = entityManager.getRepository(Worktype);
const taggerWorktypes = await worktypeRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
const optionItemRepo = entityManager.getRepository(OptionItem);
await deleteEntity(
optionItemRepo,
{ worktype_id: In(taggerWorktypes.map((worktype) => worktype.id)) },
this.isCommentOut,
context,
);
await deleteEntity(
worktypeRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// テンプレートファイルテーブルのレコードを削除する
const templateFileRepo = entityManager.getRepository(TemplateFile);
await deleteEntity(
templateFileRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// オーディオファイル系のテーブルのレコードを削除する
const audioFileRepo = entityManager.getRepository(AudioFile);
const targetaudioFiles = await audioFileRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
const audioOptionItemsRepo = entityManager.getRepository(AudioOptionItem);
await deleteEntity(
audioOptionItemsRepo,
{
audio_file_id: In(targetaudioFiles.map((audioFile) => audioFile.id)),
},
this.isCommentOut,
context,
);
await deleteEntity(
audioFileRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
// タスク系のテーブルのレコードを削除する
const taskRepo = entityManager.getRepository(Task);
const targetTasks = await taskRepo.find({
where: { account_id: targetAccountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
const checkoutPermissionRepo =
entityManager.getRepository(CheckoutPermission);
await deleteEntity(
checkoutPermissionRepo,
{ task_id: In(targetTasks.map((task) => task.id)) },
this.isCommentOut,
context,
);
await deleteEntity(
taskRepo,
{ account_id: targetAccountId },
this.isCommentOut,
context,
);
return users;
});
}
}

View File

@ -27,6 +27,13 @@ export class AccountLockedError extends Error {
}
}
// パートナーアカウント削除不可エラー
export class PartnerAccountDeletionError extends Error {
constructor(message: string) {
super(message);
this.name = 'PartnerAccountDeletionError';
}
}
/**
*
*/

View File

@ -0,0 +1,65 @@
<html>
<head>
<title>Storage Usage Exceeded Notification [U-119]</title>
</head>
<body>
<div>
<h3>&lt;English&gt;</h3>
<p>Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$</p>
<p>ODMS Cloudをご利用いただきありがとうございます。</p>
<p>
お客様のアカウント情報は$DEALER_NAME$によりODMS
Cloudから削除されました。
</p>
<p>
再度ODMS
Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM
Degital Solutionsに問い合わせください。
</p>
<p>
If you received this e-mail in error, please delete this e-mail from
your system.<br />
This is an automatically generated e-mail, please do not reply.
</p>
</div>
<div>
<h3>&lt;Deutsch&gt;</h3>
<p>Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$</p>
<p>ODMS Cloudをご利用いただきありがとうございます。</p>
<p>
お客様のアカウント情報は$DEALER_NAME$によりODMS
Cloudから削除されました。
</p>
<p>
再度ODMS
Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM
Degital Solutionsに問い合わせください。
</p>
<p>
If you received this e-mail in error, please delete this e-mail from
your system.<br />
This is an automatically generated e-mail, please do not reply.
</p>
</div>
<div>
<h3>&lt;Français&gt;</h3>
<p>Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$</p>
<p>ODMS Cloudをご利用いただきありがとうございます。</p>
<p>
お客様のアカウント情報は$DEALER_NAME$によりODMS
Cloudから削除されました。
</p>
<p>
再度ODMS
Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM
Degital Solutionsに問い合わせください。
</p>
<p>
If you received this e-mail in error, please delete this e-mail from
your system.<br />
This is an automatically generated e-mail, please do not reply.
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,38 @@
<English>
Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$
ODMS Cloudをご利用いただきありがとうございます。
お客様のアカウント情報は$DEALER_NAME$によりODMS Cloudから削除されました。
再度ODMS Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM Degital Solutionsに問い合わせください。
If you received this e-mail in error, please delete this e-mail from your system.
This is an automatically generated e-mail, please do not reply.
<Deutsch>
Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$
ODMS Cloudをご利用いただきありがとうございます。
お客様のアカウント情報は$DEALER_NAME$によりODMS Cloudから削除されました。
再度ODMS Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM Degital Solutionsに問い合わせください。
If you received this e-mail in error, please delete this e-mail from your system.
This is an automatically generated e-mail, please do not reply.
<Français>
Dear $CUSTOMER_NAME$, -> $PRIMARY_ADMIN_NAME$
ODMS Cloudをご利用いただきありがとうございます。
お客様のアカウント情報は$DEALER_NAME$によりODMS Cloudから削除されました。
再度ODMS Cloudにアカウント登録する場合は、$DEALER_NAME$にご相談いただくか、OM Degital Solutionsに問い合わせください。
If you received this e-mail in error, please delete this e-mail from your system.
This is an automatically generated e-mail, please do not reply.