From feeec9d1f539771610b23032a19d6d5444c2592a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=AF=E6=9C=AC=20=E9=96=8B?= Date: Tue, 6 Feb 2024 07:12:11 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20714:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E5=89=8A=E9=99=A4?= =?UTF-8?q?|Repository=E4=BB=A5=E5=A4=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task3594: API実装(ユーザー削除|Repository以外)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3594) ユーザー削除API実装 ユニットテスト実装 ## レビューポイント - `'E014008', // ユーザー削除エラー(削除しようとしたユーザーが自分自身だった)`が用意されているが、 `'E014002', // ユーザー削除エラー(削除しようとしたユーザーが管理者だった)`とわけて実装する必要あるか。 管理者でしか削除処理は行えない&管理者ユーザは削除できない。 - `ExistsCheckoutPermissionDeleteFailedError` 削除対象ユーザーがチェックアウト権限を持っている事が原因の削除失敗エラーは、ユーザ削除エラーの一つとして、`code.ts`にコードを用意してあげる必要があるか? (引継ぎ時あえて用意していないように見えなくもなかったので) ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- dictation_server/src/common/error/code.ts | 9 + dictation_server/src/common/error/message.ts | 9 + .../src/features/files/test/utility.ts | 3 +- .../src/features/users/users.controller.ts | 3 +- .../src/features/users/users.service.spec.ts | 777 +++++++++++++++++- .../src/features/users/users.service.ts | 223 ++++- .../src/gateways/sendgrid/sendgrid.service.ts | 57 ++ .../src/repositories/users/errors/types.ts | 4 +- .../users/users.repository.service.ts | 30 +- .../src/templates/template_U_116.html | 49 ++ .../src/templates/template_U_116.txt | 32 + 11 files changed, 1163 insertions(+), 33 deletions(-) create mode 100644 dictation_server/src/templates/template_U_116.html create mode 100644 dictation_server/src/templates/template_U_116.txt diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 3c488da..661c648 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -67,4 +67,13 @@ export const ErrorCodes = [ 'E012001', // テンプレートファイル不在エラー 'E013001', // ワークフローのAuthorIDとWorktypeIDのペア重複エラー 'E013002', // ワークフロー不在エラー + 'E014001', // ユーザー削除エラー(削除しようとしたユーザーがすでに削除済みだった) + 'E014002', // ユーザー削除エラー(削除しようとしたユーザーが管理者だった) + 'E014003', // ユーザー削除エラー(削除しようとしたAuthorのAuthorIDがWorkflowに指定されていた) + 'E014004', // ユーザー削除エラー(削除しようとしたTypistがWorkflowのTypist候補として指定されていた) + 'E014005', // ユーザー削除エラー(削除しようとしたTypistがUserGroupに所属していた) + 'E014006', // ユーザー削除エラー(削除しようとしたユーザが所有者の未完了のタスクが残っている) + 'E014007', // ユーザー削除エラー(削除しようとしたユーザーが有効なライセンスを持っていた) + 'E014008', // ユーザー削除エラー(削除しようとしたユーザーが自分自身だった) + 'E014009', // ユーザー削除エラー(削除しようとしたユーザーがタスクのルーティング(文字起こし候補)になっている場合) ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 9383694..f885d26 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -56,4 +56,13 @@ export const errors: Errors = { E012001: 'Template file not found Error', E013001: 'AuthorId and WorktypeId pair already exists Error', E013002: 'Workflow not found Error', + E014001: 'User delete failed Error: already deleted', + E014002: 'User delete failed Error: target is admin', + E014003: 'User delete failed Error: workflow assigned(AUTHOR_ID)', + E014004: 'User delete failed Error: workflow assigned(TYPIST)', + E014005: 'User delete failed Error: typist group assigned', + E014006: 'User delete failed Error: checkout permission existed', + E014007: 'User delete failed Error: enabled license assigned', + E014008: 'User delete failed Error: delete myself', + E014009: 'User delete failed Error: user has checkout permissions.', }; diff --git a/dictation_server/src/features/files/test/utility.ts b/dictation_server/src/features/files/test/utility.ts index 1fe086b..79766a7 100644 --- a/dictation_server/src/features/files/test/utility.ts +++ b/dictation_server/src/features/files/test/utility.ts @@ -53,12 +53,13 @@ export const createTask = async ( status: string, typist_user_id?: number | undefined, author_id?: string | undefined, + owner_user_id?: number | undefined, ): Promise<{ audioFileId: number }> => { const { identifiers: audioFileIdentifiers } = await datasource .getRepository(AudioFile) .insert({ account_id: account_id, - owner_user_id: 1, + owner_user_id: owner_user_id ?? 1, url: url, file_name: fileName, author_id: author_id ?? 'DEFAULT_ID', diff --git a/dictation_server/src/features/users/users.controller.ts b/dictation_server/src/features/users/users.controller.ts index 6b19b15..02f1c68 100644 --- a/dictation_server/src/features/users/users.controller.ts +++ b/dictation_server/src/features/users/users.controller.ts @@ -988,7 +988,8 @@ export class UsersController { const context = makeContext(userId, requestId); this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`); - + const now = new Date(); + await this.usersService.deleteUser(context, body.userId, now); return {}; } } diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index c6a39dc..c738fb6 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -18,9 +18,11 @@ import { import { DataSource } from 'typeorm'; import { UsersService } from './users.service'; import { + ADB2C_SIGN_IN_TYPE, LICENSE_ALLOCATED_STATUS, LICENSE_EXPIRATION_THRESHOLD_DAYS, LICENSE_TYPE, + TASK_STATUS, USER_AUDIO_FORMAT, USER_LICENSE_EXPIRY_STATUS, USER_ROLES, @@ -37,6 +39,7 @@ import { License } from '../../repositories/licenses/entity/license.entity'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { getUser, + getUserArchive, getUserFromExternalId, getUsers, makeTestAccount, @@ -45,8 +48,14 @@ import { } from '../../common/test/utility'; import { v4 as uuidv4 } from 'uuid'; import { createOptionItems, createWorktype } from '../accounts/test/utility'; -import { createWorkflow, getWorkflows } from '../workflows/test/utility'; +import { + createWorkflow, + createWorkflowTypist, + getWorkflows, +} from '../workflows/test/utility'; import { truncateAllTable } from '../../common/test/init'; +import { createTask } from '../files/test/utility'; +import { createCheckoutPermissions } from '../tasks/test/utility'; describe('UsersService.confirmUser', () => { let source: DataSource | null = null; @@ -2868,3 +2877,769 @@ describe('UsersService.getRelations', () => { } }); }); +describe('UsersService.deleteUser', () => { + let source: DataSource | null = null; + + beforeAll(async () => { + if (source == null) { + source = await (async () => { + const s = new DataSource({ + type: 'mysql', + host: 'test_mysql_db', + port: 3306, + username: 'user', + password: 'password', + database: 'odms', + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: false, // trueにすると自動的にmigrationが行われるため注意 + }); + return await s.initialize(); + })(); + } + }); + + beforeEach(async () => { + if (source) { + await truncateAllTable(source); + } + }); + + afterAll(async () => { + await source?.destroy(); + source = null; + }); + + it('ユーザーを削除できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user1, now); + + // ユーザーが削除されたことを確認 + { + const user = await getUser(source, user1); + expect(user).toBeNull(); + // ユーザアーカイブが作成されたことを確認 + const userArchive = await getUserArchive(source); + expect(userArchive[0].external_id).toBe(external_id); + } + }); + it('存在しないユーザは削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + // 現在日付を作成 + const now = new Date(); + + try { + await service.deleteUser(context, 100, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014001')); + } else { + fail(); + } + } + }); + it('管理者ユーザは削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + // 現在日付を作成 + const now = new Date(); + + try { + await service.deleteUser(context, admin.id, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014002')); + } else { + fail(); + } + } + }); + it('WorkFlowに割り当てられているユーザ(Auhtor)は削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + const worktype1 = await createWorktype( + source, + account.id, + 'worktype1', + undefined, + true, + ); + await createOptionItems(source, worktype1.id); + await createWorkflow(source, account.id, user1, worktype1.id); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user1, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014003')); + } else { + fail(); + } + } + }); + it('WorkFlowに割り当てられているユーザ(Typist)は削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: user2, external_id: external_id2 } = await makeTestUser( + source, + { + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + const worktype1 = await createWorktype( + source, + account.id, + 'worktype1', + undefined, + true, + ); + await createOptionItems(source, worktype1.id); + const workflow1 = await createWorkflow( + source, + account.id, + user1, + worktype1.id, + ); + await createWorkflowTypist(source, workflow1.id, user2); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + { + id: external_id2, + displayName: 'user2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user2@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user2, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014004')); + } else { + fail(); + } + } + }); + it('ユーザグループに所属しているユーザは削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: user2, external_id: external_id2 } = await makeTestUser( + source, + { + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + // ユーザグループを作成 + const userGroup = await createUserGroup(source, account.id, 'userGroup', [ + user1, + user2, + ]); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + { + id: external_id2, + displayName: 'user2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user2@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user2, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014005')); + } else { + fail(); + } + } + }); + it('削除対象ユーザーが有効なタスクをまだ持っている場合、削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR', + }); + const { id: user2, external_id: external_id2 } = await makeTestUser( + source, + { + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + // タスクを作成 + await createTask( + source, + account.id, + 'task-url', + 'filename', + TASK_STATUS.IN_PROGRESS, + user2, + 'AUTHOR', + user1, + ); + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + { + id: external_id2, + displayName: 'user2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user2@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user1, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014006')); + } else { + fail(); + } + } + }); + it('削除対象ユーザータスクのチェックアウト権限まだ持っている場合、削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR', + }); + const { id: user2, external_id: external_id2 } = await makeTestUser( + source, + { + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + // CheckoutPermissionを作成 + await createCheckoutPermissions(source, 100, user2); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + { + id: external_id2, + displayName: 'user2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user2@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user2, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014009')); + } else { + fail(); + } + } + }); + it('削除対象ユーザーが有効なライセンスをまだ持っている場合、削除できない', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const { id: user1, external_id } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR', + }); + const { id: user2, external_id: external_id2 } = await makeTestUser( + source, + { + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + + const service = module.get(UsersService); + const context = makeContext(`uuidv4`, 'requestId'); + // 明日まで有効なライセンスを作成して紐づける + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + await createLicense(source, account.id, user2, tomorrow); + + overrideAdB2cService(service, { + getUsers: async () => { + return [ + { + id: admin.external_id, + displayName: 'admin', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'admin@example.com', + }, + ], + }, + { + id: external_id, + displayName: 'user1', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user1@example.com', + }, + ], + }, + { + id: external_id2, + displayName: 'user2', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'user2@example.com', + }, + ], + }, + ]; + }, + getUser: async () => { + return { + id: admin.external_id, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, {}); + + try { + // 現在日付を作成 + const now = new Date(); + await service.deleteUser(context, user2, now); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E014007')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 359f163..bdde739 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -25,9 +25,16 @@ import { UsersRepositoryService } from '../../repositories/users/users.repositor import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { GetRelationsResponse, User } from './types/types'; import { + AdminDeleteFailedError, + AssignedWorkflowWithAuthorDeleteFailedError, + AssignedWorkflowWithTypistDeleteFailedError, AuthorIdAlreadyExistsError, EmailAlreadyVerifiedError, EncryptionPasswordNeedError, + ExistsCheckoutPermissionDeleteFailedError, + ExistsGroupMemberDeleteFailedError, + ExistsValidLicenseDeleteFailedError, + ExistsValidTaskDeleteFailedError, InvalidRoleChangeError, UpdateTermsVersionNotSetError, UserNotFoundError, @@ -286,7 +293,7 @@ export class UsersService { this.logger.error(`[${context.getTrackingId()}]create user failed`); //リカバリー処理 //Azure AD B2Cに登録したユーザー情報を削除する - await this.deleteB2cUser(externalUser.sub, context); + await this.internalDeleteB2cUser(externalUser.sub, context); switch (e.code) { case 'ER_DUP_ENTRY': @@ -337,9 +344,9 @@ export class UsersService { this.logger.error(`[${context.getTrackingId()}] create user failed`); //リカバリー処理 //Azure AD B2Cに登録したユーザー情報を削除する - await this.deleteB2cUser(externalUser.sub, context); + await this.internalDeleteB2cUser(externalUser.sub, context); // DBからユーザーを削除する - await this.deleteUser(newUser.id, context); + await this.internalDeleteUser(newUser.id, context); throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -353,10 +360,13 @@ export class UsersService { // Azure AD B2Cに登録したユーザー情報を削除する // TODO 「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補 - private async deleteB2cUser(externalUserId: string, context: Context) { + private async internalDeleteB2cUser( + externalUserId: string, + context: Context, + ) { this.logger.log( `[IN] [${context.getTrackingId()}] ${ - this.deleteB2cUser.name + this.internalDeleteB2cUser.name } | params: { externalUserId: ${externalUserId} }`, ); try { @@ -371,16 +381,16 @@ export class UsersService { ); } finally { this.logger.log( - `[OUT] [${context.getTrackingId()}] ${this.deleteB2cUser.name}`, + `[OUT] [${context.getTrackingId()}] ${this.internalDeleteB2cUser.name}`, ); } } // DBに登録したユーザー情報を削除する - private async deleteUser(userId: number, context: Context) { + private async internalDeleteUser(userId: number, context: Context) { this.logger.log( `[IN] [${context.getTrackingId()}] ${ - this.deleteUser.name + this.internalDeleteUser.name } | params: { userId: ${userId} }`, ); try { @@ -393,7 +403,7 @@ export class UsersService { ); } finally { this.logger.log( - `[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}`, + `[OUT] [${context.getTrackingId()}] ${this.internalDeleteUser.name}`, ); } } @@ -1287,6 +1297,201 @@ export class UsersService { ); } } + /** + * ユーザーを削除する + * @param context + * @param extarnalId + * @param currentTime ライセンス有効期限のチェックに使用する現在時刻 + * @returns user + */ + async deleteUser( + context: Context, + userId: number, + currentTime: Date, + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${ + this.deleteUser.name + } | params: { userId: ${userId} };`, + ); + try { + // 削除対象のユーザーが存在するかを確認する + let user: EntityUser; + try { + user = await this.usersRepository.findUserById(context, userId); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E014001'), + HttpStatus.BAD_REQUEST, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + // 削除対象のユーザーが管理者かどうかを確認する + const admins = await this.usersRepository.findAdminUsers( + context, + user.account_id, + ); + const adminIds = admins.map((admin) => admin.id); + if (adminIds.includes(user.id)) { + throw new HttpException( + makeErrorResponse('E014002'), + HttpStatus.BAD_REQUEST, + ); + } + // Azure AD B2Cからユーザー情報(e-mail, name)を取得する + const adminExternalIds = admins.map((admin) => admin.external_id); + const externalIds = [user.external_id, ...adminExternalIds]; + // Azure AD B2CのRateLimit対策のため、ユーザー情報を一括取得する + const details = await this.adB2cService.getUsers(context, externalIds); + // 削除対象のユーザーがAzure AD B2Cに存在するかを確認する + const deleteTargetDetail = details.find( + (details) => details.id === user.external_id, + ); + if (deleteTargetDetail == null) { + throw new HttpException( + makeErrorResponse('E014001'), + HttpStatus.BAD_REQUEST, + ); + } + // 管理者の情報が0件(=競合でアカウントが削除された場合等)の場合はエラーを返す + const adminDetails = details.filter((details) => + adminExternalIds.includes(details.id), + ); + if (adminDetails.length === 0) { + // 通常ユーザーが取得できていて管理者が取得できない事は通常ありえないため、汎用エラー + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const { emailAddress } = getUserNameAndMailAddress(deleteTargetDetail); + // メールアドレスが設定されていない場合はエラーを返す + if (emailAddress == null) { + throw new Error(`emailAddress is null. externalId=${user.external_id}`); + } + // 管理者のメールアドレスを取得 + const { adminEmails } = await this.getAccountInformation( + context, + user.account_id, + ); + // プライマリ管理者を取得 + const { external_id: adminExternalId } = await this.getPrimaryAdminUser( + context, + user.account_id, + ); + const adb2cAdminUser = await this.adB2cService.getUser( + context, + adminExternalId, + ); + const { displayName: primaryAdminName } = + getUserNameAndMailAddress(adb2cAdminUser); + + let isSuccess = false; + try { + const result = await this.usersRepository.deleteUser( + context, + userId, + currentTime, + ); + isSuccess = result.isSuccess; + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + switch (e.constructor) { + case AdminDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014002'), + HttpStatus.BAD_REQUEST, + ); + case AssignedWorkflowWithAuthorDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014003'), + HttpStatus.BAD_REQUEST, + ); + case AssignedWorkflowWithTypistDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014004'), + HttpStatus.BAD_REQUEST, + ); + case ExistsGroupMemberDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014005'), + HttpStatus.BAD_REQUEST, + ); + case ExistsValidTaskDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014006'), + HttpStatus.BAD_REQUEST, + ); + case ExistsValidLicenseDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014007'), + HttpStatus.BAD_REQUEST, + ); + case ExistsCheckoutPermissionDeleteFailedError: + throw new HttpException( + makeErrorResponse('E014009'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + // トランザクションレベルで厳密に削除が成功したかを判定する + if (!isSuccess) { + // 既に削除されている場合はエラーを返す + throw new HttpException( + makeErrorResponse('E014001'), + HttpStatus.BAD_REQUEST, + ); + } + try { + // 削除を実施したことが確定したので、Azure AD B2Cからユーザーを削除する + await this.adB2cService.deleteUser(user.external_id, context); + this.logger.log( + `[${context.getTrackingId()}] delete externalUser: ${ + user.external_id + } | params: { ` + `externalUserId: ${user.external_id}, };`, + ); + } catch (error) { + this.logger.error(`[${context.getTrackingId()}] error=${error}`); + this.logger.error( + `${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete externalUser: ${ + user.external_id + }`, + ); + } + // 削除を実施したことが確定したので、メール送信処理を実施する + try { + await this.sendgridService.sendMailWithU116( + context, + deleteTargetDetail.displayName, + emailAddress, + primaryAdminName, + adminEmails, + ); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + // メール送信に関する例外はログだけ出して握りつぶす + } + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}`, + ); + } + } /** * アカウントIDを指定して、アカウント情報と管理者情報を取得する diff --git a/dictation_server/src/gateways/sendgrid/sendgrid.service.ts b/dictation_server/src/gateways/sendgrid/sendgrid.service.ts index 985c7b1..a467b3b 100644 --- a/dictation_server/src/gateways/sendgrid/sendgrid.service.ts +++ b/dictation_server/src/gateways/sendgrid/sendgrid.service.ts @@ -56,6 +56,8 @@ export class SendGridService { private readonly templateU114Text: string; private readonly templateU115Html: string; private readonly templateU115Text: string; + private readonly templateU116Html: string; + private readonly templateU116Text: string; private readonly templateU117Html: string; private readonly templateU117Text: string; @@ -177,6 +179,14 @@ export class SendGridService { path.resolve(__dirname, `../../templates/template_U_115.txt`), 'utf-8', ); + this.templateU116Html = readFileSync( + path.resolve(__dirname, `../../templates/template_U_116.html`), + 'utf-8', + ); + this.templateU116Text = readFileSync( + path.resolve(__dirname, `../../templates/template_U_116.txt`), + 'utf-8', + ); this.templateU117Html = readFileSync( path.resolve(__dirname, `../../templates/template_U_117.html`), 'utf-8', @@ -833,6 +843,53 @@ export class SendGridService { } } + /** + * U-116のテンプレートを使用したメールを送信する + * @param context + * @param userName 削除されたユーザーの名前 + * @param userMail 削除されたユーザーのメールアドレス + * @param primaryAdminName 削除されたユーザーの所属するアカウントの管理者(primary)の名前 + * @param adminMails 削除されたユーザーの所属するアカウントの管理者(primary/secondary)のメールアドレス + * @returns mail with u116 + */ + async sendMailWithU116( + context: Context, + userName: string, + userMail: string, + primaryAdminName: string, + adminMails: string[], + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${this.sendMailWithU116.name}`, + ); + try { + const subject = 'Edit User Notification [U-116]'; + + // メールの本文を作成する + const html = this.templateU116Html + .replaceAll(USER_NAME, userName) + .replaceAll(PRIMARY_ADMIN_NAME, primaryAdminName); + const text = this.templateU116Text + .replaceAll(USER_NAME, userName) + .replaceAll(PRIMARY_ADMIN_NAME, primaryAdminName); + + // メールを送信する + await this.sendMail( + context, + [userMail], + adminMails, + this.mailFrom, + subject, + text, + html, + ); + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.sendMailWithU116.name}`, + ); + } + } + /** * U-117のテンプレートを使用したメールを送信する * @param context diff --git a/dictation_server/src/repositories/users/errors/types.ts b/dictation_server/src/repositories/users/errors/types.ts index 217d54d..5468900 100644 --- a/dictation_server/src/repositories/users/errors/types.ts +++ b/dictation_server/src/repositories/users/errors/types.ts @@ -87,7 +87,7 @@ export class ExistsGroupMemberDeleteFailedError extends Error { } } -// 削除対象ユーザーが有効なタスクをまだ持っている事が原因の削除失敗エラー +// 削除対象ユーザー(Author)に未完了のタスクがまだ残っている事が原因の削除失敗エラー export class ExistsValidTaskDeleteFailedError extends Error { constructor(message: string) { super(message); @@ -109,4 +109,4 @@ export class ExistsValidLicenseDeleteFailedError extends Error { super(message); this.name = 'ExistsValidLicenseDeleteFailedError'; } -} \ No newline at end of file +} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 0355ec5..e132ad2 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -65,6 +65,7 @@ import { import { UserGroup } from '../user_groups/entity/user_group.entity'; import { Task } from '../tasks/entity/task.entity'; import { CheckoutPermission } from '../checkout_permissions/entity/checkout_permission.entity'; +import { WorkflowTypist } from '../workflows/entity/workflow_typists.entity'; @Injectable() export class UsersRepositoryService { @@ -604,7 +605,6 @@ export class UsersRepositoryService { ): Promise<{ isSuccess: boolean }> { return await this.dataSource.transaction(async (entityManager) => { const userRepo = entityManager.getRepository(User); - // 削除対象ユーザーをロックを取った上で取得 const target = await userRepo.findOne({ relations: { @@ -616,7 +616,6 @@ export class UsersRepositoryService { lock: { mode: 'pessimistic_write' }, comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, }); - // 削除済みであれば失敗する if (target == null) { return { isSuccess: false }; @@ -636,7 +635,6 @@ export class UsersRepositoryService { if (adminIds.some((adminId) => adminId === target.id)) { throw new AdminDeleteFailedError('User is an admin.'); } - const userGroupRepo = entityManager.getRepository(UserGroup); const groups = await userGroupRepo.find({ relations: { @@ -648,7 +646,6 @@ export class UsersRepositoryService { lock: { mode: 'pessimistic_write' }, comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, }); - const workflowRepo = entityManager.getRepository(Workflow); const workflows = await workflowRepo.find({ where: { @@ -664,18 +661,20 @@ export class UsersRepositoryService { 'Author is assigned to a workflow.', ); } - - // Workflowに直接個人で指定されているTypistのID一覧を作成する - const typistIds = workflows - .flatMap((x) => x.workflowTypists) - .flatMap((x) => (x?.typist_id != null ? [x.typist_id] : [])); + const workflowTypistsRepo = entityManager.getRepository(WorkflowTypist); + const workflowTypists = await workflowTypistsRepo.find({ + where: { + typist_id: target.id, + }, + lock: { mode: 'pessimistic_write' }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); // Workflowに直接個人で指定されているTypistは削除できない - if (typistIds.some((typistId) => typistId === target.id)) { + if (workflowTypists.some((x) => x.typist_id === target.id)) { throw new AssignedWorkflowWithTypistDeleteFailedError( 'Typist is assigned to a workflow.', ); } - // いずれかのGroupに属しているユーザーIDの一覧を作成 const groupMemberIds = groups .flatMap((group) => group.userGroupMembers) @@ -687,7 +686,6 @@ export class UsersRepositoryService { 'User is a member of a group', ); } - // 削除対象ユーザーがAuthorであった時、 if (target.role === USER_ROLES.AUTHOR) { const taskRepo = entityManager.getRepository(Task); @@ -706,7 +704,6 @@ export class UsersRepositoryService { lock: { mode: 'pessimistic_write' }, // lockする事で状態遷移の競合をブロックし、新規追加以外で所有タスク群の状態変更を防ぐ comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, }); - // 未完了タスクが残っていたら削除できない const enableStatus: string[] = [ TASK_STATUS.UPLOADED, @@ -721,7 +718,6 @@ export class UsersRepositoryService { throw new ExistsValidTaskDeleteFailedError('User has valid tasks.'); } } - // 削除対象ユーザーがTypistであった時、 if (target.role === USER_ROLES.TYPIST) { const checkoutPermissionRepo = @@ -741,7 +737,6 @@ export class UsersRepositoryService { ); } } - // 対象ユーザーのライセンス割り当て状態を取得 const licenseRepo = entityManager.getRepository(License); const allocatedLicense = await licenseRepo.findOne({ @@ -775,7 +770,6 @@ export class UsersRepositoryService { this.isCommentOut, context, ); - // 期限切れライセンスが割り当てられていた場合、ユーザーを削除する前にライセンスを割り当て解除する // ※この処理時点で有効期限外ライセンスであることは確定であるため、期限切れ判定をここでは行わない if (allocatedLicense != null) { @@ -809,15 +803,13 @@ export class UsersRepositoryService { context, ); } - // ユーザテーブルのレコードを削除する await deleteEntity( userRepo, - { user_id: target.id }, + { id: target.id }, this.isCommentOut, context, ); - // ソート条件のテーブルのレコードを削除する const sortCriteriaRepo = entityManager.getRepository(SortCriteria); await deleteEntity( diff --git a/dictation_server/src/templates/template_U_116.html b/dictation_server/src/templates/template_U_116.html new file mode 100644 index 0000000..7bb71c1 --- /dev/null +++ b/dictation_server/src/templates/template_U_116.html @@ -0,0 +1,49 @@ + + + User Deleted Notification [U-116] + + + +
+

