diff --git a/dictation_server/src/repositories/users/errors/types.ts b/dictation_server/src/repositories/users/errors/types.ts index 6f90ede..217d54d 100644 --- a/dictation_server/src/repositories/users/errors/types.ts +++ b/dictation_server/src/repositories/users/errors/types.ts @@ -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'; + } +} \ 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 8c39441..0355ec5 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -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