diff --git a/dictation_server/src/common/cache/constants.ts b/dictation_server/src/common/cache/constants.ts index da6c13e..b79dd78 100644 --- a/dictation_server/src/common/cache/constants.ts +++ b/dictation_server/src/common/cache/constants.ts @@ -1 +1 @@ -export const ADB2C_PREFIX = "adb2c-external-id:" \ No newline at end of file +export const ADB2C_PREFIX = 'adb2c-external-id:'; diff --git a/dictation_server/src/common/cache/index.ts b/dictation_server/src/common/cache/index.ts index 067be25..3355a54 100644 --- a/dictation_server/src/common/cache/index.ts +++ b/dictation_server/src/common/cache/index.ts @@ -6,8 +6,8 @@ import { ADB2C_PREFIX } from './constants'; * @returns キャッシュのキー */ export const makeADB2CKey = (externalId: string): string => { - return `${ADB2C_PREFIX}${externalId}`; -} + return `${ADB2C_PREFIX}${externalId}`; +}; /** * ADB2Cのユーザー格納用のキーから外部ユーザーIDを取得する @@ -15,5 +15,5 @@ export const makeADB2CKey = (externalId: string): string => { * @returns 外部ユーザーID */ export const restoreAdB2cID = (key: string): string => { - return key.replace(ADB2C_PREFIX, ''); -} \ No newline at end of file + return key.replace(ADB2C_PREFIX, ''); +}; diff --git a/dictation_server/src/common/token/index.ts b/dictation_server/src/common/token/index.ts index dd97ce3..7c1b5ef 100644 --- a/dictation_server/src/common/token/index.ts +++ b/dictation_server/src/common/token/index.ts @@ -7,11 +7,5 @@ import type { } from './types'; import { isIDToken } from './typeguard'; -export type { - AccessToken, - B2cMetadata, - IDToken, - JwkSignKey, - RefreshToken, -}; +export type { AccessToken, B2cMetadata, IDToken, JwkSignKey, RefreshToken }; export { isIDToken }; diff --git a/dictation_server/src/features/accounts/test/utility.ts b/dictation_server/src/features/accounts/test/utility.ts index 70cfc3a..b52a186 100644 --- a/dictation_server/src/features/accounts/test/utility.ts +++ b/dictation_server/src/features/accounts/test/utility.ts @@ -150,8 +150,9 @@ export const createWorktype = async ( }, ); } - - return worktype; + return (await datasource + .getRepository(Worktype) + .findOne({ where: { id: worktype.id } })) as Worktype; }; // Worktypeを取得する diff --git a/dictation_server/src/features/files/files.module.ts b/dictation_server/src/features/files/files.module.ts index 7054ac8..d5ca8cc 100644 --- a/dictation_server/src/features/files/files.module.ts +++ b/dictation_server/src/features/files/files.module.ts @@ -7,6 +7,8 @@ import { AudioOptionItemsRepositoryModule } from '../../repositories/audio_optio import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module'; import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module'; import { TemplateFilesRepositoryModule } from '../../repositories/template_files/template_files.repository.module'; +import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module'; +import { NotificationhubModule } from '../../gateways/notificationhub/notificationhub.module'; @Module({ imports: [ @@ -16,6 +18,8 @@ import { TemplateFilesRepositoryModule } from '../../repositories/template_files TasksRepositoryModule, BlobstorageModule, TemplateFilesRepositoryModule, + UserGroupsRepositoryModule, + NotificationhubModule, ], 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 dccffd8..15aee70 100644 --- a/dictation_server/src/features/files/files.service.spec.ts +++ b/dictation_server/src/features/files/files.service.spec.ts @@ -7,7 +7,12 @@ import { makeFilesServiceMock, } from './test/files.service.mock'; import { DataSource } from 'typeorm'; -import { createTask, makeTestingModuleWithBlob } from './test/utility'; +import { + createTask, + createUserGroupAndMember, + getTaskFromJobNumber, + makeTestingModuleWithBlobAndNotification, +} from './test/utility'; import { FilesService } from './files.service'; import { makeContext } from '../../common/log'; import { @@ -22,6 +27,16 @@ import { getTemplateFiles, } from '../templates/test/utility'; import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service'; +import { makeDefaultNotificationhubServiceMockValue } from '../tasks/test/tasks.service.mock'; +import { + createWorkflow, + createWorkflowTypist, +} from '../workflows/test/utility'; +import { createWorktype } from '../accounts/test/utility'; +import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; +import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; +import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; +import { getCheckoutPermissions, getTask } from '../tasks/test/utility'; describe('音声ファイルアップロードURL取得', () => { it('アップロードSASトークンが乗っているURLを返却する', async () => { @@ -109,55 +124,418 @@ describe('音声ファイルアップロードURL取得', () => { }); }); -describe('タスク作成', () => { - it('文字起こしタスクを作成できる', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - const service = await makeFilesServiceMock( - blobParam, - userRepoParam, - taskRepoParam, - ); - - expect( - await service.uploadFinished( - makeContext('trackingId'), - 'userId', - 'http://blob/url/file.zip', - 'AUTHOR_01', - 'file.zip', - '11:22:33', - '2023-05-26T11:22:33.444', - '2023-05-26T11:22:33.444', - '2023-05-26T11:22:33.444', - 256, - '01', - 'DS2', - 'comment', - 'workTypeID', - optionItemList, - false, - ), - ).toEqual({ jobNumber: '00000001' }); +describe('タスク作成から自動ルーティング(DB使用)', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); }); - it('日付フォーマットが不正な場合、エラーを返却する', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - const service = await makeFilesServiceMock( - blobParam, - userRepoParam, - taskRepoParam, + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + it('タスク作成時に、自動ルーティングを行うことができる(APIの引数として渡されたAuthorIDとworkType)', 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 { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + author_id: undefined, + }); + // ワークタイプを作成 + const { id: worktypeId, custom_worktype_id } = await createWorktype( + source, + accountId, + 'worktypeId', ); + // テンプレートファイルを作成 + const { id: templateFileId } = await createTemplateFile( + source, + accountId, + 'templateFile', + 'http://blob/url/templateFile.zip', + ); + // ワークフローを作成 + const { id: workflowId } = await createWorkflow( + source, + accountId, + authorUserId, + worktypeId, + templateFileId, + ); + // ワークフロータイピストを作成 + await createWorkflowTypist(source, workflowId, typistUserId); + const blobParam = makeBlobstorageServiceMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + const NotificationHubService = module.get( + NotificationhubService, + ); + const result = await service.uploadFinished( + makeContext('trackingId'), + authorExternalId, + 'http://blob/url/file.zip', + authorAuthorId ?? '', + 'file.zip', + '11:22:33', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + 256, + '01', + 'DS2', + 'comment', + custom_worktype_id, + optionItemList, + false, + ); + expect(result).toEqual({ jobNumber: '00000001' }); + // 通知処理が想定通りの引数で呼ばれているか確認 + expect(NotificationHubService.notify).toHaveBeenCalledWith( + makeContext('trackingId'), + [`user_${typistUserId}`], + makeNotifyMessage('M000101'), + ); + // 作成したタスクを取得 + const resultTask = await getTaskFromJobNumber(source, result.jobNumber); + // タスクのチェックアウト権限を取得 + const resultCheckoutPermission = await getCheckoutPermissions( + source, + resultTask?.id ?? 0, + ); + // タスクのテンプレートファイルIDを確認 + expect(resultTask?.template_file_id).toEqual(templateFileId); + // タスクのチェックアウト権限が想定通り(ワークフローで設定されている)のユーザーIDで作成されているか確認 + expect(resultCheckoutPermission.length).toEqual(1); + expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId); + }); + + it('別のタスクが既に存在する場合、タスク作成時に、自動ルーティングを行うことができる(APIの引数として渡されたAuthorIDとworkType)', 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 { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + author_id: undefined, + }); + //タスクを作成 + await createTask( + source, + accountId, + 'http://blob/url/file.zip', + 'file.zip', + '01', + typistUserId, + authorAuthorId ?? '', + ); + // ワークタイプを作成 + const { id: worktypeId, custom_worktype_id } = await createWorktype( + source, + accountId, + 'worktypeId', + ); + // ワークフローを作成 + const { id: workflowId } = await createWorkflow( + source, + accountId, + authorUserId, + worktypeId, + ); + // ワークフロータイピストを作成 + await createWorkflowTypist(source, workflowId, typistUserId); + const blobParam = makeBlobstorageServiceMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + const NotificationHubService = module.get( + NotificationhubService, + ); + const result = await service.uploadFinished( + makeContext('trackingId'), + authorExternalId, + 'http://blob/url/file.zip', + authorAuthorId ?? '', + 'file.zip', + '11:22:33', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + 256, + '01', + 'DS2', + 'comment', + custom_worktype_id, + optionItemList, + false, + ); + expect(result).toEqual({ jobNumber: '00000002' }); + // 通知処理が想定通りの引数で呼ばれているか確認 + expect(NotificationHubService.notify).toHaveBeenCalledWith( + makeContext('trackingId'), + [`user_${typistUserId}`], + makeNotifyMessage('M000101'), + ); + // 作成したタスクを取得 + const resultTask = await getTaskFromJobNumber(source, result.jobNumber); + // タスクのチェックアウト権限を取得 + const resultCheckoutPermission = await getCheckoutPermissions( + source, + resultTask?.id ?? 0, + ); + // タスクのテンプレートファイルIDを確認 + expect(resultTask?.template_file_id).toBeNull(); + // タスクのチェックアウト権限が想定通り(ワークフローで設定されている)のユーザーIDで作成されているか確認 + expect(resultCheckoutPermission.length).toEqual(1); + expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId); + }); + + it('タスク作成時に、自動ルーティングを行うことができる(API実行者のAuthorIDとworkType)', async () => { + if (!source) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + // 音声ファイルの録音者のユーザー + const { author_id: authorAuthorId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // ルーティング先のタイピストのユーザー + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + author_id: undefined, + }); + // API実行者のユーザー + const { external_id: myExternalId, id: myUserId } = await makeTestUser( + source, + { + account_id: accountId, + external_id: 'my-author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }, + ); + + // ワークタイプを作成 + const { id: worktypeId, custom_worktype_id } = await createWorktype( + source, + accountId, + 'worktypeId', + ); + + // テンプレートファイルを作成 + const { id: templateFileId } = await createTemplateFile( + source, + accountId, + 'templateFile', + 'http://blob/url/templateFile.zip', + ); + + // ワークフローを作成 + const { id: workflowId } = await createWorkflow( + source, + accountId, + myUserId, // API実行者のユーザーIDを設定 + worktypeId, + templateFileId, + ); + // ユーザーグループを作成 + const { userGroupId } = await createUserGroupAndMember( + source, + accountId, + 'userGroupName', + typistUserId, // ルーティング先のタイピストのユーザーIDを設定 + ); + // ワークフロータイピストを作成 + await createWorkflowTypist( + source, + workflowId, + undefined, + userGroupId, // ルーティング先のユーザーグループIDを設定 + ); + + const blobParam = makeBlobstorageServiceMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + const NotificationHubService = module.get( + NotificationhubService, + ); + const result = await service.uploadFinished( + makeContext('trackingId'), + myExternalId, // API実行者のユーザーIDを設定 + 'http://blob/url/file.zip', + authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る + 'file.zip', + '11:22:33', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + 256, + '01', + 'DS2', + 'comment', + custom_worktype_id, + optionItemList, + false, + ); + expect(result).toEqual({ jobNumber: '00000001' }); + // 通知処理が想定通りの引数で呼ばれているか確認 + expect(NotificationHubService.notify).toHaveBeenCalledWith( + makeContext('trackingId'), + [`user_${typistUserId}`], + makeNotifyMessage('M000101'), + ); + // 作成したタスクを取得 + const resultTask = await getTaskFromJobNumber(source, result.jobNumber); + // タスクのチェックアウト権限を取得 + const resultCheckoutPermission = await getCheckoutPermissions( + source, + resultTask?.id ?? 0, + ); + // タスクのテンプレートファイルIDを確認 + expect(resultTask?.template_file_id).toEqual(templateFileId); + // タスクのチェックアウト権限が想定通り(ワークフローで設定されている)のユーザーIDで作成されているか確認 + expect(resultCheckoutPermission.length).toEqual(1); + expect(resultCheckoutPermission[0].user_group_id).toEqual(userGroupId); + }); + + 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 blobParam = makeBlobstorageServiceMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + const result = await service.uploadFinished( + makeContext('trackingId'), + authorExternalId, // API実行者のユーザーIDを設定 + 'http://blob/url/file.zip', + authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る + 'file.zip', + '11:22:33', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + '2023-05-26T11:22:33.444', + 256, + '01', + 'DS2', + 'comment', + 'worktypeId', + optionItemList, + false, + ); + expect(result).toEqual({ jobNumber: '00000001' }); + // タスクを取得 + const resultTask = await getTaskFromJobNumber(source, result.jobNumber); + // タスクのチェックアウト権限を取得 + const resultCheckoutPermission = await getCheckoutPermissions( + source, + resultTask?.id ?? 0, + ); + // タスクがあることを確認 + expect(resultTask).not.toBeNull(); + // 自動ルーティングが行われていないことを確認 + expect(resultCheckoutPermission.length).toEqual(0); + }); + 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 blobParam = makeBlobstorageServiceMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); await expect( service.uploadFinished( makeContext('trackingId'), - 'userId', + authorExternalId, 'http://blob/url/file.zip', - 'AUTHOR_01', + authorAuthorId ?? '', 'file.zip', '11:22:33', 'yyyy-05-26T11:22:33.444', @@ -175,23 +553,36 @@ describe('タスク作成', () => { new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST), ); }); - 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 blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - const service = await makeFilesServiceMock( + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, blobParam, - userRepoParam, - taskRepoParam, + notificationParam, ); + if (!module) fail(); + const service = module.get(FilesService); await expect( service.uploadFinished( makeContext('trackingId'), - 'userId', + authorExternalId, 'http://blob/url/file.zip', - 'AUTHOR_01', + authorAuthorId ?? '', 'file.zip', '11:22:33', '2023-05-26T11:22:33.444', @@ -214,25 +605,25 @@ describe('タスク作成', () => { new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST), ); }); - it('タスク追加でユーザー情報の取得に失敗した場合、エラーを返却する', async () => { + if (!source) fail(); const blobParam = makeBlobstorageServiceMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); - const service = await makeFilesServiceMock( + const module = await makeTestingModuleWithBlobAndNotification( + source, blobParam, - { - findUserByExternalId: new Error(''), - }, - taskRepoParam, + notificationParam, ); + if (!module) fail(); + const service = module.get(FilesService); await expect( service.uploadFinished( makeContext('trackingId'), - 'userId', + 'authorExternalId', 'http://blob/url/file.zip', - 'AUTHOR_01', + 'authorAuthorId', 'file.zip', '11:22:33', '2023-05-26T11:22:33.444', @@ -253,21 +644,37 @@ describe('タスク作成', () => { ), ); }); - it('タスクのDBへの追加に失敗した場合、エラーを返却する', async () => { + if (!source) fail(); const blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const service = await makeFilesServiceMock(blobParam, userRepoParam, { - create: new Error(''), - getTasksFromAccountId: new Error(), - }); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + const taskRepoService = module.get( + TasksRepositoryService, + ); + taskRepoService.create = jest.fn().mockRejectedValue(new Error('')); + const { id: accountId } = await makeTestSimpleAccount(source); + 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', + }); await expect( service.uploadFinished( makeContext('trackingId'), - 'userId', + authorExternalId, 'http://blob/url/file.zip', - 'AUTHOR_01', + authorAuthorId ?? '', 'file.zip', '11:22:33', '2023-05-26T11:22:33.444', @@ -338,7 +745,12 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -381,7 +793,12 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -430,7 +847,12 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -470,7 +892,12 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -497,7 +924,12 @@ describe('音声ファイルダウンロードURL取得', () => { const blobParam = makeBlobstorageServiceMockValue(); - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -541,7 +973,12 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = false; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -604,7 +1041,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -641,7 +1083,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -686,7 +1133,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -726,7 +1178,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = true; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -753,7 +1210,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { const blobParam = makeBlobstorageServiceMockValue(); - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); @@ -796,7 +1258,12 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.publishDownloadSas = `${url}?sas-token`; blobParam.fileExists = false; - const module = await makeTestingModuleWithBlob(source, blobParam); + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); if (!module) fail(); const service = module.get(FilesService); diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 44dff8f..b6ad1bf 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -24,6 +24,10 @@ import { import { Context } from '../../common/log'; import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service'; import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; +import { Task } from '../../repositories/tasks/entity/task.entity'; +import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; +import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; +import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; @Injectable() export class FilesService { @@ -34,6 +38,8 @@ export class FilesService { private readonly tasksRepositoryService: TasksRepositoryService, private readonly templateFilesRepository: TemplateFilesRepositoryService, private readonly blobStorageService: BlobstorageService, + private readonly userGroupsRepositoryService: UserGroupsRepositoryService, + private readonly notificationhubService: NotificationhubService, ) {} /** @@ -157,7 +163,7 @@ export class FilesService { HttpStatus.INTERNAL_SERVER_ERROR, ); } - + let task: Task; try { // URLにSASトークンがついている場合は取り除く const urlObj = new URL(url); @@ -167,7 +173,7 @@ export class FilesService { // 文字起こしタスク追加(音声ファイルとオプションアイテムも同時に追加) // 追加時に末尾のJOBナンバーにインクリメントする - const task = await this.tasksRepositoryService.create( + task = await this.tasksRepositoryService.create( user.account_id, user.id, priority, @@ -185,13 +191,57 @@ export class FilesService { isEncrypted, optionItemList, ); - return { jobNumber: task.job_number }; } catch (e) { this.logger.error(`error=${e}`); throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); + } + try { + // ルーティング設定に従い、チェックアウト権限を付与する + const { typistGroupIds, typistIds } = + await this.tasksRepositoryService.autoRouting( + task.audio_file_id, + user.account_id, + workType, + user.author_id ?? undefined, + ); + + const groupMembers = + await this.userGroupsRepositoryService.getGroupMembersFromGroupIds( + typistGroupIds, + ); + + // 重複のない割り当て候補ユーザーID一覧を取得する + const distinctUserIds = [ + ...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]), + ]; + + // 割り当てられたユーザーがいない場合は通知不要 + if (distinctUserIds.length === 0) { + this.logger.log('No user assigned.'); + return { jobNumber: task.job_number }; + } + + // タグを生成 + const tags = distinctUserIds.map((x) => `user_${x}`); + this.logger.log(`tags: ${tags}`); + + // タグ対象に通知送信 + await this.notificationhubService.notify( + context, + tags, + makeNotifyMessage('M000101'), + ); + + // 追加したタスクのJOBナンバーを返却 + return { jobNumber: task.job_number }; + } catch (error) { + // 処理の本筋はタスク生成のため自動ルーティングに失敗してもエラーにしない + this.logger.error(`Automatic routing or notification failed.`); + this.logger.error(`error=${error}`); + return { jobNumber: task.job_number }; } finally { this.logger.log( `[OUT] [${context.trackingId}] ${this.uploadFinished.name}`, diff --git a/dictation_server/src/features/files/test/files.service.mock.ts b/dictation_server/src/features/files/test/files.service.mock.ts index 9facc6f..979b9fb 100644 --- a/dictation_server/src/features/files/test/files.service.mock.ts +++ b/dictation_server/src/features/files/test/files.service.mock.ts @@ -6,6 +6,8 @@ import { FilesService } from '../files.service'; import { TasksRepositoryService } from '../../../repositories/tasks/tasks.repository.service'; import { Task } from '../../../repositories/tasks/entity/task.entity'; import { TemplateFilesRepositoryService } from '../../../repositories/template_files/template_files.repository.service'; +import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service'; +import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service'; export type BlobstorageServiceMockValue = { createContainer: void | Error; @@ -42,6 +44,10 @@ export const makeFilesServiceMock = async ( return makeTasksRepositoryMock(tasksRepositoryMockValue); case TemplateFilesRepositoryService: return {}; + case NotificationhubService: + return {}; + case UserGroupsRepositoryService: + return {}; } }) .compile(); @@ -186,6 +192,9 @@ export const makeDefaultTasksRepositoryMockValue = option_items: null, template_file: null, typist_user: null, + created_by: null, + updated_by: null, + updated_at: new Date(), }, getTasksFromAccountId: { tasks: [], diff --git a/dictation_server/src/features/files/test/utility.ts b/dictation_server/src/features/files/test/utility.ts index c1f6e52..80846d7 100644 --- a/dictation_server/src/features/files/test/utility.ts +++ b/dictation_server/src/features/files/test/utility.ts @@ -37,6 +37,12 @@ import { makeBlobstorageServiceMock, } from './files.service.mock'; import { TemplateFile } from '../../../repositories/template_files/entity/template_file.entity'; +import { + NotificationhubServiceMockValue, + makeNotificationhubServiceMock, +} from '../../tasks/test/tasks.service.mock'; +import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity'; +import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; export const createTask = async ( datasource: DataSource, @@ -95,9 +101,51 @@ export const createTask = async ( return { audioFileId: audioFile.id }; }; -export const makeTestingModuleWithBlob = async ( +export const getTaskFromJobNumber = async ( + datasource: DataSource, + jobNumber: string, +): Promise => { + const task = await datasource.getRepository(Task).findOne({ + where: { + job_number: jobNumber, + }, + }); + return task; +}; + +// ユーザーグループとユーザーグループメンバーを作成する +export const createUserGroupAndMember = async ( + datasource: DataSource, + accountId: number, + name: string, + userId: number, +): Promise<{ userGroupId: number }> => { + const { identifiers } = await datasource.getRepository(UserGroup).insert({ + account_id: accountId, + name: name, + deleted_at: null, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const userGroup = identifiers.pop() as UserGroup; + // ユーザーグループメンバーを作成する + await datasource.getRepository(UserGroupMember).insert({ + user_group_id: userGroup.id, + user_id: userId, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + return { userGroupId: userGroup.id }; +}; + +export const makeTestingModuleWithBlobAndNotification = async ( datasource: DataSource, blobStorageService: BlobstorageServiceMockValue, + notificationhubService: NotificationhubServiceMockValue, ): Promise => { try { const module: TestingModule = await Test.createTestingModule({ @@ -148,6 +196,8 @@ export const makeTestingModuleWithBlob = async ( }) .overrideProvider(BlobstorageService) .useValue(makeBlobstorageServiceMock(blobStorageService)) + .overrideProvider(NotificationhubService) + .useValue(makeNotificationhubServiceMock(notificationhubService)) .compile(); return module; diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index a01cebc..e1abcb3 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -208,6 +208,9 @@ describe('TasksService', () => { template_file_id: null, typist_user: null, template_file: null, + created_by: null, + updated_by: null, + updated_at: new Date('2023-01-01T01:01:01.000'), file: { id: 1, account_id: 1, diff --git a/dictation_server/src/features/tasks/test/tasks.service.mock.ts b/dictation_server/src/features/tasks/test/tasks.service.mock.ts index 1e09b84..cf5aa71 100644 --- a/dictation_server/src/features/tasks/test/tasks.service.mock.ts +++ b/dictation_server/src/features/tasks/test/tasks.service.mock.ts @@ -362,6 +362,9 @@ const defaultTasksRepositoryMockValue: { template_file_id: null, typist_user: null, template_file: null, + created_by: null, + updated_by: null, + updated_at: new Date('2023-01-01T01:01:01.000Z'), option_items: [ { id: 1, diff --git a/dictation_server/src/features/users/test/users.service.mock.ts b/dictation_server/src/features/users/test/users.service.mock.ts index 0c44550..908c130 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -337,7 +337,7 @@ export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => { }, createUser: '001', getUser: { - id: "xxxx-xxxxx-xxxxx-xxxx", + id: 'xxxx-xxxxx-xxxxx-xxxx', displayName: 'Hanako Sato', }, getUsers: AdB2cMockUsers, diff --git a/dictation_server/src/gateways/redis/redis.service.ts b/dictation_server/src/gateways/redis/redis.service.ts index e932a19..1fd7445 100644 --- a/dictation_server/src/gateways/redis/redis.service.ts +++ b/dictation_server/src/gateways/redis/redis.service.ts @@ -80,7 +80,7 @@ export class RedisService { */ async mget(keys: string[]): Promise<{ key: string; value: T | null }[]> { if (keys.length === 0) return []; // mget操作は0件の時エラーとなるため、0件は特別扱いする - + try { const records = await this.cacheManager.store.mget(...keys); // getで取得した順序とKeysの順序は一致するはずなので、indexを利用してペアになるよう加工する diff --git a/dictation_server/src/repositories/tasks/entity/task.entity.ts b/dictation_server/src/repositories/tasks/entity/task.entity.ts index 124d4d1..7abb66f 100644 --- a/dictation_server/src/repositories/tasks/entity/task.entity.ts +++ b/dictation_server/src/repositories/tasks/entity/task.entity.ts @@ -10,6 +10,8 @@ import { JoinColumn, OneToMany, ManyToOne, + CreateDateColumn, + UpdateDateColumn, } from 'typeorm'; import { bigintTransformer } from '../../../common/entity'; @@ -37,8 +39,24 @@ export class Task { started_at: Date | null; @Column({ nullable: true, type: 'datetime' }) finished_at: Date | null; - @Column({}) + + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; + + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; + + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; + + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date; @OneToOne(() => AudioFile, (audiofile) => audiofile.task) @JoinColumn({ name: 'audio_file_id' }) file: AudioFile | null; diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 63a813a..8ac71b6 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -1,10 +1,12 @@ import { Injectable } from '@nestjs/common'; import { DataSource, + EntityManager, FindOptionsOrder, FindOptionsOrderValue, In, IsNull, + Repository, } from 'typeorm'; import { Task } from './entity/task.entity'; import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; @@ -35,6 +37,8 @@ import { import { Roles } from '../../common/types/role'; import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus'; import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity'; +import { Workflow } from '../workflows/entity/workflow.entity'; +import { Worktype } from '../worktypes/entity/worktype.entity'; @Injectable() export class TasksRepositoryService { @@ -710,18 +714,6 @@ export class TasksRepositoryService { task.audio_file_id = savedAudioFile.id; - const optionItems = paramOptionItems.map((x) => { - return { - audio_file_id: savedAudioFile.id, - label: x.optionItemLabel, - value: x.optionItemValue, - }; - }); - - const optionItemRepo = entityManager.getRepository(AudioOptionItem); - const newAudioOptionItems = optionItemRepo.create(optionItems); - await optionItemRepo.save(newAudioOptionItems); - const taskRepo = entityManager.getRepository(Task); // アカウント内でJOBナンバーが有効なタスクのうち最新のものを取得 @@ -743,8 +735,19 @@ export class TasksRepositoryService { } task.job_number = newJobNumber; - const newTask = taskRepo.create(task); - const persisted = await taskRepo.save(newTask); + const persisted = await taskRepo.save(task); + + const optionItems = paramOptionItems.map((x) => { + return { + audio_file_id: persisted.audio_file_id, + label: x.optionItemLabel, + value: x.optionItemValue, + }; + }); + + const optionItemRepo = entityManager.getRepository(AudioOptionItem); + const newAudioOptionItems = optionItemRepo.create(optionItems); + await optionItemRepo.save(newAudioOptionItems); return persisted; }, ); @@ -952,6 +955,231 @@ export class TasksRepositoryService { return tasks; }); } + + /** + * worktypeIdをもとにルーティングルールを取得し、タスクのチェックアウト権限を設定する + * @param audioFileId + * @param accountId + * @param worktypeId + * @param [myAuthorId] + * @returns typistIds: タイピストIDの一覧 / typistGroupIds: タイピストグループIDの一覧 + */ + async autoRouting( + audioFileId: number, + accountId: number, + worktypeId: string, // ユーザーが任意につけるworktypeId(DBのcustom_worktype_id) + myAuthorId?: string, // API実行者のAuthorId + ): Promise<{ typistIds: number[]; typistGroupIds: number[] }> { + return await this.dataSource.transaction(async (entityManager) => { + // 音声ファイルを取得 + const audioFileRepo = entityManager.getRepository(AudioFile); + const audioFile = await audioFileRepo.findOne({ + relations: { + task: true, + }, + where: { + id: audioFileId, + account_id: accountId, + }, + }); + if (!audioFile) { + throw new Error( + `audio file not found. audio_file_id:${audioFileId}, accountId:${accountId}`, + ); + } + + const { task } = audioFile; + + if (!task) { + throw new Error( + `task not found. audio_file_id:${audioFileId}, accountId:${accountId}`, + ); + } + // authorIdをもとにユーザーを取得 + const userRepo = entityManager.getRepository(User); + const authorUser = await userRepo.findOne({ + where: { + author_id: audioFile.author_id, + account_id: accountId, + }, + }); + if (!authorUser) { + throw new Error( + `user not found. authorId:${audioFile.author_id}, accountId:${accountId}`, + ); + } + // ユーザーが任意につけるworktypeIdをもとにworktypeを取得 + const worktypeRepo = entityManager.getRepository(Worktype); + const worktypeRecord = await worktypeRepo.findOne({ + where: { + custom_worktype_id: worktypeId, + account_id: accountId, + }, + }); + if (!worktypeRecord) { + throw new Error( + `worktype not found. worktype:${worktypeId}, accountId:${accountId}`, + ); + } + + // Workflow(ルーティングルール)を取得 + const workflowRepo = entityManager.getRepository(Workflow); + const workflow = await workflowRepo.findOne({ + relations: { + workflowTypists: true, + }, + where: { + account_id: accountId, + author_id: authorUser.id, + worktype_id: worktypeRecord.id, + }, + }); + + // Workflow(ルーティングルール)があればタスクのチェックアウト権限を設定する + if (workflow) { + return await this.setCheckoutPermissionAndTemplate( + workflow, + task, + accountId, + entityManager, + userRepo, + ); + } + + // 音声ファイルの情報からルーティングルールを取得できない場合は、 + // API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得する + // API実行者のAuthorIdがない場合はエラーを出して終了 + if (!myAuthorId) { + throw new Error(`There is no AuthorId for the API executor.`); + } + // API実行者のAuthorIdをもとにユーザーを取得 + const myAuthorUser = await userRepo.findOne({ + where: { + author_id: myAuthorId, + account_id: accountId, + }, + }); + if (!myAuthorUser) { + throw new Error( + `user not found. authorId:${myAuthorId}, accountId:${accountId}`, + ); + } + const defaultWorkflow = await workflowRepo.findOne({ + relations: { + workflowTypists: true, + }, + where: { + account_id: accountId, + author_id: myAuthorUser.id, + worktype_id: worktypeRecord.id, + }, + }); + + // API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得できない場合はエラーを出して終了 + if (!defaultWorkflow) { + throw new Error( + `workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktype:${worktypeId}`, + ); + } + + // Workflow(ルーティングルール)があればタスクのチェックアウト権限を設定する + return await this.setCheckoutPermissionAndTemplate( + defaultWorkflow, + task, + accountId, + entityManager, + userRepo, + ); + }); + } + + /** + * workflowに紐づけられているタイピスト・タイピストグループで、タスクのチェックアウト権限を設定 + * workflowに紐づけられているテンプレートファイルIDをタスクに設定 + * + * @param workflow + * @param task + * @param accountId + * @param entityManager + * @param userRepo + * @returns checkout permission + */ + private async setCheckoutPermissionAndTemplate( + workflow: Workflow, + task: Task, + accountId: number, + entityManager: EntityManager, + userRepo: Repository, + ): Promise<{ typistIds: number[]; typistGroupIds: number[] }> { + const { workflowTypists, template_id } = workflow; + if (!workflowTypists) { + throw new Error(`workflowTypists not found. workflowId:${workflow.id}`); + } + + // タスクのテンプレートIDを更新 + const taskRepo = entityManager.getRepository(Task); + await taskRepo.update( + { id: task.id }, + { + template_file_id: template_id, + }, + ); + + // 取得したルーティングルールのタイピストまたはタイピストグループをチェックアウト権限に設定する + + // ルーティング候補ユーザーの存在確認 + const typistIds = workflowTypists.flatMap((typist) => + typist.typist_id ? [typist.typist_id] : [], + ); + const typistUsers = await userRepo.find({ + where: { account_id: accountId, id: In(typistIds) }, + }); + if (typistUsers.length !== typistIds.length) { + throw new Error(`typist not found. ids: ${typistIds}`); + } + + // ルーティング候補ユーザーグループの存在確認 + const groupIds = workflowTypists.flatMap((typist) => { + return typist.typist_group_id ? [typist.typist_group_id] : []; + }); + const userGroupRepo = entityManager.getRepository(UserGroup); + const typistGroups = await userGroupRepo.find({ + where: { account_id: accountId, id: In(groupIds) }, + }); + if (typistGroups.length !== groupIds.length) { + throw new Error(`typist group not found. ids: ${groupIds}`); + } + + const checkoutPermissionRepo = + entityManager.getRepository(CheckoutPermission); + + // 当該タスクに紐づく既存checkoutPermissionをdelete + await checkoutPermissionRepo.delete({ + task_id: task.id, + }); + + // ルーティング候補ユーザーのチェックアウト権限を作成 + const typistPermissions = typistUsers.map((typistUser) => { + const permission = new CheckoutPermission(); + permission.task_id = task.id; + permission.user_id = typistUser.id; + return permission; + }); + // ルーティング候補ユーザーグループのチェックアウト権限を作成 + const typistGroupPermissions = typistGroups.map((typistGroup) => { + const permission = new CheckoutPermission(); + permission.task_id = task.id; + permission.user_group_id = typistGroup.id; + return permission; + }); + const permissions = [...typistPermissions, ...typistGroupPermissions]; + await checkoutPermissionRepo.save(permissions); + // user_idsとuser_group_idsを返却する + return { + typistIds: typistIds, + typistGroupIds: groupIds, + }; + } } // ソート用オブジェクトを生成する