diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index 66471b9..8e527f4 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -246,3 +246,9 @@ export const OPTION_ITEM_VALUE_TYPE = { export const ADB2C_SIGN_IN_TYPE = { EAMILADDRESS: 'emailAddress', } as const; + +/** + * MANUAL_RECOVERY_REQUIRED + * @const {string} + */ +export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]'; diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 03e4cd9..4ac1c64 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -1071,7 +1071,7 @@ export class AccountsController { description: 'DBアクセスに失敗しログインできる状態で処理が終了した場合', type: ErrorResponse, }) - @ApiOperation({ operationId: 'deleteAccount' }) + @ApiOperation({ operationId: 'deleteAccountAndData' }) @ApiBearerAuth() @UseGuards(AuthGuard) @UseGuards( @@ -1079,7 +1079,7 @@ export class AccountsController { roles: [ADMIN_ROLES.ADMIN], }), ) - async deleteAccount( + async deleteAccountAndData( @Req() req: Request, @Body() body: DeleteAccountRequest, ): Promise { @@ -1088,12 +1088,7 @@ export class AccountsController { const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - /* TODO 仮実装、別タスクで実装する - await this.accountService.deleteAccount( - context, - accountId - ); - */ + await this.accountService.deleteAccountAndData(context, userId, accountId); return; } } diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 2c13616..495fee5 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -15,6 +15,7 @@ import { USER_ROLES, ADB2C_SIGN_IN_TYPE, OPTION_ITEM_VALUE_TYPE, + MANUAL_RECOVERY_REQUIRED, } from '../../constants'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { @@ -319,7 +320,7 @@ export class AccountsService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, ); } } @@ -338,7 +339,7 @@ export class AccountsService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`, ); } } @@ -361,7 +362,7 @@ export class AccountsService { ); } catch (error) { this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`, ); } } @@ -1684,4 +1685,99 @@ export class AccountsService { ); } } + + /** + * アカウントと紐づくデータを削除する + * @param context + * @param externalId + * @param accountId // 削除対象のアカウントID + */ + async deleteAccountAndData( + context: Context, + externalId: string, + accountId: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteAccountAndData.name} | params: { ` + + `externalId: ${externalId}, ` + + `accountId: ${accountId}, };`, + ); + let country: string; + let dbUsers: User[]; + try { + // パラメータとトークンから取得したアカウントIDの突き合わせ + const { account_id: myAccountId } = + await this.usersRepository.findUserByExternalId(externalId); + if (myAccountId !== accountId) { + throw new HttpException( + makeErrorResponse('E000108'), + HttpStatus.UNAUTHORIZED, + ); + } + + // アカウント削除前に必要な情報を退避する + const targetAccount = await this.accountRepository.findAccountById( + accountId, + ); + // 削除対象アカウントを削除する + dbUsers = await this.accountRepository.deleteAccountAndInsertArchives( + accountId, + ); + this.logger.log(`[${context.trackingId}] delete account: ${accountId}`); + country = targetAccount.country; + } catch (e) { + // アカウントの削除に失敗した場合はエラーを返す + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`, + ); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + // 削除対象アカウント内のADB2Cユーザーをすべて削除する + await this.adB2cService.deleteUsers( + dbUsers.map((x) => x.external_id), + context, + ); + this.logger.log( + `[${ + context.trackingId + }] delete ADB2C users: ${accountId}, users_id: ${dbUsers.map( + (x) => x.external_id, + )}`, + ); + } catch (e) { + // ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行 + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `${MANUAL_RECOVERY_REQUIRED} [${ + context.trackingId + }] Failed to delete ADB2C users: ${accountId}, users_id: ${dbUsers.map( + (x) => x.external_id, + )}`, + ); + } + + try { + // blobstorageコンテナを削除する + await this.deleteBlobContainer(accountId, country, context); + this.logger.log( + `[${context.trackingId}] delete blob container: ${accountId}-${country}`, + ); + } catch (e) { + // blobstorageコンテナを削除で失敗した場合は、MANUAL_RECOVERY_REQUIRED出して正常終了 + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `${MANUAL_RECOVERY_REQUIRED}[${context.trackingId}] Failed to delete blob container: ${accountId}, country: ${country}`, + ); + } + + this.logger.log( + `[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`, + ); + } } diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 94377ca..a3e9521 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -35,6 +35,7 @@ import { import { ADB2C_SIGN_IN_TYPE, LICENSE_EXPIRATION_THRESHOLD_DAYS, + MANUAL_RECOVERY_REQUIRED, USER_LICENSE_STATUS, USER_ROLES, } from '../../constants'; @@ -300,7 +301,7 @@ export class UsersService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, ); } } @@ -313,7 +314,7 @@ export class UsersService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete user: ${userId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete user: ${userId}`, ); } } diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index d1000c5..254acea 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -254,6 +254,31 @@ export class AdB2cService { this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUser.name}`); } } + + /** + * Azure AD B2Cからユーザ情報を削除する(複数) + * @param externalIds 外部ユーザーID + * @param context コンテキスト + */ + async deleteUsers(externalIds: string[], context: Context): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteUsers.name} | params: { externalIds: ${externalIds} };`, + ); + + try { + // 複数ユーザーを一括削除する方法が不明なため、rate limitの懸念があるのを承知のうえ単一削除の繰り返しで実装 + // TODO 一括削除する方法が判明したら修正する + // https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example + externalIds.map( + async (x) => await this.graphClient.api(`users/${x}`).delete(), + ); + } catch (e) { + this.logger.error(`error=${e}`); + throw e; + } finally { + this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUsers.name}`); + } + } } // TODO [Task2002] 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定) diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 5da84e7..510a865 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -10,7 +10,7 @@ import { UpdateResult, EntityManager, } from 'typeorm'; -import { User } from '../users/entity/user.entity'; +import { User, UserArchive } from '../users/entity/user.entity'; import { Account } from './entity/account.entity'; import { License, LicenseOrder } from '../licenses/entity/license.entity'; import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity'; @@ -902,4 +902,34 @@ export class AccountsRepositoryService { ); }); } + + /** + * 指定されたアカウントを削除する + * @param accountId + * @returns users 削除対象のユーザー + */ + async deleteAccountAndInsertArchives(accountId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + // 削除対象のユーザーを退避テーブルに退避 + const users = await this.dataSource.getRepository(User).find({ + where: { + account_id: accountId, + }, + }); + + const userArchiveRepo = entityManager.getRepository(UserArchive); + await userArchiveRepo + .createQueryBuilder() + .insert() + .into(UserArchive) + .values(users) + .execute(); + + // アカウントを削除 + // アカウントを削除することで、外部キー制約がで紐づいている関連テーブルのデータも削除される + const accountRepo = entityManager.getRepository(Account); + await accountRepo.delete({ id: accountId }); + return users; + }); + } } diff --git a/dictation_server/src/repositories/users/entity/user.entity.ts b/dictation_server/src/repositories/users/entity/user.entity.ts index 51cc19c..9e375df 100644 --- a/dictation_server/src/repositories/users/entity/user.entity.ts +++ b/dictation_server/src/repositories/users/entity/user.entity.ts @@ -9,6 +9,7 @@ import { JoinColumn, OneToOne, OneToMany, + PrimaryColumn, } from 'typeorm'; import { License } from '../../licenses/entity/license.entity'; import { UserGroupMember } from '../../user_groups/entity/user_group_member.entity'; @@ -80,6 +81,63 @@ export class User { userGroupMembers?: UserGroupMember[]; } +@Entity({ name: 'users_archive' }) +export class UserArchive { + @PrimaryColumn() + id: number; + + @Column() + external_id: string; + + @Column() + account_id: number; + + @Column() + role: string; + + @Column({ nullable: true }) + author_id?: string; + + @Column({ nullable: true }) + accepted_terms_version?: string; + + @Column() + email_verified: boolean; + + @Column() + auto_renew: boolean; + + @Column() + license_alert: boolean; + + @Column() + notification: boolean; + + @Column() + encryption: boolean; + + @Column() + prompt: boolean; + + @Column({ nullable: true }) + deleted_at?: Date; + + @Column({ nullable: true }) + created_by: string; + + @Column() + created_at: Date; + + @Column({ nullable: true }) + updated_by?: string; + + @Column() + updated_at: Date; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + archived_at: Date; +} + export type newUser = Omit< User, | 'id' diff --git a/dictation_server/src/repositories/users/users.repository.module.ts b/dictation_server/src/repositories/users/users.repository.module.ts index 79be43a..94ccdc4 100644 --- a/dictation_server/src/repositories/users/users.repository.module.ts +++ b/dictation_server/src/repositories/users/users.repository.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './entity/user.entity'; +import { User, UserArchive } from './entity/user.entity'; import { UsersRepositoryService } from './users.repository.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User, UserArchive])], providers: [UsersRepositoryService], exports: [UsersRepositoryService], })