<English>

+

Dear $USER_NAME$,

+

+ Thank you for using ODMS Cloud. Your user information has been deleted from ODMS Cloud. +

+

+ If you need support regarding ODMS Cloud, please contact $PRIMARY_ADMIN_NAME$. +

+

+ If you have received this e-mail in error, please delete this e-mail from your system.
+ This is an automatically generated e-mail and this mailbox is not monitored. Please do not reply. +

+
+

<Deutsch>

+

Sehr geehrte(r) $USER_NAME$,

+

+ Vielen Dank, dass Sie ODMS Cloud verwenden. Ihre Benutzerinformationen wurden aus ODMS Cloud gelöscht. +

+

+ Wenn Sie Unterstützung in Bezug auf ODMS Cloud benötigen, wenden Sie sich bitte an $PRIMARY_ADMIN_NAME$. +

+

+ Wenn Sie diese E-Mail fälschlicherweise erhalten haben, löschen Sie diese E-Mail bitte aus Ihrem System.
+ Dies ist eine automatisch generierte E-Mail und dieses Postfach wird nicht überwacht. Bitte nicht antworten. +

+
+
+

<Français>

+

Chère/Cher $USER_NAME$,

+

+ Merci d'utiliser ODMS Cloud. Vos informations utilisateur ont été supprimées d'ODMS Cloud. +

