diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 8bd934c..0b5902e 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -89,4 +89,6 @@ export const ErrorCodes = [ 'E018001', // パートナーアカウント削除エラー(削除条件を満たしていない) 'E019001', // パートナーアカウント取得不可エラー(階層構造が不正) 'E020001', // パートナーアカウント変更エラー(変更条件を満たしていない) + 'E021001', // 音声ファイル名変更不可エラー(権限不足) + 'E021002', // 音声ファイル名変更不可エラー(同名ファイルが存在) ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index d384512..6731596 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -79,4 +79,6 @@ export const errors: Errors = { E018001: 'Partner account delete failed Error: not satisfied conditions', E019001: 'Partner account get failed Error: hierarchy mismatch', E020001: 'Partner account change failed Error: not satisfied conditions', + E021001: 'Audio file name change failed Error: insufficient permissions', + E021002: 'Audio file name change failed Error: same file name exists', }; diff --git a/dictation_server/src/features/files/files.controller.spec.ts b/dictation_server/src/features/files/files.controller.spec.ts index 39798dc..d79bf29 100644 --- a/dictation_server/src/features/files/files.controller.spec.ts +++ b/dictation_server/src/features/files/files.controller.spec.ts @@ -88,20 +88,22 @@ describe('valdation FileRenameRequest', () => { const errors = await validate(valdationObject); expect(errors.length).toBe(1); }); - it('音声ファイル名が50文字の場合、リクエストに成功する', async () => { + it('音声ファイル名が64文字の場合、リクエストに成功する', async () => { const request = new FileRenameRequest(); request.audioFileId = 1; - request.fileName = 'ABCDEFGHI1ABCDEFGHI2ABCDEFGHI3ABCDEFGHI4ABCDEFGHI5'; + request.fileName = + 'ABCDEFGHI1ABCDEFGHI2ABCDEFGHI3ABCDEFGHI4ABCDEFGHI5ABCDEFGHI6ABCD'; const valdationObject = plainToClass(FileRenameRequest, request); const errors = await validate(valdationObject); expect(errors.length).toBe(0); }); - it('音声ファイル名が51文字の場合、リクエストが失敗する', async () => { + it('音声ファイル名が65文字の場合、リクエストが失敗する', async () => { const request = new FileRenameRequest(); request.audioFileId = 1; - request.fileName = 'ABCDEFGHI1ABCDEFGHI2ABCDEFGHI3ABCDEFGHI4ABCDEFGHI5A'; + request.fileName = + 'ABCDEFGHI1ABCDEFGHI2ABCDEFGHI3ABCDEFGHI4ABCDEFGHI5ABCDEFGHI6ABCDE'; const valdationObject = plainToClass(FileRenameRequest, request); diff --git a/dictation_server/src/features/files/files.controller.ts b/dictation_server/src/features/files/files.controller.ts index 27906a5..ceb28ac 100644 --- a/dictation_server/src/features/files/files.controller.ts +++ b/dictation_server/src/features/files/files.controller.ts @@ -602,7 +602,7 @@ export class FilesController { const context = makeContext(userId, requestId); this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`); - // TODO: ファイル名変更処理を実装する + await this.filesService.fileRename(context, userId, audioFileId, fileName); return {}; } } diff --git a/dictation_server/src/features/files/files.module.ts b/dictation_server/src/features/files/files.module.ts index 9d087d8..2a7e224 100644 --- a/dictation_server/src/features/files/files.module.ts +++ b/dictation_server/src/features/files/files.module.ts @@ -28,6 +28,7 @@ import { AccountsRepositoryModule } from '../../repositories/accounts/accounts.r SendGridModule, AdB2cModule, AccountsRepositoryModule, + AudioFilesRepositoryModule, ], providers: [FilesService], controllers: [FilesController], diff --git a/dictation_server/src/features/files/files.service.spec.ts b/dictation_server/src/features/files/files.service.spec.ts index c0ce84a..88f048d 100644 --- a/dictation_server/src/features/files/files.service.spec.ts +++ b/dictation_server/src/features/files/files.service.spec.ts @@ -35,7 +35,11 @@ import { import { createWorktype } from '../accounts/test/utility'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; -import { getCheckoutPermissions, getTask } from '../tasks/test/utility'; +import { + createCheckoutPermissions, + getAudioFile, + getCheckoutPermissions, +} from '../tasks/test/utility'; import { DateWithZeroTime } from '../licenses/types/types'; import { LICENSE_ALLOCATED_STATUS, @@ -601,7 +605,7 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); // 音声ファイルの録音者のユーザー - const { author_id: authorAuthorId } = await makeTestUser(source, { + await makeTestUser(source, { account_id: accountId, external_id: 'author-user-external-id', role: 'author', @@ -724,16 +728,13 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); // 音声ファイルの録音者のユーザー - const { - external_id: authorExternalId, - id: authorUserId, - author_id: authorAuthorId, - } = await makeTestUser(source, { - account_id: accountId, - external_id: 'author-user-external-id', - role: 'author', - author_id: 'AUTHOR_ID', - }); + const { external_id: authorExternalId, author_id: authorAuthorId } = + await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); const blobParam = makeBlobstorageServiceMockValue(); const notificationParam = makeDefaultNotificationhubServiceMockValue(); @@ -1126,16 +1127,13 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { it('日付フォーマットが不正な場合、エラーを返却する', async () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); - const { - external_id: authorExternalId, - id: authorUserId, - author_id: authorAuthorId, - } = await makeTestUser(source, { - account_id: accountId, - external_id: 'author-user-external-id', - role: 'author', - author_id: 'AUTHOR_ID', - }); + const { external_id: authorExternalId, author_id: authorAuthorId } = + await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); const blobParam = makeBlobstorageServiceMockValue(); const notificationParam = makeDefaultNotificationhubServiceMockValue(); @@ -1173,16 +1171,13 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { it('オプションアイテムが10個ない場合、エラーを返却する', async () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); - const { - external_id: authorExternalId, - id: authorUserId, - author_id: authorAuthorId, - } = await makeTestUser(source, { - account_id: accountId, - external_id: 'author-user-external-id', - role: 'author', - author_id: 'AUTHOR_ID', - }); + const { external_id: authorExternalId, author_id: authorAuthorId } = + await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); const blobParam = makeBlobstorageServiceMockValue(); const notificationParam = makeDefaultNotificationhubServiceMockValue(); @@ -2199,3 +2194,408 @@ const optionItemList = [ optionItemValue: 'value_10', }, ]; + +describe('fileRename', () => { + 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が行われるため注意 + logger: new TestLogger('none'), + logging: true, + }); + 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 service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const context = makeContext(admin.external_id, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + await service.fileRename( + context, + admin.external_id, + task.audioFileId, + newFileName, + ); + + //実行結果を確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(newFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + }); + it('ファイル名を変更できる(Author)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: authorExternalId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_ID', + }); + + const context = makeContext(authorExternalId, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + undefined, + 'AUTHOR_ID', + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + await service.fileRename( + context, + authorExternalId, + task.audioFileId, + newFileName, + ); + + //実行結果を確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(newFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + }); + it('ファイル名を変更できる(Typist)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: typistExternalId, id: typistId } = await makeTestUser( + source, + { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }, + ); + + const context = makeContext(typistExternalId, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + ); + + await createCheckoutPermissions(source, task.taskId, typistId); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + await service.fileRename( + context, + typistExternalId, + task.audioFileId, + newFileName, + ); + + //実行結果を確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(newFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + }); + it('ユーザーが管理者でなくRoleがNoneの場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: noneExternalId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'none-user-external-id', + role: USER_ROLES.NONE, + }); + + const context = makeContext(noneExternalId, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + try { + await service.fileRename( + context, + noneExternalId, + task.audioFileId, + newFileName, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E021001')); + } else { + fail(); + } + } + }); + it('Authorがファイル名変更をするときユーザーのAuthorIDとタスクのAuthorIDが異なる場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: authorExternalId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_ID', + }); + + const context = makeContext(authorExternalId, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + undefined, + 'AUTHOR_ID_XXX', + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + try { + await service.fileRename( + context, + authorExternalId, + task.audioFileId, + newFileName, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E021001')); + } else { + fail(); + } + } + }); + it('Typistがファイル名変更をするときユーザーがタスクのチェックアウト候補でない場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: typistExternalId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + const context = makeContext(typistExternalId, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + } + + const newFileName = 'new.zip'; + + try { + await service.fileRename( + context, + typistExternalId, + task.audioFileId, + newFileName, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E021001')); + } else { + fail(); + } + } + }); + it('変更するファイル名がすでに存在する場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const context = makeContext(admin.external_id, 'requestId'); + + const oldFileName = 'old.zip'; + + const task = await createTask( + source, + account.id, + 'https://blob.url/account-1', + oldFileName, + TASK_STATUS.UPLOADED, + undefined, + undefined, + undefined, + undefined, + '00000001', + ); + + const alreadyExistFileName = 'already.zip'; + const alreadyExistTask = await createTask( + source, + account.id, + 'https://blob.url/account-1', + alreadyExistFileName, + TASK_STATUS.UPLOADED, + undefined, + undefined, + undefined, + undefined, + '00000002', + ); + + // 事前にDBを確認 + { + const audioFile = await getAudioFile(source, task.audioFileId); + expect(audioFile?.file_name).toBe(oldFileName); + expect(audioFile?.raw_file_name).toBe(oldFileName); + + const alreadyExistAudioFile = await getAudioFile( + source, + alreadyExistTask.audioFileId, + ); + expect(alreadyExistAudioFile?.file_name).toBe(alreadyExistFileName); + } + + try { + await service.fileRename( + context, + admin.external_id, + task.audioFileId, + alreadyExistFileName, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E021002')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 5736ac2..f6a023c 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -43,6 +43,12 @@ import { AccountsRepositoryService } from '../../repositories/accounts/accounts. import { getUserNameAndMailAddress } from '../../gateways/adb2c/utils/utils'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { SendGridService } from '../../gateways/sendgrid/sendgrid.service'; +import { AudioFilesRepositoryService } from '../../repositories/audio_files/audio_files.repository.service'; +import { + CheckoutPermissionNotFoundError, + FileNameAlreadyExistsError, + RoleNotMatchError, +} from '../../repositories/audio_files/errors/types'; @Injectable() export class FilesService { @@ -59,6 +65,7 @@ export class FilesService { private readonly notificationhubService: NotificationhubService, private readonly licensesRepository: LicensesRepositoryService, private readonly sendGridService: SendGridService, + private readonly audioFilesRepositoryService: AudioFilesRepositoryService, ) {} /** @@ -911,4 +918,73 @@ export class FilesService { ); } } + + /** + * 音声ファイルの表示ファイル名を変更する + * @param context + * @param externalId + * @param audioFileId + * @param fileName + * @returns rename + */ + async fileRename( + context: Context, + externalId: string, + audioFileId: number, + fileName: string, + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${ + this.fileRename.name + } | params: { externalId: ${externalId}, audioFileId: ${audioFileId}, fileName: ${fileName} };`, + ); + + try { + // ユーザー取得 + const { account_id: accountId, id: userId } = + await this.usersRepository.findUserByExternalId(context, externalId); + + // 音声ファイルの表示ファイル名を変更 + await this.audioFilesRepositoryService.renameAudioFile( + context, + accountId, + userId, + audioFileId, + fileName, + ); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + + if (e instanceof Error) { + switch (e.constructor) { + case TasksNotFoundError: + case RoleNotMatchError: + case AuthorUserNotMatchError: + case CheckoutPermissionNotFoundError: + throw new HttpException( + makeErrorResponse('E021001'), + HttpStatus.BAD_REQUEST, + ); + case FileNameAlreadyExistsError: + throw new HttpException( + makeErrorResponse('E021002'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.fileRename.name}`, + ); + } + } } diff --git a/dictation_server/src/features/files/test/utility.ts b/dictation_server/src/features/files/test/utility.ts index c15f981..73416be 100644 --- a/dictation_server/src/features/files/test/utility.ts +++ b/dictation_server/src/features/files/test/utility.ts @@ -56,7 +56,7 @@ export const createTask = async ( owner_user_id?: number | undefined, fileSize?: number | undefined, jobNumber?: string | undefined, -): Promise<{ audioFileId: number }> => { +): Promise<{ audioFileId: number; taskId: number }> => { const { identifiers: audioFileIdentifiers } = await datasource .getRepository(AudioFile) .insert({ @@ -64,6 +64,7 @@ export const createTask = async ( owner_user_id: owner_user_id ?? 1, url: url, file_name: fileName, + raw_file_name: fileName, author_id: author_id ?? 'DEFAULT_ID', work_type_id: 'work_type_id', started_at: new Date(), @@ -89,20 +90,23 @@ export const createTask = async ( }); const templateFile = templateFileIdentifiers.pop() as TemplateFile; - await datasource.getRepository(Task).insert({ - job_number: jobNumber ?? '00000001', - account_id: account_id, - is_job_number_enabled: true, - audio_file_id: audioFile.id, - template_file_id: templateFile.id, - typist_user_id: typist_user_id, - status: status, - priority: '01', - started_at: new Date().toISOString(), - created_at: new Date(), - }); + const { identifiers: TaskIdentifiers } = await datasource + .getRepository(Task) + .insert({ + job_number: jobNumber ?? '00000001', + account_id: account_id, + is_job_number_enabled: true, + audio_file_id: audioFile.id, + template_file_id: templateFile.id, + typist_user_id: typist_user_id, + status: status, + priority: '01', + started_at: new Date().toISOString(), + created_at: new Date(), + }); + const task = TaskIdentifiers.pop() as Task; - return { audioFileId: audioFile.id }; + return { audioFileId: audioFile.id, taskId: task.id }; }; export const getTaskFromJobNumber = async ( diff --git a/dictation_server/src/features/files/types/types.ts b/dictation_server/src/features/files/types/types.ts index 2199a75..57bdb41 100644 --- a/dictation_server/src/features/files/types/types.ts +++ b/dictation_server/src/features/files/types/types.ts @@ -151,7 +151,7 @@ export class FileRenameRequest { audioFileId: number; @ApiProperty({ description: '変更するファイル名' }) @IsString() - @MaxLength(50) + @MaxLength(64) @MinLength(1) fileName: string; } diff --git a/dictation_server/src/repositories/audio_files/audio_files.repository.service.ts b/dictation_server/src/repositories/audio_files/audio_files.repository.service.ts index b9605e8..a6dded1 100644 --- a/dictation_server/src/repositories/audio_files/audio_files.repository.service.ts +++ b/dictation_server/src/repositories/audio_files/audio_files.repository.service.ts @@ -1,7 +1,182 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In, Not } from 'typeorm'; +import { AudioFile } from './entity/audio_file.entity'; +import { Task } from '../tasks/entity/task.entity'; +import { User } from '../users/entity/user.entity'; +import { USER_ROLES } from '../../constants'; +import { UserGroupMember } from '../user_groups/entity/user_group_member.entity'; +import { CheckoutPermission } from '../checkout_permissions/entity/checkout_permission.entity'; +import { Context } from '../../common/log'; +import { updateEntity } from '../../common/repository'; +import { + CheckoutPermissionNotFoundError, + FileNameAlreadyExistsError, + RoleNotMatchError, + TasksNotFoundError, +} from './errors/types'; +import { AuthorUserNotMatchError } from '../../features/files/errors/types'; @Injectable() export class AudioFilesRepositoryService { + //クエリログにコメントを出力するかどうか + private readonly isCommentOut = process.env.STAGE !== 'local'; constructor(private dataSource: DataSource) {} + + /** + * アカウント内のテンプレートファイルの一覧を取得する + * @param context + * @param accountId + * @param userId + * @param audioFileId + * @param fileName + * @returns audio file + */ + async renameAudioFile( + context: Context, + accountId: number, + userId: number, + audioFileId: number, + fileName: string, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + // 実行ユーザーの情報を取得 + const userRepo = entityManager.getRepository(User); + const user = await userRepo.findOne({ + where: { account_id: accountId, id: userId }, + relations: { account: true }, + lock: { mode: 'pessimistic_write' }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); + + // 運用上はあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!user) { + throw new Error( + `user not found. account_id: ${accountId}, user_id: ${userId}`, + ); + } + const account = user.account; + // 運用上はあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!account) { + throw new Error(`account not found. account_id: ${accountId}`); + } + + // ユーザーがアカウントの管理者であるかどうか + const isAdmin = + account.primary_admin_user_id === userId || + account.secondary_admin_user_id === userId; + + // ユーザーがTypistである場合は、ユーザーの所属するグループを取得しておく + let groupIds: number[] = []; + if (user.role === USER_ROLES.TYPIST) { + const groupMemberRepo = entityManager.getRepository(UserGroupMember); + // ユーザーの所属するすべてのグループを列挙 + const groups = await groupMemberRepo.find({ + relations: { user: true }, + where: { user_id: userId }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); + // ユーザーの所属するすべてのグループIDを列挙 + groupIds = groups.map((member) => member.user_group_id); + } + + // リクエストの音声ファイル名と同じファイル名の音声ファイルの一覧を取得 + const audioFileRepo = entityManager.getRepository(AudioFile); + const audioFiles = await audioFileRepo.find({ + where: { + account_id: accountId, + id: Not(audioFileId), + file_name: fileName, + }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + lock: { mode: 'pessimistic_write' }, + }); + + // ファイル名が重複している場合はエラー + if (audioFiles.length !== 0) { + throw new FileNameAlreadyExistsError( + `The file name already exists. accountId: ${accountId} file_name: ${fileName}`, + ); + } + + // タスク情報を取得 + const taskRepo = entityManager.getRepository(Task); + const task = await taskRepo.findOne({ + where: { account_id: accountId, audio_file_id: audioFileId }, + relations: { file: true }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + lock: { mode: 'pessimistic_write' }, + }); + + if (!task) { + throw new TasksNotFoundError( + `task not found. account_id: ${accountId} audio_file_id: ${audioFileId}`, + ); + } + + // 音声ファイル情報を取得 + const audioFile = task.file; + if (!audioFile) { + throw new TasksNotFoundError( + `audioFile not found. audio_file_id: ${audioFileId}`, + ); + } + + if (isAdmin) { + // 管理者の場合は、ファイル名を変更できる + await updateEntity( + audioFileRepo, + { id: audioFileId }, + { file_name: fileName }, + this.isCommentOut, + context, + ); + return; + } + + // ユーザーが管理者でない場合は、ロールに応じた権限を確認 + + if (user.role === USER_ROLES.NONE) { + // NONEの場合はエラー + throw new RoleNotMatchError( + `The user does not have the required role. userId: ${userId}. role: ${user.role}`, + ); + } + if (user.role === USER_ROLES.AUTHOR) { + // ユーザーがAuthorである場合は、音声ファイルのAuthorIDが一致するか確認 + if (audioFile.author_id !== user.author_id) { + throw new AuthorUserNotMatchError( + `The user is not the author of the audio file. audioFileId: ${audioFileId}, userId: ${userId}`, + ); + } + } + if (user.role === USER_ROLES.TYPIST) { + // ユーザーがTypistである場合は、チェックアウト権限を確認 + const checkoutRepo = entityManager.getRepository(CheckoutPermission); + // ユーザーに対するチェックアウト権限、またはユーザーの所属するユーザーグループのチェックアウト権限を取得 + const checkoutPermissions = await checkoutRepo.find({ + where: [ + { task_id: task.id, user_id: userId }, // ユーザーがチェックアウト可能である + { task_id: task.id, user_group_id: In(groupIds) }, // ユーザーの所属するユーザーグループがチェックアウト可能である + ], + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); + + // チェックアウト権限がない場合はエラー + if (checkoutPermissions.length === 0) { + throw new CheckoutPermissionNotFoundError( + `The user does not have checkout permission. taskId: ${task.id}, userId: ${userId}`, + ); + } + } + + // ファイル名を変更 + await updateEntity( + audioFileRepo, + { id: audioFileId }, + { file_name: fileName }, + this.isCommentOut, + context, + ); + }); + } } diff --git a/dictation_server/src/repositories/audio_files/errors/types.ts b/dictation_server/src/repositories/audio_files/errors/types.ts new file mode 100644 index 0000000..9d47793 --- /dev/null +++ b/dictation_server/src/repositories/audio_files/errors/types.ts @@ -0,0 +1,35 @@ +// タスク未発見エラー +export class TasksNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'TasksNotFoundError'; + } +} +// ファイル名変更権限ロール不一致エラー +export class RoleNotMatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'RoleNotMatchError'; + } +} +// タスクAuthorID不一致エラー +export class TaskAuthorIdNotMatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'TaskAuthorIdNotMatchError'; + } +} +// チェックアウト権限未発見エラー +export class CheckoutPermissionNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'CheckoutPermissionNotFoundError'; + } +} +// 同名ファイルエラー +export class FileNameAlreadyExistsError extends Error { + constructor(message: string) { + super(message); + this.name = 'StatusNotMatchError'; + } +}