Merged PR 703: API実装(ユーザー削除)/Repository実装

## 概要
[Task3521: API実装(ユーザー削除)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3521)

- ユーザー削除を行うRepository(DB操作部分)を実装
  - 削除不可条件をチェックして削除できなければエラー
  - 削除可能だった場合、以下の処理を実行
    - ユーザーをアーカイブ
    - ユーザーを削除

## レビューポイント
- 「ライセンス割り当て解除」をせずにユーザーを削除するため、ライセンスがUserテーブルに存在しないIDを指したままになってしまうが問題ないか
  - ラフスケッチ時には、UserArchiveのidには紐づく & UserArchiveに紐づくことによって期限切れのライセンスが誰に割り当たっていたかを把握できるという話だったと思うが、これは"そういう必要がある"という仕様という認識でよいか
- ロック対象の指定は妥当であるか
  - デッドロックは発生しなさそうか
    - User -> UserGroup -> Workflow -> Task -> CheckoutPermission -> Licenseの順番

## 動作確認状況
- 動作確認なし
This commit is contained in:
湯本 開 2024-01-30 07:10:11 +00:00
parent 08a37ff264
commit c32b38b783
2 changed files with 314 additions and 2 deletions

View File

@ -54,3 +54,59 @@ export class DelegationNotAllowedError extends Error {
this.name = 'DelegationNotAllowedError';
}
}
// 削除対象ユーザーが管理者である事が原因の削除失敗エラー
export class AdminDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdminDeleteFailedError';
}
}
// 削除対象ユーザー(Author)がWorkflowにアサインされている事が原因の削除失敗エラー
export class AssignedWorkflowWithAuthorDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AssignedWorkflowWithAuthorDeleteFailedError';
}
}
// 削除対象ユーザー(Typist)がWorkflowにアサインされている事が原因の削除失敗エラー
export class AssignedWorkflowWithTypistDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AssignedWorkflowWithTypistDeleteFailedError';
}
}
// 削除対象ユーザーがGroupに所属している事が原因の削除失敗エラー
export class ExistsGroupMemberDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExistsGroupMemberDeleteFailedError';
}
}
// 削除対象ユーザーが有効なタスクをまだ持っている事が原因の削除失敗エラー
export class ExistsValidTaskDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExistsValidTaskDeleteFailedError';
}
}
// 削除対象ユーザーがチェックアウト権限を持っている事が原因の削除失敗エラー
export class ExistsCheckoutPermissionDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExistsCheckoutPermissionDeleteFailedError';
}
}
// 削除対象ユーザーが有効なライセンスをまだ持っている事が原因の削除失敗エラー
export class ExistsValidLicenseDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExistsValidLicenseDeleteFailedError';
}
}

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { User, newUser } from './entity/user.entity';
import { User, UserArchive, newUser } from './entity/user.entity';
import {
DataSource,
FindOptionsWhere,
In,
IsNull,
Not,
UpdateResult,
@ -21,17 +22,29 @@ import {
TermInfoNotFoundError,
UpdateTermsVersionNotSetError,
DelegationNotAllowedError,
ExistsGroupMemberDeleteFailedError,
AssignedWorkflowWithTypistDeleteFailedError,
AssignedWorkflowWithAuthorDeleteFailedError,
AdminDeleteFailedError,
ExistsValidTaskDeleteFailedError,
ExistsCheckoutPermissionDeleteFailedError,
ExistsValidLicenseDeleteFailedError,
} from './errors/types';
import {
LICENSE_ALLOCATED_STATUS,
LICENSE_TYPE,
SWITCH_FROM_TYPE,
TASK_STATUS,
TERM_TYPE,
TIERS,
TRIAL_LICENSE_ISSUE_NUM,
USER_ROLES,
USER_ROLE_ORDERS,
} from '../../constants';
import { License } from '../licenses/entity/license.entity';
import {
License,
LicenseAllocationHistory,
} from '../licenses/entity/license.entity';
import { NewTrialLicenseExpirationDate } from '../../features/licenses/types/types';
import { Term } from '../terms/entity/term.entity';
import { TermsCheckInfo } from '../../features/auth/types/types';
@ -49,6 +62,9 @@ import {
updateEntity,
deleteEntity,
} from '../../common/repository';
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';
@Injectable()
export class UsersRepositoryService {
@ -574,6 +590,246 @@ export class UsersRepositoryService {
});
}
/**
* Deletes user
* @param context Context
* @param userId ID
* @param currentTime 使
* @returns user
*/
async deleteUser(
context: Context,
userId: number,
currentTime: Date,
): Promise<{ isSuccess: boolean }> {
return await this.dataSource.transaction(async (entityManager) => {
const userRepo = entityManager.getRepository(User);
// 削除対象ユーザーをロックを取った上で取得
const target = await userRepo.findOne({
relations: {
account: true,
},
where: {
id: userId,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// 削除済みであれば失敗する
if (target == null) {
return { isSuccess: false };
}
const { account } = target;
if (account == null) {
// 通常ありえないが、アカウントが存在しない場合はエラー
throw new AccountNotFoundError('Account is Not Found.');
}
// 管理者IDの一覧を作成
const adminIds = [account]
.flatMap((x) => [x?.primary_admin_user_id, x?.secondary_admin_user_id])
.flatMap((x) => (x != null ? [x] : []));
// 削除対象が管理者であるかを確認
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: {
userGroupMembers: true,
},
where: {
account_id: target.account_id,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
const workflowRepo = entityManager.getRepository(Workflow);
const workflows = await workflowRepo.find({
where: {
account_id: target.account_id,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// WorkflowのAuthor枠に設定されているユーザーは削除できない
if (workflows.some((x) => x.author_id === target.id)) {
throw new AssignedWorkflowWithAuthorDeleteFailedError(
'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] : []));
// Workflowに直接個人で指定されているTypistは削除できない
if (typistIds.some((typistId) => typistId === target.id)) {
throw new AssignedWorkflowWithTypistDeleteFailedError(
'Typist is assigned to a workflow.',
);
}
// いずれかのGroupに属しているユーザーIDの一覧を作成
const groupMemberIds = groups
.flatMap((group) => group.userGroupMembers)
.flatMap((member) => (member != null ? [member.user_id] : []));
// 削除対象ユーザーがGroupに属しているユーザーに含まれていたら削除できない
if (groupMemberIds.some((id) => id === target.id)) {
throw new ExistsGroupMemberDeleteFailedError(
'User is a member of a group',
);
}
// 削除対象ユーザーがAuthorであった時、
if (target.role === USER_ROLES.AUTHOR) {
const taskRepo = entityManager.getRepository(Task);
// 自分が所有者のタスクの一覧を取得する
const tasks = await taskRepo.find({
relations: {
file: true,
},
where: {
account_id: target.account_id,
status: Not(TASK_STATUS.BACKUP), // 数が膨大になりうる&有効なタスクへの状態遷移ができないBACKUPは除いて取得
file: {
owner_user_id: target.id,
},
},
lock: { mode: 'pessimistic_write' }, // lockする事で状態遷移の競合をブロックし、新規追加以外で所有タスク群の状態変更を防ぐ
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// 未完了タスクが残っていたら削除できない
const enableStatus: string[] = [
TASK_STATUS.UPLOADED,
TASK_STATUS.IN_PROGRESS,
TASK_STATUS.PENDING,
];
// 未完了タスクを列挙
const enableTasks = tasks.filter((task) =>
enableStatus.includes(task.status),
);
if (enableTasks.length > 0) {
throw new ExistsValidTaskDeleteFailedError('User has valid tasks.');
}
}
// 削除対象ユーザーがTypistであった時、
if (target.role === USER_ROLES.TYPIST) {
const checkoutPermissionRepo =
entityManager.getRepository(CheckoutPermission);
const permissions = await checkoutPermissionRepo.find({
where: {
user_id: target.id,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// タスクのチェックアウト権限が残っていたら削除できない
if (permissions.length !== 0) {
throw new ExistsCheckoutPermissionDeleteFailedError(
'User has checkout permissions.',
);
}
}
// 対象ユーザーのライセンス割り当て状態を取得
const licenseRepo = entityManager.getRepository(License);
const allocatedLicense = await licenseRepo.findOne({
where: {
allocated_user_id: userId,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// ライセンスが割り当て済みかつ、
if (allocatedLicense !== null) {
const { status, expiry_date } = allocatedLicense;
// 有効な状態かつ、
if (status === LICENSE_ALLOCATED_STATUS.ALLOCATED) {
// 有効期限を迎えていない場合は削除できない
if (expiry_date !== null && expiry_date > currentTime) {
throw new ExistsValidLicenseDeleteFailedError(
'User has valid licenses.',
);
}
}
}
// ユーザーを削除する前に、削除対象ユーザーをアーカイブする
const userArchiveRepo = entityManager.getRepository(UserArchive);
await insertEntity(
UserArchive,
userArchiveRepo,
target,
this.isCommentOut,
context,
);
// 期限切れライセンスが割り当てられていた場合、ユーザーを削除する前にライセンスを割り当て解除する
// ※この処理時点で有効期限外ライセンスであることは確定であるため、期限切れ判定をここでは行わない
if (allocatedLicense != null) {
allocatedLicense.status = LICENSE_ALLOCATED_STATUS.REUSABLE;
allocatedLicense.allocated_user_id = null;
await updateEntity(
licenseRepo,
{ id: allocatedLicense.id },
allocatedLicense,
this.isCommentOut,
context,
);
// ライセンス割り当て履歴テーブルへ登録
const licenseAllocationHistoryRepo = entityManager.getRepository(
LicenseAllocationHistory,
);
const deallocationHistory = new LicenseAllocationHistory();
deallocationHistory.user_id = userId;
deallocationHistory.license_id = allocatedLicense.id;
deallocationHistory.account_id = account.id;
deallocationHistory.is_allocated = false;
deallocationHistory.executed_at = new Date();
deallocationHistory.switch_from_type = SWITCH_FROM_TYPE.NONE;
await insertEntity(
LicenseAllocationHistory,
licenseAllocationHistoryRepo,
deallocationHistory,
this.isCommentOut,
context,
);
}
// ユーザテーブルのレコードを削除する
await deleteEntity(
userRepo,
{ user_id: target.id },
this.isCommentOut,
context,
);
// ソート条件のテーブルのレコードを削除する
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
await deleteEntity(
sortCriteriaRepo,
{ user_id: target.id },
this.isCommentOut,
context,
);
return { isSuccess: true };
});
}
/**
* UserID指定のユーザーとソート条件を同時に削除する
* @param userId