Merged PR 714: API実装(ユーザー削除|Repository以外)

## 概要
[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`にコードを用意してあげる必要があるか?
(引継ぎ時あえて用意していないように見えなくもなかったので)

## 動作確認状況
- ローカルで確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
湯本 開 2024-02-06 07:12:11 +00:00 committed by masaaki
parent 4442c7cbbe
commit feeec9d1f5
11 changed files with 1163 additions and 33 deletions

View File

@ -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;

View File

@ -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.',
};

View File

@ -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',

View File

@ -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 {};
}
}

View File

@ -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>(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>(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>(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>(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>(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>(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>(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>(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>(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();
}
}
});
});

View File

@ -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<void> {
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を指定して

View File

@ -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<void> {
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

View File

@ -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';
}
}
}

View File

@ -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(

View File

@ -0,0 +1,49 @@
<html>
<head>
<title>User Deleted Notification [U-116]</title>
</head>
<body>
<div>
<h3>&lt;English&gt;</h3>
<p>Dear $USER_NAME$,</p>
<p>
Thank you for using ODMS Cloud. Your user information has been deleted from ODMS Cloud.
</p>
<p>
If you need support regarding ODMS Cloud, please contact $PRIMARY_ADMIN_NAME$.
</p>
<p>
If you have received this e-mail in error, please delete this e-mail from your system.<br />
This is an automatically generated e-mail and this mailbox is not monitored. Please do not reply.
</div>
<div>
<h3>&lt;Deutsch&gt;</h3>
<p>Sehr geehrte(r) $USER_NAME$,</p>
<p>
Vielen Dank, dass Sie ODMS Cloud verwenden. Ihre Benutzerinformationen wurden aus ODMS Cloud gelöscht.
</p>
<p>
Wenn Sie Unterstützung in Bezug auf ODMS Cloud benötigen, wenden Sie sich bitte an $PRIMARY_ADMIN_NAME$.
</p>
<p>
Wenn Sie diese E-Mail fälschlicherweise erhalten haben, löschen Sie diese E-Mail bitte aus Ihrem System.<br />
Dies ist eine automatisch generierte E-Mail und dieses Postfach wird nicht überwacht. Bitte nicht antworten.
</p>
</div>
<div>
<h3>&lt;Français&gt;</h3>
<p>Chère/Cher $USER_NAME$,</p>
<p>
Merci d'utiliser ODMS Cloud. Vos informations utilisateur ont été supprimées d'ODMS Cloud.
</p>
<p>
Si vous avez besoin d'assistance concernant ODMS Cloud, veuillez contacter $PRIMARY_ADMIN_NAME$.
</p>
<p>
Si vous avez reçu cet e-mail par erreur, veuillez supprimer cet e-mail de votre système.<br />
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.
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,32 @@
<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 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.
<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.