+

+ Si vous avez besoin d'assistance concernant ODMS Cloud, veuillez contacter $PRIMARY_ADMIN_NAME$. +

+

+ Si vous avez reçu cet e-mail par erreur, veuillez supprimer cet e-mail de votre système.
+ Il s'agit d'un e-mail généré automatiquement et cette boîte aux lettres n'est pas surveillée. Merci de ne pas répondre. +

+
+ + diff --git a/dictation_server/src/templates/template_U_116.txt b/dictation_server/src/templates/template_U_116.txt new file mode 100644 index 0000000..0b00153 --- /dev/null +++ b/dictation_server/src/templates/template_U_116.txt @@ -0,0 +1,32 @@ + + +Dear $USER_NAME$, + +Thank you for using ODMS Cloud. Your user information has been deleted from ODMS Cloud. + +If you need support regarding ODMS Cloud, please contact $PRIMARY_ADMIN_NAME$. + +If you have received this e-mail in error, please delete this e-mail from your system. +This is an automatically generated e-mail and this mailbox is not monitored. Please do not reply. + + + +Sehr geehrte(r) $USER_NAME$, + +Vielen Dank, dass Sie ODMS Cloud nutzen. Ihre Benutzerinformationen wurden aus der ODMS Cloud gelöscht. + +Wenn Sie Unterstützung bezüglich ODMS Cloud benötigen, wenden Sie sich bitte an $PRIMARY_ADMIN_NAME$. + +Wenn Sie diese E-Mail fälschlicherweise erhalten haben, löschen Sie diese E-Mail bitte aus Ihrem System. +Dies ist eine automatisch generierte E-Mail und dieses Postfach wird nicht überwacht. Bitte nicht antworten. + + + +Chère/Cher $USER_NAME$, + +Merci d'utiliser ODMS Cloud. Vos informations utilisateur ont été supprimées d'ODMS Cloud. + +Si vous avez besoin d'assistance concernant ODMS Cloud, veuillez contacter $PRIMARY_ADMIN_NAME$. + +Si vous avez reçu cet e-mail par erreur, veuillez supprimer cet e-mail de votre système. +Il s'agit d'un e-mail généré automatiquement et cette boîte aux lettres n'est pas surveillée. Merci de ne pas répondre. \ No newline at end of file